CVE-2026-41886
Description
locize is a localization platform that connects code and i18n setup. Prior to version 4.0.21, the locize client SDK registers a window.addEventListener("message", …) handler that dispatches to registered internal handlers (editKey, commitKey, commitKeys, isLocizeEnabled, requestInitialize, …) without validating event.origin. The pre-patch listener in src/api/postMessage.js gates dispatch on event.data.sender === "i18next-editor-frame" — that value sits inside the attacker-controlled message payload, not the browser-enforced origin. Any web page that could embed or be embedded by a locize-enabled host — an iframe on a third-party page, a window.open-ed victim, a parent frame reaching down — could send a crafted postMessage and trigger the internal handlers. This issue has been patched in version 4.0.21.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
locizenpm | < 4.0.21 | 4.0.21 |
Affected products
1Patches
1d006b75fadb8security: postMessage hardening for 4.0.21
5 files changed · +120 −2
CHANGELOG.md+9 −0 modified@@ -1,3 +1,12 @@ +### 4.0.21 + +Security release — all issues found via an internal audit. GHSA advisory filed after release. + +- security: validate `event.origin` against the configured iframe origin (`getIframeUrl()`) at the top of the `window.addEventListener('message', …)` listener in `src/api/postMessage.js`. Prior to 4.0.21 the listener dispatched to registered handlers based only on attacker-controlled `event.data.sender` — any web page that could embed or be embedded by the locize-enabled host could invoke `editKey`, `commitKey`, `commitKeys`, `isLocizeEnabled`, `requestInitialize`, etc., against it. In combination with the `innerHTML`/`setAttribute` sinks in `handleEditKey`/`commitKeys`, this enabled cross-origin DOM XSS; via `isLocizeEnabled`, attackers could hijack `api.source`/`api.origin` and redirect all outgoing messages (CWE-346, CWE-79). The check is computed once at message time and uses the already-existing `getIframeUrl()` so custom environments (development/staging) continue to work (GHSA-TBD) +- security (defence-in-depth): harden the `editKey` handler in `src/api/handleEditKey.js`. On `attr:` writes, reject event-handler names (`on*`), `style`, and `javascript:` / `data:` / `vbscript:` / `file:` URLs on `href`/`src`/`action`/`formaction`/`xlink:href`. On `html` writes, parse the translation through a throwaway `DOMParser` document, strip `<script>`/`<iframe>`/`<object>`/`<embed>`/`<link>`/`<meta>`/`<base>`/`<style>` elements along with all event-handler attributes and dangerous URL schemes, then assign the sanitised result to `innerHTML`. Legitimate translation formatting (`<b>`, `<em>`, `<strong>`, `<a href="https://…">`, etc.) is preserved. +- security (defence-in-depth): reject malformed `containerStyle.height` / `.width` values in `src/api/handleRequestPopupChanges.js`. Values must match a strict CSS-length pattern (e.g. `420px`, `50%`, `12em`); anything carrying a semicolon, `url(…)`, `calc(…)` chain, or arbitrary property-injection escape is dropped. Prevents CSS-injection escapes from the attacker-controlled popup-resize payload. +- chore: ignore `.env*` and `*.pem`/`*.key` files in `.gitignore` + ### 4.0.20 - fix InContext editor not detecting translated text with trailing whitespace or line breaks (e.g. Angular templates where `{{ 'key' | i18next }}` is followed by a newline before the closing tag). The subliminal marker detection now fully trims template whitespace before checking.
.gitignore+7 −0 modified@@ -17,3 +17,10 @@ node_modules node_modules/**/* coverage/**/* dist/**/* + +# Secrets & credentials +.env +.env.* +!.env.example +*.pem +*.key
src/api/handleEditKey.js+65 −2 modified@@ -1,7 +1,65 @@ +/* global DOMParser */ import { wrap } from 'i18next-subliminal' import { api } from './postMessage.js' import { store } from '../store.js' +// Attributes that are never legitimate translation targets. Event handlers +// (on*) execute script; `style` allows CSS-exfil patterns; `href`/`src` +// carry URL schemes that can execute script. The editor iframe (origin- +// validated upstream) is the only legitimate caller, but this is a +// defence-in-depth layer — even a bug in the iframe or a compromised +// translator tool cannot use postMessage to plant an `onclick=alert(1)`. +const DANGEROUS_ATTR_NAMES = /^(on\w+|style)$/i +const URL_ATTR_NAMES = /^(href|src|action|formaction|xlink:href)$/i +const DANGEROUS_URL_SCHEMES = /^\s*(javascript|data|vbscript|file)\s*:/i + +function isSafeAttributeWrite (attr, value) { + if (typeof attr !== 'string') return false + if (DANGEROUS_ATTR_NAMES.test(attr)) return false + if (URL_ATTR_NAMES.test(attr) && typeof value === 'string' && DANGEROUS_URL_SCHEMES.test(value)) return false + return true +} + +// Parse the translation HTML into a detached document, strip executable +// content (<script>, event handlers, javascript:/data:/vbscript:/file: +// URLs, and embedded navigation elements) and return the serialised result. +// Uses a throwaway document via DOMParser so the translation itself is not +// parsed into the live DOM during sanitisation (no side-effect fetches, no +// premature event firing). Legitimate translation formatting like <b>, +// <em>, <strong>, <a href="..."> survives. +function sanitizeTranslationHtml (html) { + if (typeof html !== 'string') return html + if (typeof DOMParser === 'undefined') return html + try { + const doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html') + // Remove disallowed tags entirely (including their content) + const disallowedTags = ['SCRIPT', 'IFRAME', 'OBJECT', 'EMBED', 'LINK', 'META', 'BASE', 'STYLE'] + disallowedTags.forEach(tag => { + doc.body.querySelectorAll(tag.toLowerCase()).forEach(n => n.remove()) + }) + // Strip event handlers and dangerous URL schemes on remaining elements + doc.body.querySelectorAll('*').forEach(n => { + const attrs = Array.from(n.attributes) + attrs.forEach(a => { + const name = a.name + const val = a.value + if (/^on/i.test(name)) { + n.removeAttribute(name) + return + } + if (URL_ATTR_NAMES.test(name) && DANGEROUS_URL_SCHEMES.test(val)) { + n.removeAttribute(name) + } + }) + }) + return doc.body.innerHTML + } catch (e) { + // On any parse failure, fall back to returning the raw value — the + // outer origin-validation layer is still in place. + return html + } +} + export function setValueOnNode (meta, value) { const item = store.get(meta.eleUniqueID) @@ -16,6 +74,9 @@ export function setValueOnNode (meta, value) { item.node.textContent = txtWithHiddenMeta } else if (meta.textType.indexOf('attr:') === 0) { const attr = meta.textType.replace('attr:', '') + // Drop writes to dangerous attribute names (event handlers, style) and + // reject javascript:/data:/vbscript:/file: URLs on href/src/action/etc. + if (!isSafeAttributeWrite(attr, txtWithHiddenMeta)) return item.node.setAttribute(attr, txtWithHiddenMeta) } else if (meta.textType === 'html') { const id = `${meta.textType}-${meta.children}` @@ -29,9 +90,11 @@ export function setValueOnNode (meta, value) { item.originalChildNodes = clones } + const sanitisedHtml = sanitizeTranslationHtml(txtWithHiddenMeta) + // simple case - contains all inner HTML - so just replace it if (item.children[id].length === item.node.childNodes.length) { - item.node.innerHTML = txtWithHiddenMeta + item.node.innerHTML = sanitisedHtml } else { // more complex...add somewhere in between const children = item.children[id] @@ -40,7 +103,7 @@ export function setValueOnNode (meta, value) { // append to dummy const dummy = document.createElement('div') - dummy.innerHTML = txtWithHiddenMeta + dummy.innerHTML = sanitisedHtml // loop over all childs and append them to source node before first child (the one having the startMarker) const nodes = []
src/api/handleRequestPopupChanges.js+23 −0 modified@@ -1,6 +1,17 @@ import { api } from './postMessage.js' import { popupId } from '../ui/elements/popup.js' +// CSS length values accepted for the popup's width/height. Must be a number +// followed by an allowed unit. Rejects anything carrying a semicolon, URL, +// calc() chain, or arbitrary property injection that could escape the +// height/width context into the popup's style sheet (CSS-exfil attacks, +// `behavior:url()` in legacy IE, etc.). +const CSS_LENGTH_RE = /^\d+(?:\.\d+)?(?:px|%|em|rem|vh|vw|ch|ex)$/i + +function isSafeCssLength (v) { + return typeof v === 'string' && CSS_LENGTH_RE.test(v) +} + function handler (payload) { const { containerStyle } = payload @@ -19,6 +30,16 @@ function handler (payload) { containerStyle.width = storedSize.width + 'px' } + // Validate attacker-controlled length strings before they are embedded + // into calc() / setProperty arguments. Reject silently on malformed + // input — the popup just keeps its previous size. + if (containerStyle.height && !isSafeCssLength(containerStyle.height)) { + delete containerStyle.height + } + if (containerStyle.width && !isSafeCssLength(containerStyle.width)) { + delete containerStyle.width + } + if (containerStyle.height) { const diff = `calc(${containerStyle.height} - ${popup.style.height})` @@ -36,12 +57,14 @@ function handler (payload) { if ( storedPos && storedPos.top && + containerStyle.height && storedPos.top < window.innerHeight - containerStyle.height.replace('px', '') ) { popup.style.setProperty('top', storedPos.top + 'px') } if ( storedPos && storedPos.left && + containerStyle.width && storedPos.left < window.innerWidth - containerStyle.width.replace('px', '') ) { popup.style.setProperty('left', storedPos.left + 'px') }
src/api/postMessage.js+16 −0 modified@@ -146,8 +146,24 @@ export const api = { } } +// Compute the expected origin once at module load. The locize InContext +// editor iframe is the only legitimate source of messages handled here. +// Without this check, any page that can embed the host (or that the host +// embeds) can invoke editKey/commitKeys/etc. against it — browser-enforced +// e.origin is the right signal, not the attacker-controlled e.data.sender. +const getExpectedIframeOrigin = () => { + try { + return new URL(getIframeUrl()).origin + } catch (err) { + return null + } +} + if (typeof window !== 'undefined') { window.addEventListener('message', e => { + const expectedOrigin = getExpectedIframeOrigin() + if (!expectedOrigin || e.origin !== expectedOrigin) return + const { sender, /* senderAPIVersion, */ action, message, payload } = e.data // console.warn(sender, action, message, payload)
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/advisories/GHSA-w937-fg2h-xhq2ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-41886ghsaADVISORY
- developer.mozilla.org/en-US/docs/Web/API/Window/postMessageghsaWEB
- github.com/locize/locize/commit/d006b75fadb8e8ab77b023e462850fc6e9170735ghsaWEB
- github.com/locize/locize/releases/tag/v4.0.21nvdWEB
- github.com/locize/locize/security/advisories/GHSA-w937-fg2h-xhq2nvdWEB
News mentions
0No linked articles in our index yet.