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.


Prevention & Best Practices

1. Never Trust Attribute Names From External Data

The root cause here isn't the translation values — it's that the translation keys were mapping to attribute names that were never validated. Whenever external data influences an attribute name (not just its value), treat it as a code-injection risk.

2. Prefer Allowlists Over Denylists for Attribute Control

A denylist approach (blocking onclick, onerror, etc.) is fragile — HTML has dozens of event-handler attributes and new ones can appear. An allowlist of the small set of attributes you actually need is far more robust.

3. Separate Presentation Attributes From Behavioral Attributes

Consider a design principle: i18n systems should only ever touch descriptive attributes. If your translation layer needs to influence behavior, that's a design smell worth revisiting.

4. Audit All setAttribute() Call Sites

Run a quick grep or Semgrep rule across your codebase:

# Find setAttribute calls where the first argument isn't a string literal
grep -n "setAttribute(" assets/js/*.js

Or use the Semgrep rule:

rules:
  - id: unsafe-setattribute
    patterns:
      - pattern: $EL.setAttribute($ATTR, ...)
      - pattern-not: $EL.setAttribute("...", ...)
    message: setAttribute() called with a dynamic attribute name — verify it's allowlisted
    languages: [javascript]
    severity: WARNING

5. Apply Content Security Policy (CSP)

A strong CSP (script-src 'self') won't prevent attribute injection directly, but it will block inline event handlers from executing, providing a valuable second layer of defense. CSP is not a substitute for fixing the root cause, but it reduces blast radius.

OWASP and CWE References

  • CWE-79: Improper Neutralization of Input During Web Page Generation (XSS)
  • OWASP DOM-based XSS Prevention Cheat Sheet: covers safe DOM manipulation patterns
  • OWASP Top 10 A03:2021 — Injection

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.


References

Frequently Asked Questions

What is HTML attribute injection?

HTML attribute injection occurs when attacker-controlled data is written into a DOM element's attribute name or value without restriction, potentially introducing executable event handlers like `onclick` or `onerror`.

How do you prevent attribute injection in JavaScript i18n code?

Use an explicit allowlist of safe, non-executable attribute names and check every attribute name against it before calling `setAttribute()`.

What CWE is attribute injection?

Attribute injection that leads to script execution is classified as CWE-79 (Improper Neutralization of Input During Web Page Generation — Cross-site Scripting).

Is sanitizing the attribute *value* enough to prevent attribute injection?

No. If the attribute *name* itself is attacker-controlled, an attacker can choose an event-handler attribute like `onclick` and the value becomes the payload. You must restrict attribute names, not just values.

Can static analysis detect attribute injection?

Yes. Tools like Semgrep can flag unrestricted `setAttribute()` calls where the first argument flows from external data. Orbis AppSec's multi-agent AI scanner detected exactly this pattern in `language.js`.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #30

Related Articles

critical

How Cross-Site Scripting happens in fast-xml-parser and how to fix it

CVE-2026-25896 is a critical Cross-Site Scripting vulnerability in fast-xml-parser caused by improper handling of DOCTYPE entity declarations, allowing attackers to inject malicious scripts through crafted XML input. The fix upgrades the library from vulnerable versions (4.5.3 and 5.2.3) to patched releases (4.5.7 and 5.10.1), closing the attack vector in production code. This matters because fast-xml-parser is widely used to process user-supplied XML in Node.js applications, making any XSS flaw

critical

How Reflected XSS happens in Astro and how to fix it

CVE-2026-50146 is a reflected cross-site scripting (XSS) vulnerability in Astro versions prior to 6.3.3, where unescaped slot names could be injected into rendered HTML. The fix upgrades Astro from 5.18.1 to 6.3.3 (along with related packages `@astrojs/starlight` and `starlight-blog`), closing a code path that allowed attacker-controlled input to reach the browser without sanitization. Any Astro-based site that renders dynamic slot names from untrusted sources was potentially exposed to session

high

How Unsafe eval() in JavaScript Happens in React Components and How to Fix It

A high-severity code injection vulnerability was discovered in `TurnPlanner.tsx`, where the `parseInputExpr` function used JavaScript's `Function` constructor — effectively `eval()` — to evaluate user-provided mathematical expressions. The regex guard in place only checked for the presence of arithmetic operators, not whether the input was safe to execute, leaving the door open for arbitrary JavaScript injection. A targeted whitelist fix was applied to reject any input containing characters outs

critical

How Unsanitized IPC Data Injection happens in Electron/HTML and how to fix it

A content injection vulnerability in `src/NankaiTrough.html` allowed attacker-controlled IPC message data to flow directly into DOM properties without type coercion or validation. The fix explicitly converts all `request.data` fields to strings using `String()` with fallback defaults before assigning them to `document.title` and `innerText` properties, eliminating the risk of prototype pollution and unexpected object-to-string coercion attacks.

critical

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

A stored Cross-Site Scripting (XSS) vulnerability in `hasheous/wwwroot/pages/dataobjectdetail.js` allowed attackers with Moderator or Admin privileges to inject malicious HTML into DataObject attribute fields, executing arbitrary JavaScript in every visitor's browser. The fix replaces unsafe `innerHTML` assignments with `textContent` for plain text and a sanitized markdown renderer for AI-generated descriptions, eliminating the injection vector entirely.

high

How Stored XSS via Unsanitized GitHub README HTML Happens in JavaScript and How to Fix It

A high-severity stored Cross-Site Scripting (XSS) vulnerability was discovered in `custom_components/hacs_vision/frontend/panel.js`, where the backend fetched GitHub's pre-rendered README HTML and the frontend injected it directly into the DOM without sanitization. An attacker who controls a GitHub repository could embed malicious JavaScript in their README that executes automatically when any HACS Vision user views that repository's details, potentially exfiltrating credentials or hijacking the