CVE-2026-41683
Description
i18next-http-middleware is a middleware to be used with Node.js web frameworks like express or Fastify and also for Deno. Prior to version 3.9.3, i18next-http-middleware wrote user-controlled language values into the Content-Language response header after passing them through utils.escape(), which is an HTML-entity encoder that does not strip carriage return, line feed, or other control characters. When the application used an older i18next (< 19.5.0) that still exercised the backward-compatibility fallback at LanguageDetector.js:100 or otherwise produced a raw detected value, CRLF sequences in the attacker-controlled lng parameter reached res.setHeader('Content-Language', ...) verbatim. This issue has been patched in version 3.9.3.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
i18next-http-middlewarenpm | < 3.9.3 | 3.9.3 |
Affected products
1- Range: <3.9.3
Patches
165301c194593security: harden setPath, CRLF, missingKeyHandler, getResourcesHandler, hasXSS, cookie SameSite
16 files changed · +287 −48
CHANGELOG.md+10 −0 modified@@ -1,3 +1,13 @@ +## [v3.9.3](https://github.com/i18next/i18next-http-middleware/compare/v3.9.2...v3.9.3) +Security release — all issues found via an internal audit. GHSA advisories filed after release. +- security: guard `utils.setPath` against prototype pollution via crafted `lng`/`ns` in `getResourcesHandler` (GHSA-TBD) +- security: sanitise `Content-Language` response header to prevent CRLF injection / unhandled `ERR_INVALID_CHAR` crash via unsanitised language codes (GHSA-TBD) +- security: skip inherited/prototype-polluting keys (`__proto__`, `constructor`, `prototype`) in `missingKeyHandler` request body +- security: filter unsafe `lng`/`ns` values in `getResourcesHandler` (reject path-traversal, path separators, control characters, prototype keys, over-long inputs) to prevent path traversal / SSRF via the backend connector and unbounded growth of the shared `i18next.options.ns` array. Any legitimate language code shape is still accepted — i18next permits arbitrary codes ([FAQ](https://www.i18next.com/how-to/faq#how-should-the-language-codes-be-formatted)) +- security: `hasXSS` regex now catches event handlers in any attribute position (previously only matched when the handler was the first attribute; e.g. `<input autofocus onfocus=…>` bypassed the filter) +- security: automatically set the `Secure` flag on the language cookie when `cookieSameSite: 'none'` — browsers reject `SameSite=None` without `Secure` +- chore: ignore `.env*` and `*.pem`/`*.key` files in `.gitignore` + ## [v3.9.2](https://github.com/i18next/i18next-http-middleware/compare/v3.9.1...v3.9.2) - TS definition for default export [#100](https://github.com/i18next/i18next-http-middleware/issues/100)
.github/workflows/deno.yml+2 −2 modified@@ -20,9 +20,9 @@ jobs: # os: [ubuntu-latest, windows-latest, macOS-latest] os: [ubuntu-latest] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v6 - name: Setup Deno - uses: denolib/setup-deno@master + uses: denoland/setup-deno@v2 with: deno-version: ${{ matrix.deno }} - run: deno --version
.github/workflows/node.yml+3 −3 modified@@ -16,13 +16,13 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - node: [ '22.x', '20.x', '18.x' ] + node: [ '24.x', '22.x', '20.x' ] # os: [ubuntu-latest, windows-latest, macOS-latest] os: [ubuntu-latest] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v6 - name: Setup Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v6 with: node-version: ${{ matrix.node }} - run: npm install
.gitignore+5 −0 modified@@ -4,3 +4,8 @@ package-lock.json yarn.lock cjs esm +.env +.env.* +!.env.example +*.pem +*.key
lib/index.js+21 −8 modified@@ -69,7 +69,7 @@ export function handle (i18next, options = {}) { } if (lng && options.getHeader(res, 'Content-Language') !== lng) { - options.setHeader(res, 'Content-Language', utils.escape(lng)) + options.setHeader(res, 'Content-Language', utils.sanitizeHeaderValue(lng)) } req.languages = i18next.services.languageUtils.toResolveHierarchy(lng) @@ -88,7 +88,7 @@ export function handle (i18next, options = {}) { // set locale req.language = req.locale = req.lng = lng if (lng && options.getHeader(res, 'Content-Language') !== lng) { - options.setHeader(res, 'Content-Language', utils.escape(lng)) + options.setHeader(res, 'Content-Language', utils.sanitizeHeaderValue(lng)) } req.languages = i18next.services.languageUtils.toResolveHierarchy(lng) req.resolvedLanguage = i18n.resolvedLanguage @@ -243,6 +243,14 @@ export function getResourcesHandler (i18next, options = {}) { : [] } + // Drop user-supplied values containing patterns that could trigger path + // traversal / SSRF / prototype pollution when forwarded to the backend + // connector. i18next itself permits arbitrary language codes, so we do + // not impose a BCP-47 shape — we only block known-dangerous patterns. + // See: https://www.i18next.com/how-to/faq#how-should-the-language-codes-be-formatted + languages = languages.filter(utils.isSafeIdentifier) + namespaces = namespaces.filter(utils.isSafeIdentifier) + // extend ns namespaces.forEach(ns => { if (i18next.options.ns && i18next.options.ns.indexOf(ns) < 0) { @@ -290,23 +298,28 @@ export function missingKeyHandler (i18next, options = {}) { const body = options.getBody(req) + // iterate only over own, non-prototype-polluting keys + const saveMissingKeys = src => { + if (!src || typeof src !== 'object') return + for (const m of Object.keys(src)) { + if (utils.UNSAFE_KEYS.indexOf(m) > -1) continue + i18next.services.backendConnector.saveMissing([lng], ns, m, src[m]) + } + } + if (typeof body === 'function') { const promise = body() if (promise && typeof promise.then === 'function') { return new Promise(resolve => { promise.then(b => { - for (const m in b) { - i18next.services.backendConnector.saveMissing([lng], ns, m, b[m]) - } + saveMissingKeys(b) resolve(options.send(res, 'ok')) }) }) } } - for (const m in body) { - i18next.services.backendConnector.saveMissing([lng], ns, m, body[m]) - } + saveMissingKeys(body) return options.send(res, 'ok') }
lib/languageLookups/cookie.js+7 −0 modified@@ -86,6 +86,13 @@ export default { if (options.cookieSecure) cookieOptions.secure = options.cookieSecure + // SameSite=None requires Secure — browsers reject the cookie otherwise. + // Force Secure in that case so misconfigured setups still function. + const sameSiteNormalised = typeof cookieOptions.sameSite === 'string' + ? cookieOptions.sameSite.toLowerCase() + : cookieOptions.sameSite + if (sameSiteNormalised === 'none') cookieOptions.secure = true + let existingCookie = options.getHeader(res, 'set-cookie') || options.getHeader(res, 'Set-Cookie') || [] if (typeof existingCookie === 'string') existingCookie = [existingCookie] if (!Array.isArray(existingCookie)) existingCookie = []
lib/utils.js+35 −2 modified@@ -1,3 +1,25 @@ +export const UNSAFE_KEYS = ['__proto__', 'constructor', 'prototype'] + +// Returns true if `v` can be safely forwarded as a language code or namespace +// identifier to a backend connector. Denylist approach (i18next permits any +// language-code shape — https://www.i18next.com/how-to/faq#how-should-the-language-codes-be-formatted) +// that blocks the concrete attack patterns without restricting legitimate use: +// - not one of `__proto__` / `constructor` / `prototype` (prototype pollution) +// - no path separators `/` or `\` (path traversal / SSRF in fs/http backends) +// - no `..` sequence (relative path traversal) +// - no control characters (header injection downstream, log forging) +// - non-empty, length <= 128 +export function isSafeIdentifier (v) { + if (typeof v !== 'string') return false + if (v.length === 0 || v.length > 128) return false + if (UNSAFE_KEYS.indexOf(v) > -1) return false + if (v.indexOf('..') > -1) return false + if (v.indexOf('/') > -1 || v.indexOf('\\') > -1) return false + // eslint-disable-next-line no-control-regex + if (/[\x00-\x1F\x7F]/.test(v)) return false + return true +} + export function setPath (object, path, newValue) { let stack if (typeof path !== 'string') stack = [].concat(path) @@ -6,12 +28,14 @@ export function setPath (object, path, newValue) { while (stack.length > 1) { let key = stack.shift() if (key.indexOf('###') > -1) key = key.replace(/###/g, '.') + if (UNSAFE_KEYS.indexOf(key) > -1) return // guard against prototype pollution if (!object[key]) object[key] = {} object = object[key] } let key = stack.shift() if (key.indexOf('###') > -1) key = key.replace(/###/g, '.') + if (UNSAFE_KEYS.indexOf(key) > -1) return // guard against prototype pollution object[key] = newValue } @@ -70,15 +94,24 @@ export function escape (str) { .replace(/`/g, '`')) } +// Strip control characters (CR, LF, NUL, other C0/C1) from a value before +// writing it into an HTTP header. Prevents HTTP response splitting on older +// Node.js, and unhandled ERR_INVALID_CHAR crashes on newer Node.js. +export function sanitizeHeaderValue (str) { + if (typeof str !== 'string') return str + // eslint-disable-next-line no-control-regex + return str.replace(/[\r\n\x00-\x1F\x7F]/g, '') +} + export function hasXSS (input) { if (typeof input !== 'string') return false // Common XSS attack patterns const xssPatterns = [ /<\s*script.*?>/i, /<\s*\/\s*script\s*>/i, - /<\s*img.*?on\w+\s*=/i, - /<\s*\w+\s*on\w+\s*=.*?>/i, + // event handlers on any tag position (not just first attribute) + /<\s*\w+\s+[^>]*?\bon\w+\s*=/i, /javascript\s*:/i, /vbscript\s*:/i, /expression\s*\(/i,
licence+1 −1 modified@@ -1,4 +1,4 @@ -Copyright (c) 2025 i18next +Copyright (c) 2020-present i18next Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal
package.json+16 −16 modified@@ -28,15 +28,15 @@ }, "module": "./esm/index.js", "devDependencies": { - "@babel/cli": "7.25.9", - "@babel/core": "7.26.0", - "@babel/preset-env": "7.26.0", - "@hapi/hapi": "^21.3.12", - "@opentelemetry/api": "^1.9.0", - "@preact/signals": "^2.2.1", - "@types/express-serve-static-core": "^5.0.1", - "@koa/router": "12.0.1", - "koa": "2.16.1", + "@babel/cli": "7.28.6", + "@babel/core": "7.29.0", + "@babel/preset-env": "7.29.2", + "@hapi/hapi": "^21.4.8", + "@opentelemetry/api": "^1.9.1", + "@preact/signals": "^2.9.0", + "@types/express-serve-static-core": "^5.1.1", + "@koa/router": "15.4.0", + "koa": "3.2.0", "babel-plugin-add-module-exports": "1.0.4", "eslint": "8.53.0", "eslint-config-standard": "17.1.0", @@ -46,14 +46,14 @@ "eslint-plugin-require-path-exists": "1.1.9", "eslint-plugin-standard": "5.0.0", "expect.js": "0.3.1", - "express": "4.21.1", + "express": "5.2.1", "fastify": "5.8.5", - "i18next": "25.5.2", - "mocha": "10.8.2", - "preact": "10.27.3", - "preact-render-to-string": "6.6.3", - "supertest": "7.0.0", - "tsd": "0.31.2", + "i18next": "26.0.5", + "mocha": "11.7.5", + "preact": "10.29.1", + "preact-render-to-string": "6.6.7", + "supertest": "7.2.2", + "tsd": "0.33.0", "uglify-js": "3.19.3" }, "description": "i18next-http-middleware is a middleware to be used with Node.js web frameworks like express or Fastify and also for Deno.",
README.md+0 −1 modified@@ -2,7 +2,6 @@ [](https://github.com/i18next/i18next-http-middleware/actions?query=workflow%3Anode) [](https://github.com/i18next/i18next-http-middleware/actions?query=workflow%3Adeno) -[](https://travis-ci.org/i18next/i18next-http-middleware) [](https://www.npmjs.com/package/i18next-http-middleware) This is a middleware to be used with Node.js web frameworks like express or Fastify and also for Deno.
test/addRoute.koa.js+1 −1 modified@@ -5,7 +5,7 @@ import Koa from 'koa' import Router from '@koa/router' import request from 'supertest' -const router = Router() +const router = new Router() i18next.init({ fallbackLng: 'en', preload: ['en', 'de'],
test/getResourcesHandler.koa.js+1 −1 modified@@ -5,7 +5,7 @@ import Koa from 'koa' import Router from '@koa/router' import request from 'supertest' -const router = Router() +const router = new Router() i18next.init({ fallbackLng: 'en', preload: ['en', 'de'],
test/middleware.koa.js+1 −1 modified@@ -5,7 +5,7 @@ import Koa from 'koa' import Router from '@koa/router' import request from 'supertest' -const router = Router() +const router = new Router() i18next.init({ fallbackLng: 'en', preload: ['en', 'de'],
test/missingKeyHandler.koa.js+1 −1 modified@@ -5,7 +5,7 @@ import Koa from 'koa' import Router from '@koa/router' import request from 'supertest' -const router = Router() +const router = new Router() i18next.init({ fallbackLng: 'en', preload: ['en', 'de'],
test/security.js+183 −0 added@@ -0,0 +1,183 @@ +import expect from 'expect.js' +import i18next from 'i18next' +import * as utils from '../lib/utils.js' +import LanguageDetector from '../lib/LanguageDetector.js' +import { getResourcesHandler, missingKeyHandler } from '../lib/index.js' + +// Tests covering the security fixes shipped in 3.9.3. +// See CHANGELOG.md for the associated advisories. + +describe('security', () => { + describe('utils.setPath', () => { + it('drops writes targeting __proto__, constructor, prototype', () => { + const target = {} + utils.setPath(target, ['__proto__', 'polluted'], 'yes') + utils.setPath(target, ['constructor', 'polluted'], 'yes') + utils.setPath(target, ['prototype', 'polluted'], 'yes') + expect(({}).polluted).to.be(undefined) + expect(Object.prototype.polluted).to.be(undefined) + }) + + it('still writes safe nested paths', () => { + const target = {} + utils.setPath(target, ['en', 'common'], { k: 'v' }) + expect(target.en.common.k).to.eql('v') + }) + }) + + describe('utils.sanitizeHeaderValue', () => { + it('strips CR, LF, NUL and other control characters', () => { + expect(utils.sanitizeHeaderValue('en\r\nX-Injected: bad')).to.eql('enX-Injected: bad') + expect(utils.sanitizeHeaderValue('en\u0000')).to.eql('en') + expect(utils.sanitizeHeaderValue('de-DE')).to.eql('de-DE') + }) + + it('passes non-string values through unchanged', () => { + expect(utils.sanitizeHeaderValue(undefined)).to.be(undefined) + expect(utils.sanitizeHeaderValue(null)).to.be(null) + }) + }) + + describe('utils.hasXSS', () => { + it('detects event handlers regardless of attribute position', () => { + // regression: /<\s*\w+\s*on\w+\s*=.*?>/i missed these + expect(utils.hasXSS('<input autofocus onfocus=alert(1)>')).to.be(true) + expect(utils.hasXSS('<details open ontoggle=alert(1)>')).to.be(true) + expect(utils.hasXSS('<body id=x onscroll=alert(1)>')).to.be(true) + }) + + it('still rejects obvious script tags and javascript: URIs', () => { + expect(utils.hasXSS('<script>alert(1)</script>')).to.be(true) + expect(utils.hasXSS('javascript:alert(1)')).to.be(true) + }) + + it('accepts normal language codes', () => { + expect(utils.hasXSS('en')).to.be(false) + expect(utils.hasXSS('de-DE')).to.be(false) + expect(utils.hasXSS('zh-Hant')).to.be(false) + }) + }) + + describe('missingKeyHandler', () => { + it('ignores __proto__/constructor/prototype keys in the request body', () => { + const saved = [] + const fakeI18next = { + services: { + backendConnector: { + saveMissing (lngs, ns, key, value) { saved.push({ lngs, ns, key, value }) } + } + } + } + const handler = missingKeyHandler(fakeI18next, { + getParams: () => ({ lng: 'en', ns: 'translation' }), + getBody: () => ({ key1: 'value1', __proto__: { isAdmin: true }, constructor: 'x', key2: 'value2' }), + send: (_res, msg) => msg, + setStatus: () => {} + }) + handler({}, {}) + const keys = saved.map(s => s.key) + expect(keys).to.contain('key1') + expect(keys).to.contain('key2') + expect(keys).not.to.contain('__proto__') + expect(keys).not.to.contain('constructor') + expect(keys).not.to.contain('prototype') + expect(({}).isAdmin).to.be(undefined) + }) + }) + + describe('utils.isSafeIdentifier', () => { + it('accepts arbitrary language codes (i18next permits any shape)', () => { + expect(utils.isSafeIdentifier('en')).to.be(true) + expect(utils.isSafeIdentifier('de-DE')).to.be(true) + expect(utils.isSafeIdentifier('en_US')).to.be(true) + expect(utils.isSafeIdentifier('zh-Hant-HK')).to.be(true) + expect(utils.isSafeIdentifier('pirate-speak')).to.be(true) + expect(utils.isSafeIdentifier('my-custom.ns')).to.be(true) + }) + + it('rejects path-traversal and prototype-pollution payloads', () => { + expect(utils.isSafeIdentifier('__proto__')).to.be(false) + expect(utils.isSafeIdentifier('constructor')).to.be(false) + expect(utils.isSafeIdentifier('prototype')).to.be(false) + expect(utils.isSafeIdentifier('../etc/passwd')).to.be(false) + expect(utils.isSafeIdentifier('..')).to.be(false) + expect(utils.isSafeIdentifier('foo/bar')).to.be(false) + expect(utils.isSafeIdentifier('foo\\bar')).to.be(false) + expect(utils.isSafeIdentifier('en\r\nX-Injected: bad')).to.be(false) + expect(utils.isSafeIdentifier('en\u0000')).to.be(false) + expect(utils.isSafeIdentifier('')).to.be(false) + expect(utils.isSafeIdentifier('a'.repeat(200))).to.be(false) + expect(utils.isSafeIdentifier(null)).to.be(false) + expect(utils.isSafeIdentifier({ toString: () => 'en' })).to.be(false) + }) + }) + + describe('getResourcesHandler', () => { + it('drops unsafe lng/ns values but accepts arbitrary safe ones', async () => { + const i18n = i18next.createInstance() + await i18n.init({ + fallbackLng: 'en', + ns: ['translation'], + resources: { en: { translation: { hi: 'hello' } } } + }) + + const nsBefore = [...i18n.options.ns] + + const loadCalls = [] + i18n.services.backendConnector.load = (lngs, nss, cb) => { + loadCalls.push({ lngs: [...lngs], nss: [...nss] }) + cb() + } + + const handler = getResourcesHandler(i18n, { + getQuery: () => ({ + lng: '__proto__ ../etc/passwd en pirate-speak', + ns: '__proto__ ../secrets translation custom.ns' + }), + getParams: () => ({}), + setContentType: () => {}, + setHeader: () => {}, + getHeader: () => undefined, + send: () => 'sent' + }) + handler({}, {}) + + await new Promise(resolve => setTimeout(resolve, 10)) + + expect(loadCalls).to.have.length(1) + // attack payloads dropped; legitimate values (including non-BCP-47 ones) kept + expect(loadCalls[0].lngs).to.eql(['en', 'pirate-speak']) + expect(loadCalls[0].nss).to.eql(['translation', 'custom.ns']) + expect(i18n.options.ns.filter(n => n.indexOf('..') > -1 || n === '__proto__')).to.eql([]) + expect(i18n.options.ns).to.contain(nsBefore[0]) + }) + }) + + describe('cookie SameSite=None enforces Secure', () => { + it('adds Secure automatically when SameSite=None is set', () => { + const ld = new LanguageDetector(i18next.services, { + order: ['cookie'], + cookieSameSite: 'none' + }) + const res = { headers: {} } + res.header = (name, value) => { res.headers[name] = value } + ld.cacheUserLanguage({}, res, 'en', ['cookie']) + const cookieStr = String(res.headers['Set-Cookie']) + expect(cookieStr).to.match(/SameSite=None/) + expect(cookieStr).to.match(/Secure/) + }) + + it('does not force Secure when SameSite is Lax', () => { + const ld = new LanguageDetector(i18next.services, { + order: ['cookie'], + cookieSameSite: 'lax' + }) + const res = { headers: {} } + res.header = (name, value) => { res.headers[name] = value } + ld.cacheUserLanguage({}, res, 'en', ['cookie']) + const cookieStr = String(res.headers['Set-Cookie']) + expect(cookieStr).to.match(/SameSite=Lax/) + expect(cookieStr).not.to.match(/Secure/) + }) + }) +})
.travis.yml+0 −11 removed@@ -1,11 +0,0 @@ -sudo: false -language: node_js -node_js: -- '12' -- '14' -branches: - only: - - master -notifications: - email: - - adriano@raiano.ch \ No newline at end of file
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4News mentions
0No linked articles in our index yet.