VYPR
Medium severity4.7GHSA Advisory· Published May 7, 2026· Updated May 8, 2026

CVE-2026-41692

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.

PackageAffected versionsPatched versions
i18nextifynpm
< 4.0.84.0.8

Affected products

1

Patches

1
16f23dbcdcf8

security: hardening for 4.0.8

https://github.com/i18next/i18nextifyAdriano RaianoApr 18, 2026via ghsa
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

4

News mentions

0

No linked articles in our index yet.