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
-
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.
-
Payload insertion: They modify
en.json(or any locale file loaded bylanguage.js) to add:
json { "ui.search": "Search...", "ui.tooltip": "Help", "evil.payload": "alert(document.cookie)" } -
Attribute injection: They also modify an HTML template or inject a
data-i18n-attrvalue (e.g., via a stored content field) to reference:
html <img data-i18n-attr="onerror:evil.payload" src="x"> -
Execution: When
applyTranslations()runs, it calls:
js el.setAttribute('onerror', 'alert(document.cookie)');
The broken image immediately firesonerror, executing the payload in every visitor's browser. -
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 texttitle— tooltip textaria-label/aria-description— screen reader labelsalt— image alternative textlabel— 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))inapplyTranslations()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_ATTRSSet 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— theattrvariable derived fromdata-i18n-attrattribute values in HTML templates - Sink:
el.setAttribute(attr, t(key))inapplyTranslations()atassets/js/language.js:88, whereattris 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 fromif (attr && key)toif (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
- CWE-79: Improper Neutralization of Input During Web Page Generation (XSS)
- OWASP DOM-based XSS Prevention Cheat Sheet
- OWASP Cross Site Scripting Prevention Cheat Sheet
- MDN: Element.setAttribute() — Security Considerations
- Semgrep rules: setAttribute injection patterns
- fix: the application loads localized language files ... in language.js