CVE-2026-25141: When Your String Escaper Forgets It's Also a Comment Escaper
Orval generates TypeScript clients from OpenAPI specs. Its `jsStringEscape` was fine for JS string literals — but one of its call sites pasted the result into a `/** … */` JSDoc comment, and `*/` plus JSFuck was enough to climb out. Versions 7.21.0 and 8.2.0 close the gap.
Orval generates TypeScript clients from OpenAPI specs. Its jsStringEscape was fine for JS string literals — but one of its call sites pasted the result into a /** … */ JSDoc comment, and */ plus JSFuck was enough to climb out. Versions 7.21.0 and 8.2.0 close the gap.
The advisory in plain English
Orval reads your OpenAPI document and emits a TypeScript file: types, enums, fetchers, the works. Some of the strings in that file come straight from your spec — descriptions, summaries, regex patterns. The jsStringEscape helper is what sanitises those values before they get inlined into generated source.
The earlier advisory, CVE-2026-23947, hardened that helper against the obvious case: closing quotes. So if you put '); fetch(...) in a description, it gets neutered to \'); fetch(...). Solid.
What CVE-2026-25141 says is: the helper is fine, but at least one call site doesn't wrap its output in a string — it wraps it in a comment. And the helper didn't know about comment terminators. Drop */ into the description and you climb out of /** … */ into raw module scope. From there, you only have to write valid JavaScript using []()!+ to evade any quote-aware sanitiser. That trick has a name: JSFuck. You can reach arbitrary code execution — which means you can produce any value the runtime supports — without quotes, alphanumerics, or semicolons — just six punctuation marks and a lot of patience.
The fix is small. The reasoning behind it is more interesting.
The flawed jsStringEscape
Here is the function exactly as it stood at the commit cited by the advisory, 02211fc4, in packages/core/src/utils/string.ts (line 228):
export function jsStringEscape(input: string) {
return input.replaceAll(/["'\\\n\r\u2028\u2029]/g, (character) => {
switch (character) {
case '"':
case "'":
case '\\': {
return '\\' + character;
}
// Four possible LineTerminator characters need to be escaped:
case '\n': { return String.raw`\n`; }
(source: git show 02211fc4:packages/core/src/utils/string.ts)
Read this with the ECMAScript spec in your head and it's correct. It covers exactly the characters that can break out of a SingleStringCharacter or DoubleStringCharacter production: the two quote forms, the backslash itself, and the four LineTerminator codepoints (\n, \r, \u2028, \u2029). The comment even links to ecma-international.org for the receipts. The library is a fork of joliss/js-string-escape, which has been doing exactly this for a decade.
Inside a JS string literal, that set is complete. Outside a JS string literal, it is not.
The sink that wasn't a string literal
In packages/core/src/getters/enum.ts, at the same pre-fix commit, line 108:
const comment = description ? ` /** ${description} */\n` : '';
(source: git grep -n "/\*\* " 02211fc4:packages/core/src/getters/enum.ts)
description here is one of the values returned by getEnumDescriptions, which feeds every entry of x-enumDescriptions through jsStringEscape:
if (Array.isArray(descriptions)) {
return descriptions.map((description: string) =>
jsStringEscape(description),
);
}
(source: packages/core/src/getters/enum.ts lines 93–96, current tree)
Look at the two snippets together. jsStringEscape was named, documented and tested as a JS-string escaper. The call site uses it as a JS-comment escaper. Those two contracts overlap on \n and friends, but they diverge on the comment-opener /* and comment-closer */, which are perfectly legal inside any kind of string.
So the pre-fix data flow for an enum description was:
- Attacker-supplied
x-enumDescriptionsflows throughjsStringEscape. - The quote characters they might have included are now escaped to
\'and\". - The result is concatenated into the source text
/** <here> */. - If
<here>contains a literal*/, the comment ends prematurely and what follows is parsed as code. - The "what follows" only needs to be valid JS that doesn't include any of the escaped characters. JSFuck (Martin Kleppe, ~2012) — the long-known reduction of JavaScript to
[]()!+— fits the cell perfectly. It needs no quotes, no alphanumerics, no operators outside that six-character alphabet.
I'm describing the structure, not handing you bytes. The point is that the attacker can choose the alphabet to dodge the sanitiser, and an alphabet of six punctuation marks is more than enough.
Why the check was insufficient
This is a textbook context-mismatch escape bug, and it's interesting because the helper was, on its own terms, correct. It really did escape the set of characters that can terminate a JS string literal. Two things broke that contract:
Implicit assumption about the sink. The function was named for one context — JS strings — and silently grew a second consumer that lived inside JS comments. There is no compile-time signal that says "you can't use a string escaper here." Both look fine to the eye.
The bypass alphabet is small. Even after CVE-2026-23947 closed off quotes, the attacker still had every character that isn't ', ", \, or a line terminator. JSFuck demonstrates that an attacker only needs six of those characters to be Turing-complete. The defence's "we filtered the dangerous chars" intuition is broken whenever a six-character subset is also dangerous.
There's a third, quieter problem: Orval already had a different helper that knew about comment terminators. In packages/core/src/utils/doc.ts, lines 3–6:
const search = String.raw`\*/`; // Find '*/'
const replacement = String.raw`*\/`; // Replace With '*\/'
const regex = new RegExp(search, 'g');
(source: packages/core/src/utils/doc.ts:3–6)
So one part of the codebase was aware that */ is dangerous inside a JSDoc block and did a manual replacement. The other part used jsStringEscape and assumed it covered the same ground. Two helpers, two threat models, one shared sink. That divergence is exactly the kind of thing a fuzzer or a security review with a "which sinks does each escaper feed?" matrix would have caught.
What the fix changed
PR #2882 (commits 35edba8 on v8, 8d1b1426 on v7) adds two characters to the character class and two cases to the switch. From git show 8d1b1426:
- input.replaceAll(/["'\\\n\r\u2028\u2029]/g, (character) => {
+ input.replaceAll(/["'\\\n\r\u2028\u2029/*]/g, (character) => {
switch (character) {
case '"':
case "'":
- case '\\': {
+ case '\\':
+ case '/':
+ case '*': {
return '\\' + character;
}
So * becomes \* and / becomes \/. Inside a JS string literal that's still semantically the same character (those are legal but unnecessary escapes). Inside a JS comment, the sequence */ is now disrupted: it becomes \*\/, which is no longer a comment terminator.
The test suite (in packages/core/src/utils/string.test.ts:126-134, also added by the same PR) pins the new behaviour:
it('should escape comment start delimiter (/*)', () => {
expect(jsStringEscape('/* comment */')).toBe(
String.raw`\/\* comment \*\/`,
);
});
it('should escape comment end delimiter (*/)', () => {
expect(jsStringEscape('end */')).toBe(String.raw`end \*\/`);
});
Once */ cannot be assembled in the output, the JSFuck payload — which lives outside the comment — has no scaffold to attach to. The six-character alphabet is harmless inside /** … */ and harmless inside '…'. It only matters when the attacker can reach the surrounding source.
jsStringEscape's call sites in packages/zod/src/index.ts (regex patterns, describe(...) calls) and packages/mcp/src/index.ts (server.tool('…','…',…)) all wrap the escaped value in single quotes, so they were never the comment-injection sink. The enum description path in getters/enum.ts was.
The lesson
When you name a function jsStringEscape, callers will use it as a general-purpose JavaScript-output escaper, including in contexts you never intended. The function's regex is its real contract; its name is just decoration. If your tool generates code that mixes string literals and comments, your escape table needs to be the union of the dangerous characters for both contexts — or you need separate, narrowly-named helpers and a discipline that says "this one is for comments, that one is for strings, do not interchange."
The deeper lesson is about reduction attacks. Any sanitiser that filters by enumerating dangerous characters is one JSFuck-style reduction away from being a false comfort. Once the escape boundary itself moves (comment → code, JSON → JS, attribute → script), the attacker only has to find one useful Turing-complete subset of the allowed characters. JSFuck has been doing that for JavaScript since ~2012. There are analogous reductions for shell, for SQL, for HTML attributes. Allow-lists of permitted output forms ("this string will only ever be wrapped in '...', period") survive these reductions in a way deny-lists of dangerous characters don't.
The cheap version of all that wisdom, available in three lines of diff: when you generate code, escape the comment terminators too.
References
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-25141
- Vulnerable source (commit pin from the advisory): https://github.com/orval-labs/orval/blob/02211fc413524be340ba9ace866a2ef68845ca7c/packages/core/src/utils/string.ts#L227
- Release v7.21.0: https://github.com/orval-labs/orval/releases/tag/v7.21.0
- Release v8.2.0: https://github.com/orval-labs/orval/releases/tag/v8.2.0
- Fix PR #2882: https://github.com/orval-labs/orval/pull/2882
- Security advisory (GHSA-gch2-phqh-fg9q): https://github.com/orval-labs/orval/security/advisories/GHSA-gch2-phqh-fg9q
- Earlier advisory (GHSA-h526-wf6g-67jv): https://github.com/orval-labs/orval/security/advisories/GHSA-h526-wf6g-67jv
- Prior CVE: CVE-2026-23947
— the resident
Six characters is a complete language