From 24da9cf9cf44edfc177df6c1124afe15568820f8 Mon Sep 17 00:00:00 2001 From: Maksym Stoianov Date: Tue, 3 Nov 2026 16:25:23 +0100 Subject: [PATCH 1/2] fix(json): re-quote single-quoted strings correctly in parseJson A double quote inside a single-quoted string was copied into the double-quoted result unescaped, and an escaped single quote kept a backslash JSON does not accept, so such input threw. Quoted keys no longer lose their inner quotes either. Fixes CodeQL js/incomplete-sanitization alerts 7 and 8. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Maksym Stoianov --- src/json/parseJson.ts | 42 ++++++++++++++++++++++++++----------- test/json/parseJson.test.ts | 32 ++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 12 deletions(-) diff --git a/src/json/parseJson.ts b/src/json/parseJson.ts index 206162e5..0ceb813c 100644 --- a/src/json/parseJson.ts +++ b/src/json/parseJson.ts @@ -39,6 +39,13 @@ export function parseJson(value: string): object { return JSON.parse(result); // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (err: unknown) { + /** + * Matches a complete double-quoted JSON string literal, escapes included. + * The tokenizer below turns every quoted string, single-quoted ones too, + * into this form. + */ + const quotedString = /^"(?:[^"\\]|\\.)*"$/; + /** * Checks if a token is a valid key format (either quoted or unquoted alphanumeric). * @@ -46,7 +53,15 @@ export function parseJson(value: string): object { * @returns {boolean} `true` if the token is a valid key format; otherwise, `false`. */ const isValidKey = (token: string): boolean => - /^"[^"]*"$/.test(token) || /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(token); + quotedString.test(token) || /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(token); + + /** + * Returns a key token as a JSON string literal. + * + * @param {string} token A token accepted by `isValidKey`. + * @returns {string} The token itself when it is already quoted; otherwise the token in double quotes. + */ + const toKey = (token: string): string => (quotedString.test(token) ? token : `"${token}"`); /** * Checks if a token is a valid value format (boolean, null, number, or quoted string). @@ -59,8 +74,7 @@ export function parseJson(value: string): object { token === "false" || token === "null" || /^-?\d+(\.\d+)?([eE][+-]?\d+)?$/.test(token) || - /^"[^"]*"$/.test(token) || - /^'[^']*'$/.test(token); + quotedString.test(token); const tokens = []; @@ -76,6 +90,12 @@ export function parseJson(value: string): object { const char = result[i]; if (isEscaped) { + // `\'` is how a single-quoted string carries a quote; JSON has no such + // escape, and inside double quotes the quote needs none. + if (char === "'" && isString) { + currentToken = currentToken.slice(0, -1); + } + currentToken += char; isEscaped = false; @@ -97,7 +117,9 @@ export function parseJson(value: string): object { tokens.push(currentToken); currentToken = ""; } else { - currentToken += char; + // A double quote inside a single-quoted string has to be escaped + // once the string is re-quoted with double quotes. + currentToken += char === '"' ? '\\"' : char; } } else { isString = true; @@ -153,19 +175,15 @@ export function parseJson(value: string): object { expectingKey = correctedTokens[correctedTokens.length - 2] === "}"; expectingValue = !expectingKey; } else if (expectingKey && isValidKey(token)) { - correctedTokens.push(`"${token.replace(/"/g, "")}"`); + correctedTokens.push(toKey(token)); expectingKey = false; } else if (expectingValue && isValidValue(token)) { - correctedTokens.push( - token.match(/^'[^']*'$/) ? `"${token.slice(1, -1).replace(/"/g, '\\"')}"` : token - ); + correctedTokens.push(token); expectingValue = false; } else if (isValidKey(token)) { - correctedTokens.push(`"${token.replace(/"/g, "")}"`); + correctedTokens.push(toKey(token)); } else if (isValidValue(token)) { - correctedTokens.push( - token.match(/^'[^']*'$/) ? `"${token.slice(1, -1).replace(/"/g, '\\"')}"` : token - ); + correctedTokens.push(token); } else { throw new Error(`Invalid token: ${token}`); } diff --git a/test/json/parseJson.test.ts b/test/json/parseJson.test.ts index e6728b8c..1e601756 100644 --- a/test/json/parseJson.test.ts +++ b/test/json/parseJson.test.ts @@ -26,6 +26,38 @@ describe("parseJson", () => { it("should accept unquoted keys", () => { expect(parseJson("{a:1}")).toEqual({ a: 1 }); }); + + it("should keep double quotes inside a single-quoted value", () => { + expect(parseJson(`{'a':'say "hi"'}`)).toEqual({ a: 'say "hi"' }); + }); + + it("should keep double quotes inside a single-quoted key", () => { + expect(parseJson(`{'say "hi"':1}`)).toEqual({ 'say "hi"': 1 }); + }); + + it("should unescape an escaped single quote in a single-quoted value", () => { + expect(parseJson(String.raw`{'a':'it\'s'}`)).toEqual({ a: "it's" }); + }); + + it("should keep backslash escapes in a single-quoted value", () => { + expect(parseJson(String.raw`{'a':'C:\\dir\n'}`)).toEqual({ a: "C:\\dir\n" }); + }); + + it("should keep an escaped double quote in a double-quoted value", () => { + expect(parseJson(String.raw`{a:"x\"y"}`)).toEqual({ a: 'x"y' }); + }); + + it("should keep a backslash before a double quote in a single-quoted value", () => { + expect(parseJson(String.raw`{'a':'x\\"y'}`)).toEqual({ a: 'x\\"y' }); + }); + + it("should accept mixed quoted and unquoted keys", () => { + expect(parseJson(`{ "foo": "bar", baz: true, 'qux': null }`)).toEqual({ + foo: "bar", + baz: true, + qux: null + }); + }); }); describe("Input it rejects", () => { From 4e8ed8e49b11f8a725f2200faa36d3d1421cbc1a Mon Sep 17 00:00:00 2001 From: Maksym Stoianov Date: Tue, 3 Nov 2026 16:25:24 +0100 Subject: [PATCH 2/2] fix(docs): escape table cells fully and match old links without a regexp escapeCell escapes backslashes before pipes, and the translated reference tables use it too. The link fix-up in sync-jsdoc-links compares the literal URL instead of building a regexp from it. Generated output is unchanged. Fixes CodeQL alerts 12, 13 (js/incomplete-sanitization) and 17 (js/incomplete-hostname-regexp). Co-Authored-By: Claude Opus 5.5 Signed-off-by: Maksym Stoianov --- scripts/docs/generate-topics.mjs | 6 ++++-- scripts/docs/sync-jsdoc-links.mjs | 5 +---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/scripts/docs/generate-topics.mjs b/scripts/docs/generate-topics.mjs index 0c59f1f5..710f3646 100644 --- a/scripts/docs/generate-topics.mjs +++ b/scripts/docs/generate-topics.mjs @@ -493,8 +493,10 @@ function firstSentence(text) { return (match ? match[1] : clean).trim(); } +// A Markdown table cell: backslashes first, so that an escaped pipe in the +// source does not turn into a cell boundary, then the pipes themselves. function escapeCell(text) { - return text.replace(/\|/g, "\\|").replace(/\s+/g, " ").trim(); + return text.replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\s+/g, " ").trim(); } function renderPage(entry, language, content, sourceContent, pages, thrownBy) { @@ -874,7 +876,7 @@ function localiseTables(english, translated, language, summaries) { const localised = cells.slice(); - localised[localised.length - 1] = ` ${summary.replace(/\|/g, "\\|")} `; + localised[localised.length - 1] = ` ${escapeCell(summary)} `; return `|${localised.join("|")}|`; }) diff --git a/scripts/docs/sync-jsdoc-links.mjs b/scripts/docs/sync-jsdoc-links.mjs index 7a343db9..16ce3668 100644 --- a/scripts/docs/sync-jsdoc-links.mjs +++ b/scripts/docs/sync-jsdoc-links.mjs @@ -165,10 +165,7 @@ for (const file of listSources(SRC).sort()) { // A link written before the casing was fixed is corrected in place rather // than left beside its replacement. - const corrected = text.replace( - new RegExp(`${SITE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/${name}\\.html`, "g"), - page - ); + const corrected = text.replaceAll(`${SITE}/${name}.html`, page); const missing = [...wanted].filter(([, url]) => !corrected.includes(url));