VYPR
High severity7.5GHSA Advisory· Published May 8, 2026· Updated May 13, 2026

CVE-2026-41886

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.

PackageAffected versionsPatched versions
locizenpm
< 4.0.214.0.21

Affected products

1

Patches

1
d006b75fadb8

security: postMessage hardening for 4.0.21

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

News mentions

0

No linked articles in our index yet.