Severity Daily

IT and AI security incidents, checked against the primary source

Tag: OpenJS Foundation

  • moment’s locale guard checked for slashes, not for strings, and 2.31.0 is the project’s first release since December 2023

    moment’s locale guard checked for slashes, not for strings, and 2.31.0 is the project’s first release since December 2023

    The guard moment added in 2022 to stop path traversal in moment.locale() called a String method on whatever it was handed, so an object that implements match walks straight through it — and the fix arrives in the project’s first release since December 2023.

    What happened

    The OpenJS Foundation CNA published CVE-2026-17495 on September 15, 2026 at 6:16 a.m. UTC, with the matching advisory GHSA-4p3w-j4w9-5jqw. NVD carries a CVSS v3.1 base score of 5.9 Medium, vector CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:H/A:N, and CWE-27. GitHub rates it Moderate at the same 5.9.

    The affected range is moment 2.29.2 through 2.30.1. The fix is 2.31.0, which npm’s registry metadata dates to September 15, 2026 at 1:07 a.m. UTC. The release before it, 2.30.1, went out on December 27, 2023 — a gap of two years and nine months.

    The advisory is explicit that this is a second pass at an old problem. In its words, moment before 2.31.0 “is vulnerable to path traversal in moment.locale(). When an application passes a non-string, attacker-influenced value to moment.locale(),” a crafted object can circumvent validation and cause the library to load files from attacker-controlled paths. It calls this “a bypass of protections introduced in version 2.29.2 for CVE-2022-24785.” The vulnerability affects server-side npm users; standard string inputs remain unaffected.

    What makes the bypass work is visible in the shipped package. We downloaded the 2.30.1 and 2.31.0 tarballs from the registry and read the relevant function in each. In 2.30.1, at line 2145 of moment.js, the guard is:

    function isLocaleNameSane(name) {
        // Prevent names that look like filesystem paths, i.e contain '/' or '\'
        // Ensure name is available and function returns boolean
        return !!(name && name.match('^[^/\\\\]*$'));
    }

    That calls .match() on the caller’s value. String.prototype.match is the intended target, but nothing checks that the value is a string. Any object carrying its own match method satisfies the test. Two lines later, loadLocale builds the require path by concatenation — aliasedRequire('./locale/' + name) — and concatenation invokes the object’s toString(). The validated value and the used value are two different things produced by two different methods on the same object.

    In 2.31.0, at line 2262, the same function reads:

    function isLocaleNameSane(name) {
        // Only canonical locale module names are safe to append to the require path.
        return typeof name === 'string' && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name);
    }

    We ran it

    Extracting both functions verbatim from the shipped files and calling them directly: an object of the form { match: () => ['ok'], toString: () => '../../../../tmp/evil' } returns true from the 2.30.1 guard and false from the 2.31.0 guard. Concatenated onto the require prefix, that object produces the string ./locale/../../../../tmp/evil.

    A second result came out of the same test. The plain string '..' also returns true from the 2.30.1 guard, and false from the 2.31.0 one. The comment above the old guard says it prevents “names that look like filesystem paths,” but the check it actually performs is a two-character denylist for / and \, and .. contains neither. On its own this is not exploitable — require('./locale/..') resolves inside the moment package itself, so there is nothing attacker-controlled at the other end — but it shows the guard was never a path check. It was a slash filter with a comment describing something stronger.

    Why it matters

    The interesting part of this is not the severity, which is fairly rated at 5.9 with an AC:H and no confidentiality impact. It is the failure mode, a specific and recurring one: validating a value by calling a method on it rather than by checking its type. name.match(...) reads as a string operation and passes review as one. It is really a message send, and in JavaScript the receiver decides what it means. An attacker who controls the shape of the object controls both the answer the validator gets and the value the consumer uses, and the two need not agree.

    That is a generic pattern, not a moment-specific one. Anywhere a validator calls .match, .test, .startsWith, .includes or .indexOf on unvalidated input and a later consumer reaches the same value through string coercion, the same split exists. The defense is the one 2.31.0 adopted: check typeof first, then validate against an allowlist rather than a denylist. The new regex /^[a-z0-9]+(?:-[a-z0-9]+)*$/ describes what a locale name is, rather than enumerating two characters a path happens to contain. A denylist of forbidden characters has to anticipate every encoding of a path; an allowlist of permitted ones does not.

    The precondition is worth stating plainly, because it narrows who is exposed. The application has to be passing attacker-influenced input into moment.locale() without type-checking it — a request body field, a query parameter, or a header threaded into locale selection. Code that passes a validated string, or that selects from a fixed set, was never in the affected state. The require path is also gated on typeof module !== 'undefined' && module && module.exports, which is why the advisory scopes this to server-side npm users rather than browser bundles.

    The release itself deserves a warning of its own, and it is the practical risk here. moment 2.31.0 is not a one-line security patch. moment.js grew from 5,688 lines to 5,917, with 872 lines added and 643 removed against 2.30.1. The changelog lists eleven bug fixes beyond the CVE, several of which change observable behavior: min and max now ignore non-Moment arguments, time zone offset parsing validates its range through a new invalidOffset parsing flag that feeds isValid(), weekday-mismatch handling changed for partial-date formats, and conditional deprecation warnings now carry a stack trace. Two new locales arrive, and roughly a dozen existing ones have corrected abbreviations, date formats or plural rules — changes that will alter rendered strings in production. This is nearly three years of accumulated work shipped in one version, and organizations that treat a security upgrade as a drop-in are likely to find at least one of these in a test suite.

    One more item in that changelog is worth reading next to the CVE: PR #6442, “Fix locale('__proto__') corrupting the global locale.” That is a second defect in the same function, fixed in the same release, filed as an ordinary bug rather than a security issue. The rewritten loadLocale now uses hasOwnProp for its cache lookups, with a comment naming __proto__, constructor and prototype as the values that would otherwise resolve to inherited Object.prototype properties. Whether that deserved its own identifier is a judgment call the maintainers made, but anyone tracking this by CVE number alone will not see it.

    Finally, the timing. This is the second long-dormant JavaScript package to ship a security release through the OpenJS Foundation CNA within the same day — proxy-addr published 2.0.8 five hours later, its first release in more than four years, fixing a trust-list parsing flaw with a structurally similar root cause: a validator that accepted an input shape it was not designed for and returned the permissive answer. Both were stable by every ordinary measure. Stability is not review.

    What to do

    Upgrade moment to 2.31.0, and budget for a test pass rather than a version bump. The security fix is small; the release around it is not. Pay particular attention to anything that asserts on formatted output in a non-English locale, on isValid() results for inputs carrying time zone offsets, or on moment.min and moment.max called with mixed argument types.

    If you cannot upgrade immediately, the vendor’s workaround removes the vulnerable state without a dependency change: “Validate that any user-supplied input is a string before passing it to moment.locale().” A typeof check at the call site is sufficient, and it is worth adding regardless of the upgrade.

    To find out whether you were exposed at all, search for calls to moment.locale() and moment().locale() and trace their arguments back to their source. If every argument is a literal or a value that has already been checked against a fixed list of locale names, this did not apply to you on any version. If any of them originate in a request — an Accept-Language header, a lang query parameter, or a user profile field — that is the call site to fix first.

    Sourcing note

    Checked: the moment advisory GHSA-4p3w-j4w9-5jqw on GitHub; NVD’s record for CVE-2026-17495, read from the NVD REST API; npm registry metadata for moment, including publication timestamps for every release; and the published tarballs for moment 2.30.1 and 2.31.0, downloaded from the registry, diffed, and read. The guard functions quoted above are copied from the shipped moment.js in each version, and the behavioral results are ours, produced by calling those two functions directly with the inputs named.

    One small inconsistency, noted rather than resolved: the changelog bundled in the 2.31.0 tarball dates the release “Sep 14, 2026,” while npm’s registry timestamp for the same version is September 15, 2026 at 1:07 a.m. UTC. Both are consistent with a publish late on September 14 in a US time zone.

    Unresolved: neither the advisory nor the NVD record reports any exploitation, and we found no report of an application observed passing an unvalidated non-string into moment.locale(). The advisory credits four people across reporting, coordination and remediation but does not describe how the bypass was found. There is no published figure for how many applications thread request-controlled values into locale selection, which is the number that would give this a scale rather than a severity, and we are not estimating one. NVD’s record was published hours after the advisory and its affected-version data comes from the CNA.

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