Severity Daily

IT and AI security incidents, checked against the primary source

Tag: IP spoofing

  • Express’s req.ip returned attacker-supplied values when proxy-addr’s trust list used IPv4-mapped IPv6 notation

    Express’s req.ip returned attacker-supplied values when proxy-addr’s trust list used IPv4-mapped IPv6 notation

    proxy-addr 2.0.8, the package’s first release in more than four years, fixes a trust-list parsing flaw that made every unauthenticated client a trusted proxy — but only for applications that wrote their trust subnets a particular way.

    What happened

    The OpenJS Foundation CNA published CVE-2026-90711 on September 15, 2026, and the jshttp project published the matching advisory, GHSA-jqcg-44mw-7w3h, the same day. NVD’s record carries a CVSS v3.1 base score of 9.1 Critical, vector CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N, and three weakness classes: CWE-290, CWE-348, and CWE-697.

    The affected range is proxy-addr 1.1.0 through 2.0.7. The fix is 2.0.8, which npm’s registry metadata dates to September 15, 2026 at 6:12 a.m. UTC. The release before it, 2.0.7, was published on June 1, 2021 — this is the first proxy-addr release in more than four years.

    proxy-addr is the module Express uses to decide which network hops count as trusted proxies. It is what stands behind req.ip and req.ips when an application sets trust proxy. Express 4.22.3 declares proxy-addr at ~2.0.7; Express 5.2.1 declares it at ^2.0.7. Both ranges admit 2.0.8, which we confirmed by installing proxy-addr@~2.0.7 from the registry and reading the resolved version.

    The flaw is in how proxy-addr matched an IPv4 client address against an IPv6 trust subnet. The advisory describes it this way: an IPv4-mapped IPv6 address written “with short prefixes—such as ::ffff:10.0.0.0/8 instead of the correct ::ffff:10.0.0.0/104—cause[s] the subnet to match all IPv4 addresses rather than the intended block.” The same applies, the advisory says, to “any IPv6 trust subnet with zero leading bits, like ::/1.” And it notes the failure mode that makes this hard to catch: “The misconfiguration compiles silently without errors.”

    The consequence, in the advisory’s own words: “Every unauthenticated client is then trusted as a proxy at hop 0, so proxyaddr(req, trust), and therefore req.ip and req.ips in Express, returns whatever the client sends in X-Forwarded-For. This defeats IP-based access control, rate limiting, geolocation, and audit logging.”

    We ran it

    We installed proxy-addr 2.0.7 and 2.0.8 from the npm registry and compared them directly. With the trust list set to the single entry ['::ffff:10.0.0.0/8'] and a request arriving from 203.0.113.77 carrying the header X-Forwarded-For: 9.9.9.9:

    • On 2.0.7, proxyaddr(req, trust) returned 9.9.9.9 — the value the client supplied.
    • On 2.0.8, the same call returned 203.0.113.77 — the real socket address.

    The compiled trust function shows it more directly. On 2.0.7, proxyaddr.compile(['::ffff:10.0.0.0/8'])('203.0.113.77', 0) returns true, and so does the same call against ['::/1']. On 2.0.8 both return false. Two forms were never affected on either version: the correctly written ::ffff:10.0.0.0/104 returns false for a public address and true for 10.1.2.3, and plain IPv4 notation 10.0.0.0/8 returns false for the public address. That is the shape of the bug: one way of writing a private-range trust subnet silently expanded to the whole IPv4 address space.

    The shipped diff between the two tarballs is eleven added lines across the module’s two matching paths. Two of them canonicalize an IPv4-mapped candidate address down to IPv4 before matching, with the comment that a mapped address “cannot bypass the cross-family guard via the same-family path.” Two more refuse to let an IPv6 subnet span IPv4 unless subnetrange >= 96 and the subnet is genuinely IPv4-mapped. A third pair blocks the reverse direction — a native IPv6 candidate matching an IPv4-mapped subnet — which the advisory’s Patches and Workarounds text does not mention at all. The published fix is slightly wider than the published description of it.

    Why it matters

    A 9.1 with C:H/I:H reads like an unconditional break, and it is not one. The vulnerable state requires that the application itself wrote a trust subnet in IPv4-mapped IPv6 notation with a prefix that does not cover the mapped marker. An Express app that set app.set('trust proxy', 1), or listed '10.0.0.0/8', or used the built-in 'loopback' or 'uniquelocal' presets, was never in the affected state on any version. That distinction belongs in the first paragraph of any writeup, because the alternative is a scramble across every Node service in an organization to fix something most of them never had.

    What makes it worth the attention it is getting anyway is the direction of the failure. Security controls generally fail closed when they are misconfigured: a bad allowlist entry locks people out, and somebody files a ticket within the hour. This one failed open and silently. proxyaddr.compile() accepted ::ffff:10.0.0.0/8 without complaint, the application started, requests were served, and the only visible symptom was that req.ip reported values an operator had no reason to doubt. An access-control list keyed on req.ip kept returning allow decisions. A rate limiter kept counting distinct clients that were one client. Audit logs kept recording addresses. Everything downstream of the trust decision looked healthy precisely because the trust decision was wrong.

    That has a second-order cost that outlasts the patch. Any record produced by an affected deployment — rate-limit counters, geolocation-based routing, IP allowlist decisions, and above all audit logs — is untrustworthy for the period the misconfiguration was live, and there is no way to tell from the logs themselves which entries were real. Upgrading to 2.0.8 stops the bleeding; it does not tell you what the logs meant last month. Organizations that answer questions about who accessed what, from where, should be treating the affected window as a gap in the record rather than as data.

    There is also the matter of how the notation got written in the first place. ::ffff:10.0.0.0/8 is not a typo in the ordinary sense — it is what you get when someone reasons that 10.0.0.0/8 is the RFC 1918 block, that IPv4-mapped IPv6 is how you express an IPv4 address in an IPv6 context, and that the two combine. The arithmetic that makes the correct answer /104 rather than /8 — 96 bits of mapped prefix plus the 8 bits of network you actually want — is not obvious, and the library silently agreed with the wrong version of it. A validation layer that rejected the ambiguous form would have made this a startup error rather than a CVE.

    Finally, the release cadence is its own signal. proxy-addr sat at 2.0.7 for four years and three months. It is a small module with two direct dependencies, and by every ordinary measure it was finished. A package being stable is not the same as a package being reviewed, and this is the second long-dormant JavaScript package to ship a security release through the OpenJS Foundation CNA in the same 24-hour window — moment published 2.31.0 overnight to fix an incomplete guard of its own. Dependency trees are full of modules in exactly this condition, and the stability that makes them easy to ignore is what lets a parsing assumption sit unexamined for years.

    What to do

    Upgrade proxy-addr to 2.0.8. For most applications that means refreshing the transitive dependency rather than changing anything you wrote: both Express 4’s ~2.0.7 and Express 5’s ^2.0.7 resolve to 2.0.8 on a fresh install, so npm update proxy-addr or regenerating the lockfile is sufficient. Existing lockfiles pin 2.0.7 and will not move on their own.

    Before you upgrade, find out whether you were ever affected. Search your configuration for a trust proxy value — or any direct proxyaddr.compile() call — containing ::ffff: or a bare :: subnet. If there is no IPv6 trust subnet in your configuration at all, you were not in the affected state, and the upgrade is hygiene rather than remediation.

    If you find one, the vendor’s workaround stands on its own for anyone who cannot upgrade immediately: “Write IPv4 trust subnets in plain IPv4 notation (for example 10.0.0.0/8). If IPv4-mapped IPv6 notation is required, use the full form so the prefix covers the mapped marker (for example ::ffff:10.0.0.0/104 for the 10.0.0.0/8 block).” That is a configuration change, deployable without a dependency bump, and it removes the vulnerable state on 2.0.7.

    Where you find an affected configuration that was live, treat the exposure window as a logging and rate-limiting integrity problem, not only a patching one. Re-derive anything you can from data that did not come through req.ip — load balancer access logs, CDN logs, or the socket address recorded elsewhere in the stack — and flag the period in any audit record that will be relied on later.

    Sourcing note

    Checked: the jshttp advisory GHSA-jqcg-44mw-7w3h on GitHub; NVD’s record for CVE-2026-90711, read directly from the NVD REST API; the npm registry metadata for proxy-addr and Express, including publication timestamps and declared dependency ranges; and the published tarballs for proxy-addr 2.0.7 and 2.0.8, downloaded from the registry and diffed. The behavioral results above are ours, produced by installing both versions and calling proxyaddr and proxyaddr.compile against the addresses named; they are reproducible from the package as shipped.

    Unresolved: the advisory names three people — two reporters and a remediation developer — but does not say how the issue was found or whether any deployment was observed in the affected configuration. There is no exploitation reported by the vendor or by any party we could check, and no telemetry on how many applications use IPv4-mapped IPv6 notation in their trust lists — the number that would turn this from a severity figure into a scale figure. We did not find one and are not estimating it. NVD’s record was published hours after the advisory and had not completed analysis at the time of writing, so its affected-version data comes from the CNA rather than from NVD enrichment.