Back to Blog
critical SEVERITY8 min read

How Unsafe Attribute Injection happens in JavaScript i18n and how to fix it

A critical attribute injection vulnerability in `assets/js/language.js` allowed attackers with write access to locale JSON files to inject arbitrary HTML attributes — including event handlers like `onclick` — into DOM elements via the `applyTranslations()` function. The fix introduces a strict allowlist (`SAFE_ATTRS`) that restricts which attributes the i18n system can set, closing the injection path entirely. This is a concrete reminder that any code path that writes attacker-influenced data in

O
By Orbis AppSec
Published August 26, 2026Reviewed August 26, 2026

Answer Summary

This is an HTML attribute injection vulnerability (CWE-79, XSS via DOM attribute) in the JavaScript i18n module `assets/js/language.js`. The `applyTranslations()` function called `el.setAttribute(attr, t(key))` without restricting which attributes could be set, allowing an attacker who could modify locale JSON files to inject event-handler attributes like `onclick` or `onerror`. The fix adds a `SAFE_ATTRS` allowlist (`Set(['placeholder', 'title', 'aria-label', ...]`) and gates every `setAttribute()` call behind a `SAFE_ATTRS.has(attr)` check, so only benign, non-executable attributes can be written.

Vulnerability at a Glance

cweCWE-79
fixAdded a `SAFE_ATTRS` Set allowlist; `setAttribute()` is now only called when `SAFE_ATTRS.has(attr)` is true
riskArbitrary JavaScript execution in the victim's browser via injected event-handler attributes
languageJavaScript
root cause`el.setAttribute(attr, t(key))` in `applyTranslations()` accepted any attribute name from locale JSON without validation
vulnerabilityHTML Attribute Injection / DOM-based XSS

The Problem With Letting Your Translation Layer Touch the DOM Freely

The assets/js/language.js file is responsible for one thing that feels completely harmless: reading locale JSON files and applying translated strings to the page. Text goes in, translated text comes out. What could go wrong?

Quite a lot, it turns out — because the applyTranslations() function didn't just set text. It also set attributes, and it did so without any restriction on which attributes it was allowed to write.


The Vulnerability Explained

What applyTranslations() Was Doing

The function supports a data-i18n-attr HTML attribute that lets developers declaratively map translation keys to element attributes. For example:

<input data-i18n-attr="placeholder:ui.search,title:ui.tooltip">

When applyTranslations() runs, it parses that string, splits on commas, then on colons, and calls setAttribute() for each pair:

// VULNERABLE — before the fix (language.js ~line 88)
root.querySelectorAll('[data-i18n-attr]').forEach(el => {
  const pairs = el.getAttribute('data-i18n-attr').split(',').map(s => s.trim());
  for (const pair of pairs) {
    const [attr, key] = pair.split(':').map(s => s.trim());
    if (attr && key) el.setAttribute(attr, t(key));   // ← no restriction on `attr`
  }
});

The critical line is el.setAttribute(attr, t(key)). The variable attr comes directly from the locale JSON file — there is zero validation of what attribute name is being set. t(key) resolves the translation value, but attr itself is entirely data-driven.

Why That's a Security Problem

HTML attributes are not all equal. Some are purely presentational (placeholder, title, alt). Others are executable:

Attribute What it does
onclick Runs JavaScript when the element is clicked
onerror Runs JavaScript when an image/resource fails to load
onmouseover Runs JavaScript on hover
href on <a> Can be javascript: URI

If an attacker can control the contents of a locale JSON file, they can add a translation entry that maps to an event-handler attribute and inject arbitrary JavaScript into the page.

Concrete Attack Scenario

  1. Initial access: An attacker gains write access to the hosted locale files — perhaps through a misconfigured CDN, a compromised CI/CD pipeline, or an insecure deployment script.

  2. Payload insertion: They modify en.json (or any locale file loaded by language.js) to add:
    json { "ui.search": "Search...", "ui.tooltip": "Help", "evil.payload": "alert(document.cookie)" }

  3. Attribute injection: They also modify an HTML template or inject a data-i18n-attr value (e.g., via a stored content field) to reference:
    html <img data-i18n-attr="onerror:evil.payload" src="x">

  4. Execution: When applyTranslations() runs, it calls:
    js el.setAttribute('onerror', 'alert(document.cookie)');
    The broken image immediately fires onerror, executing the payload in every visitor's browser.

  5. Impact: Session hijacking, credential theft, malicious redirects — all the standard XSS consequences, delivered through what looks like a completely innocent i18n system.

This is classified as DOM-based XSS (CWE-79) because the injection and execution both happen in the browser, with the locale file as the tainted data source.


The Fix

One Line That Changes Everything

The fix is elegant and minimal. A SAFE_ATTRS allowlist is defined once, and every setAttribute() call is gated behind a membership check:

// FIXED — after the patch (language.js ~line 86)
const SAFE_ATTRS = new Set([
  'placeholder',
  'title',
  'aria-label',
  'aria-placeholder',
  'aria-description',
  'alt',
  'label'
]);

root.querySelectorAll('[data-i18n-attr]').forEach(el => {
  const pairs = el.getAttribute('data-i18n-attr').split(',').map(s => s.trim());
  for (const pair of pairs) {
    const [attr, key] = pair.split(':').map(s => s.trim());
    if (attr && key && SAFE_ATTRS.has(attr)) el.setAttribute(attr, t(key)); // ← allowlist check
  }
});

Before: if (attr && key) — any attribute name passes.
After: if (attr && key && SAFE_ATTRS.has(attr)) — only the seven explicitly approved attributes pass.

Why This Specific Allowlist Works

Every attribute in SAFE_ATTRS shares a key property: none of them can execute JavaScript. They are all passive, descriptive attributes used for accessibility and UI hints:

  • placeholder / aria-placeholder — input hint text
  • title — tooltip text
  • aria-label / aria-description — screen reader labels
  • alt — image alternative text
  • label — form label text

An attacker who injects onclick, onerror, onmouseover, href, src, or any other executable attribute will find that SAFE_ATTRS.has(attr) returns false and the setAttribute() call is simply skipped. The malicious attribute is never written to the DOM.

The Test File

The PR also adds tests/i18n-attr-allowlist.test.js, which uses Node's built-in vm module to load language.js in a sandboxed context and verify the allowlist behavior. This is important: it means the allowlist is now part of the regression test suite, so future refactors can't accidentally remove the guard without breaking a test.


Key Takeaways

  • el.setAttribute(attr, t(key)) in applyTranslations() was the exact sink — not a generic DOM write, but a specific function in the i18n pipeline that accepted any attribute name from locale data.
  • The attack surface was the locale JSON files, not user input fields. Supply-chain and deployment security matters for static assets too.
  • A seven-item SAFE_ATTRS Set is all that was needed to eliminate the entire attack class — allowlists are often simpler than they seem.
  • Attribute names are as dangerous as attribute values when they come from external data — this is an easy pattern to overlook during code review.
  • The fix preserves all legitimate i18n functionality: placeholder, title, aria-label, and friends all still work; only executable/behavioral attributes are now blocked.

How Orbis AppSec Detected This

  • Source: Locale JSON files loaded by language.js — the attr variable derived from data-i18n-attr attribute values in HTML templates
  • Sink: el.setAttribute(attr, t(key)) in applyTranslations() at assets/js/language.js:88, where attr is data-driven and unrestricted
  • Missing control: No validation or allowlisting of the attribute name before the setAttribute() call; any string from the locale data could become an HTML attribute name
  • CWE: CWE-79 — Improper Neutralization of Input During Web Page Generation (Cross-site Scripting)
  • Fix: Added const SAFE_ATTRS = new Set([...]) and changed the condition from if (attr && key) to if (attr && key && SAFE_ATTRS.has(attr)), ensuring only non-executable attributes can be set via the i18n system

Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.


Conclusion

The applyTranslations() vulnerability is a good example of how security issues hide in infrastructure code. No one looks at an i18n helper and thinks "XSS vector" — but any code that writes external data into the DOM is a potential injection point, and attribute names are just as dangerous as attribute values when they're data-driven.

The fix is a model of how to address this class of problem: define the smallest possible set of safe operations (seven attribute names), express it as an allowlist, and enforce it at the exact call site where the risk exists. The change is three lines of code, it doesn't break any existing functionality, and it's backed by a regression test that will catch any future regression.

When you're building or reviewing i18n systems, ask yourself: does this code path touch the DOM? If yes, what's the complete set of attributes it's allowed to set? If the answer is "whatever the locale file says," that's worth a closer look.


Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #30

Related Articles

critical

How stored XSS happens in TinyMCE plugins and how to fix it

The snippets plugin's `Main.ts` inserted raw, unsanitized snippet content directly into the TinyMCE editor via `editor.insertContent(snippet.content)`, allowing stored JavaScript payloads saved by any snippet editor to execute in every user's browser. The fix routes snippet content through TinyMCE's own parser and serializer before insertion, stripping dangerous markup while preserving legitimate formatting.

critical

How CSS Injection via Weak Pattern Validation happens in Vue.js and how to fix it

A critical CSS injection vulnerability in `testpage/App.vue` allowed attackers to bypass weak HTML5 pattern validation and load malicious stylesheets. The fix replaces direct variable assignment with a hardened `setCustomStylesheetHref()` method using strict regex validation.

high

How SQL-injection-style template literal injection happens in JavaScript DOM rendering and how to fix it

A Semgrep rule (`utils.custom.sql-injection-template-literal`) flagged `src/export/SheetMusicView.js` for building a query/markup string out of a JavaScript template literal with untrusted values interpolated directly into it. In this case the sink was an `<option value="${s.id}">${s.name}</option>` string used to build the snippet picker, meaning any snippet name containing `"` or `<` could break out of the attribute and inject arbitrary HTML. The fix introduces an `_escapeHtml()` helper and ro

critical

How Unvalidated External Data Fetch happens in React and how to fix it

The Datasets.jsx component fetched a remote manifest from snapshots.qdrant.io and rendered its contents directly into React state without validating response status, JSON shape, or field types. A compromised or spoofed endpoint could have injected malicious payloads straight into the UI; the fix adds strict validation and type coercion before the data ever reaches the render tree.

medium

How Cross-Site Scripting Happens in JavaScript Parsers and How to Fix It

A cross-site scripting vulnerability in JSXGraph's JessieCode parser allowed attackers to inject JavaScript through maliciously crafted input that appeared in error messages. The fix ensures proper output encoding when user-controlled data is included in parser error reporting.

critical

How Cross-Site Scripting (XSS) happens in JavaScript sanitization functions and how to fix it

A critical XSS vulnerability was discovered in the `sanitizeInput()` function in script.js, where only angle brackets were being escaped while quotes, ampersands, and backticks remained unprotected. This incomplete sanitization allowed attackers to craft payloads using event handlers and template literals that bypassed the security controls entirely. The fix implements comprehensive HTML entity encoding for all XSS-relevant characters.