CVE-2026-48714
Description
i18next-http-middleware prior to 3.9.7 fails to reject dotted prototype-polluting keys in missingKeyHandler, enabling remote prototype pollution when used with vulnerable backends.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
i18next-http-middleware prior to 3.9.7 fails to reject dotted prototype-polluting keys in missingKeyHandler, enabling remote prototype pollution when used with vulnerable backends.
Vulnerability
i18next-http-middleware versions prior to 3.9.7 contain a prototype pollution vulnerability in the missingKeyHandler function. While literal request-body keys __proto__, constructor, and prototype were blocked (added in 3.9.3), dotted variants such as __proto__.polluted were not rejected. Downstream backends that split the missing-key string on a configured keySeparator (notably i18next-fs-backend ≤ 2.6.5) pass these keys to an unguarded setPath() walker that writes to Object.prototype. The issue is fixed in version 3.9.7 [1][2].
Exploitation
An attacker can send an HTTP request to an endpoint that exposes missingKeyHandler to untrusted input, with a JSON body containing keys like "__proto__.polluted": "value". The application must use a vulnerable backend (e.g., i18next-fs-backend ≤ 2.6.5) that splits keys on the configured keySeparator (default .). No authentication is required if the endpoint is publicly accessible. The attacker simply submits the crafted request, and the backend writes the polluted key to Object.prototype [2].
Impact
Successful exploitation results in remote prototype pollution. The attacker can set arbitrary properties on Object.prototype, which can cause crashes, corrupted translation behavior, configuration poisoning, or bypasses of property-based security checks. The impact depends on the host application but can affect all users and potentially lead to privilege escalation or denial of service [2].
Mitigation
The vulnerability is fixed in i18next-http-middleware version 3.9.7 [1]. The fix introduces a hasUnsafeKeySegment() helper that rejects keys whose segments contain __proto__, constructor, or prototype after splitting on the configured keySeparator. The root cause is also fixed in i18next-fs-backend 2.6.6 [2]. If immediate upgrade is not possible, workarounds include: not exposing missingKeyHandler to untrusted users (mount behind authentication or remove the route), adding a request-body filter that rejects top-level keys containing unsafe segments after splitting on the configured keySeparator, and disabling missing-key persistence (saveMissing: false) when accepting writes from untrusted input [2]. The CVE is not listed in the Known Exploited Vulnerabilities (KEV) catalog.
AI Insight generated on Jun 15, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
3(expand)+ 1 more
- (no CPE)
- (no CPE)range: <3.9.7
- Range: <=2.6.5
Patches
17c6d26f137d3security: reject missing keys whose split segments hit __proto__ / constructor / prototype
4 files changed · +156 −2
CHANGELOG.md+4 −0 modified@@ -1,3 +1,7 @@ +## [v3.9.7](https://github.com/i18next/i18next-http-middleware/compare/v3.9.6...v3.9.7) +Security release — coordinated disclosure from [@codeswhite](https://github.com/codeswhite). See published advisory [GHSA-xxxx-xxxx-xxxx](https://github.com/i18next/i18next-http-middleware/security/advisories/GHSA-xxxx-xxxx-xxxx). +- security: extend the `missingKeyHandler` prototype-pollution guard to also reject dotted (or otherwise `keySeparator`-segmented) keys whose segments contain `__proto__`, `constructor`, or `prototype`. The 3.9.3 fix blocked the literal keys but missed dotted variants like `__proto__.polluted`, which downstream backends that split the missing-key string on `keySeparator` before persisting (notably `i18next-fs-backend` ≤ 2.6.5 in its now-patched `writeFile()` path) walked into `Object.prototype`. New `utils.hasUnsafeKeySegment(key, keySeparator)` helper is now used by `missingKeyHandler`; the configured `i18next.options.keySeparator` is honoured (default `.`; `false` disables segment splitting and only the literal-key denylist applies). Pairs with the root-cause fix in `i18next-fs-backend` 2.6.6. Credit: [@codeswhite](https://github.com/codeswhite) ([GHSA-xxxx-xxxx-xxxx](https://github.com/i18next/i18next-http-middleware/security/advisories/GHSA-xxxx-xxxx-xxxx)). + ## [v3.9.6](https://github.com/i18next/i18next-http-middleware/compare/v3.9.5...v3.9.6) - Fix cookie header being overwritten instead of appended
lib/index.js+5 −2 modified@@ -311,12 +311,15 @@ export function missingKeyHandler (i18next, options = {}) { } const body = options.getBody(req) + const keySeparator = i18next.options && i18next.options.keySeparator - // iterate only over own, non-prototype-polluting keys + // iterate only over own, non-prototype-polluting keys. The check also + // rejects dotted variants like `__proto__.polluted` whose segments under + // the configured keySeparator land on an unsafe key — see utils.js. const saveMissingKeys = src => { if (!src || typeof src !== 'object') return for (const m of Object.keys(src)) { - if (utils.UNSAFE_KEYS.indexOf(m) > -1) continue + if (utils.hasUnsafeKeySegment(m, keySeparator)) continue i18next.services.backendConnector.saveMissing([lng], ns, m, src[m]) } }
lib/utils.js+21 −0 modified@@ -43,6 +43,27 @@ export function isSafeNsIdentifier (v) { // exported this symbol; external callers (if any) get the pre-fix behaviour. export const isSafeIdentifier = isSafeLangIdentifier +// Defense-in-depth check for `missingKeyHandler`. The literal-key denylist +// catches `{"__proto__": ...}` but misses dotted variants like +// `{"__proto__.polluted": "x"}` — backends that split the missing-key string +// on a configured keySeparator (e.g. i18next-fs-backend's writeFile()) would +// hand that key to an unguarded setPath and walk into Object.prototype. +// Reject any key whose segments — under the configured keySeparator (default +// `.`) — include an unsafe key. keySeparator === false means "no splitting", +// in which case only the literal-key check applies. +export function hasUnsafeKeySegment (key, keySeparator) { + if (typeof key !== 'string') return false + if (UNSAFE_KEYS.indexOf(key) > -1) return true + if (keySeparator === false) return false + const sep = keySeparator || '.' + if (key.indexOf(sep) < 0) return false + const segments = key.split(sep) + for (const s of segments) { + if (UNSAFE_KEYS.indexOf(s) > -1) return true + } + return false +} + export function setPath (object, path, newValue) { let stack if (typeof path !== 'string') stack = [].concat(path)
test/security.js+126 −0 modified@@ -83,6 +83,132 @@ describe('security', () => { expect(keys).not.to.contain('prototype') expect(({}).isAdmin).to.be(undefined) }) + + it('ignores dotted keys whose segments contain __proto__ / constructor / prototype', () => { + // Defense-in-depth for the case where a downstream backend splits + // the missing-key string on keySeparator before persisting (e.g. + // i18next-fs-backend's writeFile). Without this, `__proto__.polluted` + // would reach the backend as `['__proto__','polluted']`. + const saved = [] + const fakeI18next = { + options: { keySeparator: '.' }, + services: { + backendConnector: { + saveMissing (lngs, ns, key, value) { saved.push({ lngs, ns, key, value }) } + } + } + } + const handler = missingKeyHandler(fakeI18next, { + getParams: () => ({ lng: 'en', ns: 'translation' }), + getBody: () => ({ + '__proto__.polluted': 'PWNED', + 'constructor.prototype.polluted': 'PWNED', + 'a.__proto__.polluted': 'PWNED', + 'a.prototype': 'PWNED', + 'header.title': 'Welcome' + }), + send: (_res, msg) => msg, + setStatus: () => {} + }) + handler({}, {}) + const keys = saved.map(s => s.key) + // legitimate dotted key (no unsafe segment) is still forwarded + expect(keys).to.contain('header.title') + // all unsafe-segment variants are dropped + expect(keys).not.to.contain('__proto__.polluted') + expect(keys).not.to.contain('constructor.prototype.polluted') + expect(keys).not.to.contain('a.__proto__.polluted') + expect(keys).not.to.contain('a.prototype') + expect(({}).polluted).to.be(undefined) + expect(Object.prototype.polluted).to.be(undefined) + }) + + it('respects custom keySeparator (rejects unsafe segments under that sep)', () => { + const saved = [] + const fakeI18next = { + options: { keySeparator: ':' }, + services: { + backendConnector: { + saveMissing (lngs, ns, key, value) { saved.push({ lngs, ns, key, value }) } + } + } + } + const handler = missingKeyHandler(fakeI18next, { + getParams: () => ({ lng: 'en', ns: 'translation' }), + getBody: () => ({ + '__proto__:polluted': 'PWNED', + 'header:title': 'Welcome', + // a `.` is just a normal char when sep=:, so this is a legitimate + // (if unusual) single key — keep it + '__proto__.polluted': 'odd-but-safe-under-:-sep' + }), + send: (_res, msg) => msg, + setStatus: () => {} + }) + handler({}, {}) + const keys = saved.map(s => s.key) + expect(keys).to.contain('header:title') + expect(keys).to.contain('__proto__.polluted') + expect(keys).not.to.contain('__proto__:polluted') + }) + + it('keySeparator=false: only literal unsafe keys are rejected', () => { + const saved = [] + const fakeI18next = { + options: { keySeparator: false }, + services: { + backendConnector: { + saveMissing (lngs, ns, key, value) { saved.push({ lngs, ns, key, value }) } + } + } + } + const handler = missingKeyHandler(fakeI18next, { + getParams: () => ({ lng: 'en', ns: 'translation' }), + getBody: () => ({ + // literal — still rejected + __proto__: 'no', + // single string, no splitting at the backend + '__proto__.polluted': 'ok', + 'header.title': 'ok' + }), + send: (_res, msg) => msg, + setStatus: () => {} + }) + handler({}, {}) + const keys = saved.map(s => s.key) + expect(keys).not.to.contain('__proto__') + expect(keys).to.contain('__proto__.polluted') + expect(keys).to.contain('header.title') + }) + }) + + describe('utils.hasUnsafeKeySegment', () => { + it('rejects literal unsafe keys', () => { + expect(utils.hasUnsafeKeySegment('__proto__', '.')).to.be(true) + expect(utils.hasUnsafeKeySegment('constructor', '.')).to.be(true) + expect(utils.hasUnsafeKeySegment('prototype', '.')).to.be(true) + }) + it('rejects dotted keys whose segments are unsafe', () => { + expect(utils.hasUnsafeKeySegment('__proto__.polluted', '.')).to.be(true) + expect(utils.hasUnsafeKeySegment('a.__proto__', '.')).to.be(true) + expect(utils.hasUnsafeKeySegment('a.constructor.prototype', '.')).to.be(true) + }) + it('accepts legitimate dotted keys', () => { + expect(utils.hasUnsafeKeySegment('header.title', '.')).to.be(false) + expect(utils.hasUnsafeKeySegment('a.b.c', '.')).to.be(false) + }) + it('respects keySeparator', () => { + expect(utils.hasUnsafeKeySegment('__proto__:x', ':')).to.be(true) + expect(utils.hasUnsafeKeySegment('__proto__.polluted', ':')).to.be(false) + }) + it('keySeparator=false skips segment splitting (only literal check)', () => { + expect(utils.hasUnsafeKeySegment('__proto__', false)).to.be(true) + expect(utils.hasUnsafeKeySegment('__proto__.polluted', false)).to.be(false) + }) + it('passes non-string keys through unchanged (truthy keys are stringified upstream)', () => { + expect(utils.hasUnsafeKeySegment(undefined, '.')).to.be(false) + expect(utils.hasUnsafeKeySegment(null, '.')).to.be(false) + }) }) describe('utils.isSafeLangIdentifier (strict — for `lng`)', () => {
Vulnerability mechanics
Root cause
"The missingKeyHandler's literal-key denylist did not reject dotted keys whose segments, after splitting on the configured keySeparator, contain __proto__, constructor, or prototype."
Attack vector
An unauthenticated attacker sends an HTTP request to an endpoint that exposes `missingKeyHandler` with a JSON body containing a dotted key such as `__proto__.polluted` [ref_id=1]. The middleware's existing literal-key denylist does not catch this variant, so the key is forwarded to a downstream backend (e.g. `i18next-fs-backend` ≤ 2.6.5) that splits the string on its configured `keySeparator` and writes the resulting segments into an unguarded `setPath()` walker, achieving remote prototype pollution [patch_id=6112939]. The CVSS vector confirms network-based, low-complexity exploitation with no authentication required.
Affected code
The vulnerability resides in `missingKeyHandler` in `lib/index.js` and the new `hasUnsafeKeySegment` helper in `lib/utils.js`. The 3.9.3 fix only blocked literal keys `__proto__`, `constructor`, and `prototype`, but dotted variants like `__proto__.polluted` passed through and were split by downstream backends (notably `i18next-fs-backend` ≤ 2.6.5) on their `keySeparator`, leading to `Object.prototype` pollution [patch_id=6112939].
What the fix does
The patch introduces `utils.hasUnsafeKeySegment(key, keySeparator)` which splits the key on the configured `keySeparator` (default `.`) and rejects the key if any segment matches `__proto__`, `constructor`, or `prototype` [patch_id=6112939]. When `keySeparator` is `false`, only the literal-key check applies. The `missingKeyHandler` in `lib/index.js` now calls this helper instead of the simple `UNSAFE_KEYS.indexOf` check, closing the bypass for dotted variants.
Preconditions
- configThe application must expose missingKeyHandler to untrusted HTTP input (e.g. a route that accepts POST bodies without authentication).
- configA downstream backend (such as i18next-fs-backend ≤ 2.6.5) must split the missing-key string on keySeparator and write it via an unguarded setPath() walker.
- inputThe attacker must be able to send arbitrary JSON keys in the request body.
Generated on Jun 15, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
2News mentions
0No linked articles in our index yet.