CVE-2026-41692
Description
i18nextify is a JavaScript library that adds website internationalization via a script tag, without source code changes. Versions prior to 4.0.8 substitute {{key}} interpolation tokens inside src and href attribute values with the raw string returned by i18next.t(). The substitution logic in src/localize.js (the replaceInside handler) only guards against a duplicated http:// origin prefix — it does not validate the URL scheme of the substituted value. A translated value such as javascript:alert(1) or data:text/html,<script>...</script> is applied unchanged to the live DOM attribute when an attacker can influence the content of a translation file or the translation-backend response — for example, via a compromised translation CDN, user-contributed locales, a MITM on a plain-HTTP backend, or write access to the translation JSON. This issue was patched in version 4.0.8.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
i18nextifynpm | < 4.0.8 | 4.0.8 |
Affected products
1- Range: < 4.0.8
Patches
116f23dbcdcf8security: hardening for 4.0.8
10 files changed · +492 −47
CHANGELOG.md+10 −0 modified@@ -1,3 +1,13 @@ +### 4.0.8 + +Security release — all issues found via an internal audit. GHSA advisory filed after release. + +- security: drop dangerous URL schemes (`javascript:`, `data:`, `vbscript:`, `file:`) when substituted into translated `href` / `src` attribute values. No legitimate translation use case needs these schemes; the previous substitution logic applied them unchanged to the live DOM. Scheme matching is case-insensitive and ignores leading whitespace (GHSA-TBD) +- security: new `sanitize(html, ctx)` option — if configured, it is invoked with each translated HTML body before it is parsed into the virtual DOM. Defaults to pass-through because rendering HTML from translations is i18nextify's core purpose; applications whose translation sources are not fully trusted (user-contributed locales, third-party translation CDN, etc.) can wire it to DOMPurify or a similar sanitizer. +- security: fix `debug` / `saveMissing` URL-parameter detection. The previous substring match (`window.location.search.indexOf('debug=true') > -1`) activated these modes for any URL containing the substring — for example `?nosaveMissing=true` silently enabled `saveMissing`, and `?track_debug=true` enabled verbose debug logging. Now uses `URLSearchParams` for an exact parameter match. +- chore: bump pinned `i18next` to 26.0.6 and `i18next-http-backend` to 3.0.5 — both security releases. See their respective CHANGELOG entries and GHSA advisories. +- chore: ignore `.env*` and `*.pem`/`*.key` files in `.gitignore`. + ### 4.0.7 - update i18next dependencies
.gitignore+7 −0 modified@@ -18,3 +18,10 @@ node_modules/**/* coverage/**/* dist/**/* package-lock.json + +# Secrets & credentials +.env +.env.* +!.env.example +*.pem +*.key
i18nextify.js+196 −22 modified@@ -295,6 +295,7 @@ } forward(args, lvl, prefix, debugOnly) { if (debugOnly && !this.debug) return null; + args = args.map(a => isString(a) ? a.replace(/[\r\n\x00-\x1F\x7F]/g, ' ') : a); if (isString(args[0])) args[0] = "".concat(prefix).concat(this.prefix, " ").concat(args[0]); return this.logger[lvl](args); } @@ -1187,8 +1188,8 @@ this.prefix = prefix ? regexEscape(prefix) : prefixEscaped || '{{'; this.suffix = suffix ? regexEscape(suffix) : suffixEscaped || '}}'; this.formatSeparator = formatSeparator || ','; - this.unescapePrefix = unescapeSuffix ? '' : unescapePrefix || '-'; - this.unescapeSuffix = this.unescapePrefix ? '' : unescapeSuffix || ''; + this.unescapePrefix = unescapeSuffix ? '' : unescapePrefix ? regexEscape(unescapePrefix) : '-'; + this.unescapeSuffix = this.unescapePrefix ? '' : unescapeSuffix ? regexEscape(unescapeSuffix) : ''; this.nestingPrefix = nestingPrefix ? regexEscape(nestingPrefix) : nestingPrefixEscaped || regexEscape('$t('); this.nestingSuffix = nestingSuffix ? regexEscape(nestingSuffix) : nestingSuffixEscaped || regexEscape(')'); this.nestingOptionsSeparator = nestingOptionsSeparator || ','; @@ -1232,6 +1233,9 @@ })); }; this.resetRegExp(); + if (!this.escapeValue && typeof str === 'string' && /\$t\([^)]*\{[^}]*\{\{/.test(str)) { + this.logger.warn('nesting options string contains interpolated variables with escapeValue: false — ' + 'if any of those values are attacker-controlled they can inject additional ' + 'nesting options (e.g. redirect lng/ns). Sanitise untrusted input before passing ' + 'it to t(), or keep escapeValue: true.'); + } var missingInterpolationHandler = (options === null || options === void 0 ? void 0 : options.missingInterpolationHandler) || this.options.missingInterpolationHandler; var skipOnVariables = (options === null || options === void 0 || (_options$interpolatio2 = options.interpolation) === null || _options$interpolatio2 === void 0 ? void 0 : _options$interpolatio2.skipOnVariables) !== undefined ? options.interpolation.skipOnVariables : this.options.interpolation.skipOnVariables; var todos = [{ @@ -1908,7 +1912,7 @@ deferred.resolve(t); callback(err, t); }; - if (this.languages && !this.isInitialized) return finish(null, this.t.bind(this)); + if ((this.languages || this.isLanguageChangingTo) && !this.isInitialized) return finish(null, this.t.bind(this)); this.changeLanguage(this.options.lng, finish); }; if (this.options.resources || !this.options.initAsync) { @@ -2290,6 +2294,66 @@ var loadNamespaces = instance.loadNamespaces; var loadLanguages = instance.loadLanguages; + function _createForOfIteratorHelper(r, e) { + var t = "undefined" != typeof Symbol && r[Symbol.iterator] || r["@@iterator"]; + if (!t) { + if (Array.isArray(r) || (t = _unsupportedIterableToArray(r)) || e && r && "number" == typeof r.length) { + t && (r = t); + var _n = 0, + F = function F() {}; + return { + s: F, + n: function n() { + return _n >= r.length ? { + done: !0 + } : { + done: !1, + value: r[_n++] + }; + }, + e: function e(r) { + throw r; + }, + f: F + }; + } + throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); + } + var o, + a = !0, + u = !1; + return { + s: function s() { + t = t.call(r); + }, + n: function n() { + var r = t.next(); + return a = r.done, r; + }, + e: function e(r) { + u = !0, o = r; + }, + f: function f() { + try { + a || null == t.return || t.return(); + } finally { + if (u) throw o; + } + } + }; + } + function _unsupportedIterableToArray(r, a) { + if (r) { + if ("string" == typeof r) return _arrayLikeToArray(r, a); + var t = {}.toString.call(r).slice(8, -1); + return "Object" === t && r.constructor && (t = r.constructor.name), "Map" === t || "Set" === t ? Array.from(r) : "Arguments" === t || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(t) ? _arrayLikeToArray(r, a) : void 0; + } + } + function _arrayLikeToArray(r, a) { + (null == a || a > r.length) && (a = r.length); + for (var e = 0, n = Array(a); e < a; e++) n[e] = r[e]; + return n; + } function _typeof(o) { "@babel/helpers - typeof"; @@ -2299,6 +2363,35 @@ return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o; }, _typeof(o); } + var UNSAFE_KEYS = ['__proto__', 'constructor', 'prototype']; + function isSafeUrlSegment(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; + if (/[?#%\s@]/.test(v)) return false; + if (/[\x00-\x1F\x7F]/.test(v)) return false; + return true; + } + function sanitizeLogValue(v) { + if (typeof v !== 'string') return v; + return v.replace(/[\r\n\x00-\x1F\x7F]/g, ' '); + } + function redactUrlCredentials(u) { + if (typeof u !== 'string' || u.length === 0) return u; + try { + var parsed = new URL(u); + if (parsed.username || parsed.password) { + parsed.username = ''; + parsed.password = ''; + return parsed.toString(); + } + return u; + } catch (e) { + return u.replace(/(\/\/)[^/@\s]+@/g, '$1'); + } + } function hasXMLHttpRequest() { return typeof XMLHttpRequest === 'function' || (typeof XMLHttpRequest === "undefined" ? "undefined" : _typeof(XMLHttpRequest)) === 'object'; } @@ -2312,11 +2405,32 @@ return Promise.resolve(maybePromise); } var interpolationRegexp = /\{\{(.+?)\}\}/g; - function interpolate(str, data) { - return str.replace(interpolationRegexp, function (match, key) { - var value = data[key.trim()]; - return value != null ? value : match; + function interpolateUrl(str, data) { + var unsafe = false; + var result = str.replace(interpolationRegexp, function (match, key) { + var k = key.trim(); + if (UNSAFE_KEYS.indexOf(k) > -1) return match; + var value = data[k]; + if (value == null) return match; + var segments = String(value).split('+'); + var _iterator = _createForOfIteratorHelper(segments), + _step; + try { + for (_iterator.s(); !(_step = _iterator.n()).done;) { + var seg = _step.value; + if (!isSafeUrlSegment(seg)) { + unsafe = true; + return match; + } + } + } catch (err) { + _iterator.e(err); + } finally { + _iterator.f(); + } + return segments.join('+'); }); + return unsafe ? null : result; } function ownKeys$1(e, r) { @@ -2401,10 +2515,13 @@ }).catch(function () {}); } catch (e) {} } + var UNSAFE_KEYS$1 = ['__proto__', 'constructor', 'prototype']; var addQueryString = function addQueryString(url, params) { if (params && _typeof$1(params) === 'object') { var queryString = ''; - for (var paramName in params) { + for (var _i = 0, _Object$keys = Object.keys(params); _i < _Object$keys.length; _i++) { + var paramName = _Object$keys[_i]; + if (UNSAFE_KEYS$1.indexOf(paramName) > -1) continue; queryString += '&' + encodeURIComponent(paramName) + '=' + encodeURIComponent(params[paramName]); } if (!queryString) return url; @@ -2437,7 +2554,6 @@ fetchApi(url, fetchOptions).then(resolver).catch(callback); } }; - var omitFetchOptions = false; var requestWithFetch = function requestWithFetch(options, url, payload, callback) { if (options.queryStringParams) { url = addQueryString(url, options.queryStringParams); @@ -2452,7 +2568,7 @@ method: payload ? 'POST' : 'GET', body: payload ? options.stringify(payload) : undefined, headers: headers - }, omitFetchOptions ? {} : reqOptions); + }, options._omitFetchOptions ? {} : reqOptions); var altFetch = typeof options.alternateFetch === 'function' && options.alternateFetch.length >= 1 ? options.alternateFetch : undefined; try { fetchIt(url, fetchOptions, callback, altFetch); @@ -2465,7 +2581,7 @@ delete fetchOptions[opt]; }); fetchIt(url, fetchOptions, callback, altFetch); - omitFetchOptions = true; + options._omitFetchOptions = true; } catch (err) { callback(err); } @@ -2494,7 +2610,9 @@ var h = options.customHeaders; h = typeof h === 'function' ? h() : h; if (h) { - for (var i in h) { + for (var _i2 = 0, _Object$keys2 = Object.keys(h); _i2 < _Object$keys2.length; _i2++) { + var i = _Object$keys2[_i2]; + if (UNSAFE_KEYS$1.indexOf(i) > -1) continue; x.setRequestHeader(i, h[i]); } } @@ -2666,10 +2784,15 @@ loadPath = makePromise(loadPath); loadPath.then(function (resolvedLoadPath) { if (!resolvedLoadPath) return callback(null, {}); - var url = interpolate(resolvedLoadPath, { + var url = interpolateUrl(resolvedLoadPath, { lng: languages.join('+'), ns: namespaces.join('+') }); + if (url == null) { + var safeLngs = languages.map(sanitizeLogValue).join(', '); + var safeNss = namespaces.map(sanitizeLogValue).join(', '); + return callback(new Error('i18next-http-backend: unsafe lng/ns value — refusing to build request URL for languages=[' + safeLngs + '] namespaces=[' + safeNss + ']'), false); + } _this2.loadUrl(url, callback, loadUrlLanguages, loadUrlNamespaces); }); } @@ -2680,16 +2803,17 @@ var lng = typeof languages === 'string' ? [languages] : languages; var ns = typeof namespaces === 'string' ? [namespaces] : namespaces; var payload = this.options.parseLoadPayload(lng, ns); + var safeUrl = sanitizeLogValue(redactUrlCredentials(url)); this.options.request(this.options, url, payload, function (err, res) { - if (res && (res.status >= 500 && res.status < 600 || !res.status)) return callback('failed loading ' + url + '; status code: ' + res.status, true); - if (res && res.status >= 400 && res.status < 500) return callback('failed loading ' + url + '; status code: ' + res.status, false); + if (res && (res.status >= 500 && res.status < 600 || !res.status)) return callback('failed loading ' + safeUrl + '; status code: ' + res.status, true); + if (res && res.status >= 400 && res.status < 500) return callback('failed loading ' + safeUrl + '; status code: ' + res.status, false); if (!res && err && err.message) { var errorMessage = err.message.toLowerCase(); var isNetworkError = ['failed', 'fetch', 'network', 'load'].find(function (term) { return errorMessage.indexOf(term) > -1; }); if (isNetworkError) { - return callback('failed loading ' + url + ': ' + err.message, true); + return callback('failed loading ' + safeUrl + ': ' + sanitizeLogValue(err.message), true); } } if (err) return callback(err, false); @@ -2701,7 +2825,7 @@ ret = res.data; } } catch (e) { - parseErr = 'failed parsing ' + url + ' to json'; + parseErr = 'failed parsing ' + safeUrl + ' to json'; } if (parseErr) return callback(parseErr, false); callback(null, ret); @@ -2722,10 +2846,15 @@ if (typeof _this4.options.addPath === 'function') { addPath = _this4.options.addPath(lng, namespace); } - var url = interpolate(addPath, { + var url = interpolateUrl(addPath, { lng: lng, ns: namespace }); + if (url == null) { + finished += 1; + if (callback && finished === languages.length) callback(dataArray, resArray); + return; + } _this4.options.request(_this4.options, url, payload, function (data, res) { finished += 1; dataArray.push(data); @@ -6434,6 +6563,16 @@ } var replaceInside = ['src', 'href']; var REGEXP = new RegExp('%7B%7B(.+?)%7D%7D', 'g'); // urlEncoded {{}} + + // Reject URL schemes that execute script when used in href/src — regardless + // of whether the attacker-controlled value reaches the attribute via a + // compromised translation backend, a compromised translation file, or a + // local override. The list covers the concrete known-exploitable schemes; + // legitimate translation use cases never need them. + var DANGEROUS_URL_SCHEMES = /^\s*(javascript|data|vbscript|file)\s*:/i; + function isDangerousUrl(value) { + return typeof value === 'string' && DANGEROUS_URL_SCHEMES.test(value); + } function translateProps(node, props) { var tOptions = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {}; var overrideKey = arguments.length > 3 ? arguments[3] : undefined; @@ -6483,7 +6622,13 @@ mem.splice(index - 1, 1); } } - mem.push(tr); + // Drop dangerous URL schemes (javascript:/data:/vbscript:/file:) — + // no legitimate translation needs them in src/href. + if (isDangerousUrl(tr)) { + mem.push(''); + } else { + mem.push(tr); + } } return mem; }, arr); @@ -6560,7 +6705,20 @@ } // translate that's children and surround it again with a dummy node to parse to vdom - var translation = "<i18nextifydummy>".concat(translate(key, tOptions, overrideKey), "</i18nextifydummy>"); + var translated = translate(key, tOptions, overrideKey); + // Optional sanitize hook — if the application has configured + // `i18next.options.sanitize` (e.g. wired to DOMPurify), run the raw + // translation through it before parsing into the virtual DOM. + // Default is pass-through because i18nextify's whole purpose is to + // render HTML from translations; sanitisation is only safe for apps + // where translation content may not be fully trusted. + if (typeof instance.options.sanitize === 'function') { + translated = instance.options.sanitize(translated, { + key, + attribute: null + }); + } + var translation = "<i18nextifydummy>".concat(translated, "</i18nextifydummy>"); var newNode = vdomParser((translation || '').trim()); // replace children on passed in node @@ -6715,8 +6873,24 @@ cleanWhitespace: true, nsSeparator: '#||#', keySeparator: '#|#', - debug: window.location.search && window.location.search.indexOf('debug=true') > -1, - saveMissing: window.location.search && window.location.search.indexOf('saveMissing=true') > -1, + // Use URLSearchParams for exact parameter lookup — a previous substring + // match (`indexOf('debug=true')`) activated these modes for any URL that + // happened to contain the substring (`?nosaveMissing=true` enabled + // saveMissing; `?track_debug=true` enabled debug). + debug: (() => { + try { + return new URLSearchParams(window.location.search).get('debug') === 'true'; + } catch (e) { + return false; + } + })(), + saveMissing: (() => { + try { + return new URLSearchParams(window.location.search).get('saveMissing') === 'true'; + } catch (e) { + return false; + } + })(), namespace: scriptEle && scriptEle.getAttribute('namespace') || false, namespaceFromPath: scriptEle && (scriptEle.getAttribute('namespacefrompath') || scriptEle.getAttribute('namespaceFromPath')) || false, missingKeyHandler: missingHandler,
i18nextify.min.js+3 −3 modified__mocks__/i18next.js+9 −0 modified@@ -32,6 +32,7 @@ const options = { }; let tCalls = []; +let translations = null; const resetCalls = () => { tCalls = []; }; @@ -40,8 +41,16 @@ module.exports = { resetOptions(opts) { this.options = parseOptions({ ...options, ...opts }); }, + // Test-only: override the stubbed translation return values. `map` is + // `{ key: returnValue, ... }`. Call with null to clear. + setTranslations(map) { + translations = map; + }, t(k, opts = {}) { tCalls.push({ k, opts }); + if (translations && Object.prototype.hasOwnProperty.call(translations, k)) { + return translations[k]; + } return `#${opts.defaultValue || k}#`; }, getCalls(reset = true) {
package.json+10 −10 modified@@ -18,27 +18,27 @@ "homepage": "https://github.com/i18next/i18nextify", "bugs": "https://github.com/i18next/i18nextify/issues", "dependencies": { - "@babel/runtime": "^7.28.6", - "i18next": "26.0.3", + "@babel/runtime": "^7.29.2", + "i18next": "26.0.6", "i18next-browser-languagedetector": "8.2.1", - "i18next-http-backend": "3.0.4", + "i18next-http-backend": "3.0.5", "udc": "1.0.1", "vdom-parser": "1.4.1", "vdom-to-html": "2.3.1", "vdom-virtualize": "2.0.0", "virtual-dom": "2.1.1" }, "devDependencies": { - "@babel/cli": "^7.28.3", - "@babel/core": "^7.28.5", + "@babel/cli": "^7.28.6", + "@babel/core": "^7.29.0", "@babel/plugin-proposal-async-generator-functions": "^7.19.1", "@babel/plugin-proposal-object-rest-spread": "^7.18.9", - "@babel/plugin-transform-modules-commonjs": "^7.27.1", - "@babel/plugin-transform-runtime": "^7.28.5", + "@babel/plugin-transform-modules-commonjs": "^7.28.6", + "@babel/plugin-transform-runtime": "^7.29.0", "@babel/polyfill": "^7.2.5", - "@babel/preset-env": "^7.28.5", + "@babel/preset-env": "^7.29.2", "@babel/preset-react": "^7.28.5", - "@babel/register": "^7.28.3", + "@babel/register": "^7.28.6", "eslint": "^8.57.1", "eslint-plugin-import": "2.32.0", "eslint-plugin-jsx-a11y": "6.10.2", @@ -47,7 +47,7 @@ "jest": "^24.9.0", "jest-cli": "^24.9.0", "mkdirp": "3.0.1", - "rimraf": "6.1.2", + "rimraf": "6.1.3", "rollup": "1.27.8", "rollup-plugin-babel": "^4.3.2", "rollup-plugin-commonjs": "^10.0.2",
README.md+62 −0 modified@@ -89,6 +89,11 @@ See the [example](https://github.com/i18next/i18nextify/tree/master/example/simp namespaceFromPath: false // set true will use namepace based on window.location.pathname ns: ['common'] // -> only set if accessing more then one namepace + // optional: sanitize hook invoked on every translated HTML body before + // it is rendered into the DOM. Default is pass-through. See "Security + // considerations" below for when to wire this up (e.g. DOMPurify). + sanitize: (html, ctx) => html, + // + all options available in i18next }); </script> @@ -287,6 +292,63 @@ window.i18nextify.changeNamespace('newNamespace'); window.i18nextify.forceRerender(); ``` +## Security considerations + +i18nextify's core job is to localise a live HTML page by replacing text and +certain attribute values with translations. Because formatting tags inside +translations (`<b>`, `<em>`, `<a href="…">`, …) are a supported feature, +translation strings are **rendered as HTML** rather than as plain text. +That power comes with a trust assumption: + +> **The translation source must be fully controlled by the developer.** +> Treat every translation value as if it were code shipped in your bundle. + +Concretely that means: + +- Serve the translation backend over **HTTPS** (MITM on plain HTTP could + swap translations for attacker HTML). +- Do **not** accept user-contributed translations into the live backend + without an explicit sanitisation step. +- If translations are authored by multiple people, treat the authoring + workflow like code review — the same way you'd review any other source + that ends up executed in the browser. + +### The `sanitize` hook + +Since v4.0.8, you can pass a `sanitize(html, ctx)` function via init options +(or via `i18next.init({ ... })`). It is invoked on every translated HTML +body before it is parsed into the virtual DOM. This is the right place to +wire [DOMPurify](https://github.com/cure53/DOMPurify) or a similar sanitizer +if your translation sources are **partially** trusted (third-party CDN, +user-contributed locales, content owned by a different team): + +```js +import DOMPurify from 'dompurify'; + +window.i18nextify.init({ + sanitize: (html, ctx) => DOMPurify.sanitize(html, { ADD_ATTR: ['target'] }), + // …other options +}); +``` + +`ctx` currently contains `{ key, attribute }` so you can apply stricter +rules for specific keys if needed. The hook defaults to pass-through — do +not enable sanitisation blindly, as it will strip the very HTML markup +this library is designed to render. + +### Blocked URL schemes in `href` / `src` + +Since v4.0.8, values substituted into translated `href` / `src` attributes +are dropped if they begin with `javascript:`, `data:`, `vbscript:`, or +`file:` (case-insensitive, leading whitespace ignored). Legitimate +translation use cases never need these schemes, so the protection is on by +default and cannot be disabled. + +### Reporting a vulnerability + +Please **do not** open a public GitHub issue for security problems. Send +reports privately via the [GitHub Security Advisories](https://github.com/i18next/i18nextify/security/advisories/new) +flow on the repository. ---
src/index.js+18 −6 modified@@ -36,12 +36,24 @@ function getDefaults() { cleanWhitespace: true, nsSeparator: '#||#', keySeparator: '#|#', - debug: - window.location.search && - window.location.search.indexOf('debug=true') > -1, - saveMissing: - window.location.search && - window.location.search.indexOf('saveMissing=true') > -1, + // Use URLSearchParams for exact parameter lookup — a previous substring + // match (`indexOf('debug=true')`) activated these modes for any URL that + // happened to contain the substring (`?nosaveMissing=true` enabled + // saveMissing; `?track_debug=true` enabled debug). + debug: (() => { + try { + return new URLSearchParams(window.location.search).get('debug') === 'true'; + } catch (e) { + return false; + } + })(), + saveMissing: (() => { + try { + return new URLSearchParams(window.location.search).get('saveMissing') === 'true'; + } catch (e) { + return false; + } + })(), namespace: (scriptEle && scriptEle.getAttribute('namespace')) || false, namespaceFromPath: (scriptEle && (scriptEle.getAttribute('namespacefrompath') || scriptEle.getAttribute('namespaceFromPath'))) || false, missingKeyHandler: missingHandler,
src/localize.js+28 −6 modified@@ -68,6 +68,16 @@ function translate(str, options = {}, overrideKey) { const replaceInside = ['src', 'href']; const REGEXP = new RegExp('%7B%7B(.+?)%7D%7D', 'g'); // urlEncoded {{}} + +// Reject URL schemes that execute script when used in href/src — regardless +// of whether the attacker-controlled value reaches the attribute via a +// compromised translation backend, a compromised translation file, or a +// local override. The list covers the concrete known-exploitable schemes; +// legitimate translation use cases never need them. +const DANGEROUS_URL_SCHEMES = /^\s*(javascript|data|vbscript|file)\s*:/i; +function isDangerousUrl(value) { + return typeof value === 'string' && DANGEROUS_URL_SCHEMES.test(value); +} function translateProps( node, props, @@ -143,7 +153,13 @@ function translateProps( mem.splice(index -1 , 1); } } - mem.push(tr); + // Drop dangerous URL schemes (javascript:/data:/vbscript:/file:) — + // no legitimate translation needs them in src/href. + if (isDangerousUrl(tr)) { + mem.push(''); + } else { + mem.push(tr); + } } return mem; }, arr); @@ -262,11 +278,17 @@ function walk( } // translate that's children and surround it again with a dummy node to parse to vdom - const translation = `<i18nextifydummy>${translate( - key, - tOptions, - overrideKey - )}</i18nextifydummy>`; + let translated = translate(key, tOptions, overrideKey); + // Optional sanitize hook — if the application has configured + // `i18next.options.sanitize` (e.g. wired to DOMPurify), run the raw + // translation through it before parsing into the virtual DOM. + // Default is pass-through because i18nextify's whole purpose is to + // render HTML from translations; sanitisation is only safe for apps + // where translation content may not be fully trusted. + if (typeof i18next.options.sanitize === 'function') { + translated = i18next.options.sanitize(translated, { key, attribute: null }); + } + const translation = `<i18nextifydummy>${translated}</i18nextifydummy>`; const newNode = parser((translation || '').trim()); // replace children on passed in node
test/localize/security.spec.js+149 −0 added@@ -0,0 +1,149 @@ +import { createRunner } from '../helpers'; +import i18next from 'i18next'; + +// Security tests for fixes shipped in 4.0.8. +// See CHANGELOG for the associated GHSA advisory. + +describe('security: javascript:/data: URL scheme blocklist in href/src substitutions', () => { + const runner = createRunner(); + + beforeEach(() => { + i18next.setTranslations(null); + }); + + afterAll(() => { + i18next.setTranslations(null); + }); + + it('drops javascript: URL when substituted into href', () => { + i18next.setTranslations({ malurl: 'javascript:alert(1)' }); + // Use `run` with only the source and expectedResult (keys check skipped) + runner.run( + '<a href="{{malurl}}">link</a>', + undefined, // we don't assert on exact output — just on the schemes below + ); + // Re-run to capture output + // (the runner already executed; now just ensure no dangerous scheme escapes) + }); + + // Below tests exercise the full pipeline via html → localize → html roundtrip + const cases = [ + { + name: 'javascript: scheme stripped from interpolated href', + translations: { bad: 'javascript:alert(1)' }, + input: '<a href="{{bad}}">x</a>', + mustNotContain: ['javascript:', 'javascript%3A'], + }, + { + name: 'data:text/html scheme stripped from interpolated href', + translations: { bad: 'data:text/html,<script>alert(1)</script>' }, + input: '<a href="{{bad}}">x</a>', + mustNotContain: ['data:text/html', 'data%3A'], + }, + { + name: 'vbscript: scheme stripped from interpolated src', + translations: { bad: 'vbscript:msgbox("x")' }, + input: '<img src="{{bad}}">', + mustNotContain: ['vbscript:'], + }, + { + name: 'file: scheme stripped from interpolated href', + translations: { bad: 'file:///etc/passwd' }, + input: '<a href="{{bad}}">x</a>', + mustNotContain: ['file://'], + }, + { + name: 'upper/mixed-case javascript: also blocked (regex is /i)', + translations: { bad: 'JaVaScRiPt:alert(1)' }, + input: '<a href="{{bad}}">x</a>', + mustNotContain: ['javascript:', 'JaVaScRiPt:'], + }, + { + name: 'leading whitespace before javascript: still blocked', + translations: { bad: ' javascript:alert(1)' }, + input: '<a href="{{bad}}">x</a>', + mustNotContain: ['javascript:'], + }, + ]; + + const VNode = require('virtual-dom/vnode/vnode'); + const VText = require('virtual-dom/vnode/vtext'); + const convertHTML = require('html-to-vdom')({ VNode, VText }); + const toHTML = require('vdom-to-html'); + const localize = require('../../src/localize').default; + + cases.forEach((c) => { + it(c.name, () => { + i18next.setTranslations(c.translations); + const node = convertHTML(c.input.trim()); + const result = toHTML(localize(node)); + c.mustNotContain.forEach((needle) => { + expect(result).not.toContain(needle); + }); + }); + }); + + it('still allows legitimate http/https URLs through interpolation', () => { + i18next.setTranslations({ ok: 'https://example.com/x.png' }); + const node = convertHTML('<img src="{{ok}}">'); + const result = toHTML(localize(node)); + expect(result).toContain('https://example.com/x.png'); + }); + + it('still allows relative URLs through interpolation', () => { + i18next.setTranslations({ ok: '/assets/logo.png' }); + const node = convertHTML('<img src="{{ok}}">'); + const result = toHTML(localize(node)); + expect(result).toContain('/assets/logo.png'); + }); +}); + +describe('security: optional sanitize hook for translated HTML', () => { + const VNode = require('virtual-dom/vnode/vnode'); + const VText = require('virtual-dom/vnode/vtext'); + const convertHTML = require('html-to-vdom')({ VNode, VText }); + const toHTML = require('vdom-to-html'); + const localize = require('../../src/localize').default; + + beforeEach(() => { + i18next.setTranslations(null); + }); + + afterAll(() => { + i18next.setTranslations(null); + }); + + it('calls the configured sanitize function on each translated HTML body (merge mode)', () => { + const seen = []; + i18next.resetOptions({ + sanitize: (s, ctx) => { + seen.push({ s, ctx }); + return s.replace(/<script[^>]*>.*?<\/script>/gi, ''); + }, + }); + // text content IS the key — mock returns HTML with a <script> + i18next.setTranslations({ + hello: 'hi <script>alert(1)</script>', + }); + // `merge=""` attribute forces the HTML-parsing code path + const node = convertHTML('<div merge="">hello</div>'); + const result = toHTML(localize(node)); + // sanitize was invoked with the raw translated HTML + expect(seen.length).toBeGreaterThan(0); + expect(seen[0].s).toContain('<script>'); + // and the <script> was stripped from the final output + expect(result).not.toContain('<script>'); + expect(result).not.toContain('alert(1)'); + }); + + it('defaults to pass-through when no sanitize is configured (library\'s core purpose: render HTML)', () => { + i18next.resetOptions({}); // no sanitize + i18next.setTranslations({ + hello: 'hi <em>world</em>', + }); + const node = convertHTML('<div merge="">hello</div>'); + const result = toHTML(localize(node)); + // Legitimate HTML formatting survives (that's the library's whole point) + expect(result).toContain('<em>'); + }); +});
Vulnerability mechanics
AI mechanics synthesis has not run for this CVE yet.
References
4News mentions
0No linked articles in our index yet.