Severity Daily

IT and AI security incidents, checked against the primary source

Tag: incomplete fix

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