← 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.

Affected ip ≤ 2.0.1
Status Unpatched (upstream closed as "not planned")
Downloads 17M+ / week
Repository indutny/node-ip
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.
// 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

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
// 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)
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

// 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.

🟠 PullLight — High Finding
[SSRF] Canonicalization gap in IP validation — src/utils/ip-check.js:_checkSSRF (line ~42)

ip.isPublic(hostname) does not canonicalize shorthand (127.1), octal (017700000001), or hex (0x7f000001) IP notations before checking ranges. A bypass payload like http://017700000001/latest/meta-data/ (which resolves to 127.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 the isPublic() call returning true. This is a known bypass vector (CWE-20, CWE-697) introduced by the incomplete fix for CVE-2023-42282.
→ Canonicalize the IP string before calling isPublic(): parse through dns.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.

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.

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