From d33f831d24e6e3f89ed2ea13a38382135186e47b Mon Sep 17 00:00:00 2001 From: Andy McClenaghan Date: Tue, 5 May 2026 12:51:22 +1000 Subject: [PATCH 1/2] Fix xss matcher catastrophic backtracking --- .../scanner/src/__tests__/matchers.test.ts | 23 +++++ packages/scanner/src/matchers/xss.ts | 98 ++++++++++++++++--- 2 files changed, 108 insertions(+), 13 deletions(-) diff --git a/packages/scanner/src/__tests__/matchers.test.ts b/packages/scanner/src/__tests__/matchers.test.ts index 696b52a..68bf7ef 100644 --- a/packages/scanner/src/__tests__/matchers.test.ts +++ b/packages/scanner/src/__tests__/matchers.test.ts @@ -59,6 +59,29 @@ describe("xss matcher", () => { const slugs = matches.map((m) => m.matchedPattern); expect(slugs).toContain("dangerouslySetInnerHTML"); }); + + it("detects template interpolation mixed with HTML", () => { + const matches = xssMatcher.match( + "const unsafe = `
${body}
`;", + "src/components/comment.tsx", + ); + expect(matches.map((m) => m.matchedPattern)).toContain("template literal in HTML"); + }); + + it("does not catastrophically backtrack on long generated HTML report lines", () => { + const longJsonLine = `app.report = ${JSON.stringify({ + files: Object.fromEntries( + Array.from({ length: 3000 }, (_, i) => [ + `packages/example-${i}/src/file.ts`, + { language: "typescript", mutants: [{ id: String(i), replacement: "${user.value}" }] }, + ]), + ), + })};`; + const started = performance.now(); + const matches = xssMatcher.match(longJsonLine, "reports/mutation/packages-acp/mutation.html"); + expect(performance.now() - started).toBeLessThan(250); + expect(matches).toEqual([]); + }); }); describe("rce matcher", () => { diff --git a/packages/scanner/src/matchers/xss.ts b/packages/scanner/src/matchers/xss.ts index 9a1bd1c..e2e3c4b 100644 --- a/packages/scanner/src/matchers/xss.ts +++ b/packages/scanner/src/matchers/xss.ts @@ -1,24 +1,96 @@ +import type { CandidateMatch } from "@deepsec/core"; import type { MatcherPlugin } from "../types.js"; import { regexMatcher } from "./utils.js"; +function contextSnippet(lines: string[], lineIndex: number): string { + const start = Math.max(0, lineIndex - 2); + const end = Math.min(lines.length, lineIndex + 3); + return lines.slice(start, end).join("\n"); +} + +function hasHtmlTagAfter(line: string, start: number): boolean { + for (let i = start; i < line.length - 1; i++) { + if (line.charCodeAt(i) !== 60 /* < */) continue; + const next = line.charCodeAt(i + 1); + const afterSlash = next === 47 /* / */ ? line.charCodeAt(i + 2) : next; + if ((afterSlash >= 65 && afterSlash <= 90) || (afterSlash >= 97 && afterSlash <= 122)) return true; + } + return false; +} + +function hasInterpolationInsideHtmlTag(line: string): boolean { + let searchFrom = 0; + while (searchFrom < line.length) { + const tagStart = line.indexOf("<", searchFrom); + if (tagStart === -1) return false; + + const next = line.charCodeAt(tagStart + 1); + const afterSlash = next === 47 /* / */ ? line.charCodeAt(tagStart + 2) : next; + if (!((afterSlash >= 65 && afterSlash <= 90) || (afterSlash >= 97 && afterSlash <= 122))) { + searchFrom = tagStart + 1; + continue; + } + + const interpolation = line.indexOf("${", tagStart + 1); + if (interpolation === -1) return false; + + const tagEnd = line.indexOf(">", tagStart + 1); + if (tagEnd === -1) return true; + if (interpolation < tagEnd) return true; + + searchFrom = tagEnd + 1; + } + return false; +} + +function findTemplateLiteralHtml(content: string): CandidateMatch[] { + const lines = content.split("\n"); + const hitLines: number[] = []; + let snippet = ""; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const interpolation = line.indexOf("${"); + if (interpolation === -1) continue; + + if (hasHtmlTagAfter(line, interpolation + 2) || hasInterpolationInsideHtmlTag(line)) { + hitLines.push(i + 1); + if (!snippet) snippet = contextSnippet(lines, i); + } + } + + return hitLines.length > 0 + ? [ + { + vulnSlug: "xss", + lineNumbers: hitLines, + snippet, + matchedPattern: "template literal in HTML", + }, + ] + : []; +} + export const xssMatcher: MatcherPlugin = { noiseTier: "normal" as const, slug: "xss", description: "Unsafe innerHTML, dangerouslySetInnerHTML, template injection patterns", filePatterns: ["**/*.{ts,tsx,js,jsx,html,ejs,hbs}"], match(content, _filePath) { - return regexMatcher( - "xss", - [ - { regex: /dangerouslySetInnerHTML/, label: "dangerouslySetInnerHTML" }, - { regex: /\.innerHTML\s*=/, label: "innerHTML assignment" }, - { regex: /\.outerHTML\s*=/, label: "outerHTML assignment" }, - { regex: /document\.write\s*\(/, label: "document.write" }, - { regex: /\$\{.*\}.*<\/?\w+>|<\w+[^>]*\$\{/, label: "template literal in HTML" }, - { regex: /v-html\s*=/, label: "Vue v-html directive" }, - { regex: /\[innerHTML\]\s*=/, label: "Angular innerHTML binding" }, - ], - content, - ); + return [ + ...regexMatcher( + "xss", + [ + { regex: /dangerouslySetInnerHTML/, label: "dangerouslySetInnerHTML" }, + { regex: /\.innerHTML\s*=/, label: "innerHTML assignment" }, + { regex: /\.outerHTML\s*=/, label: "outerHTML assignment" }, + { regex: /document\.write\s*\(/, label: "document.write" }, + { regex: /v-html\s*=/, label: "Vue v-html directive" }, + { regex: /\[innerHTML\]\s*=/, label: "Angular innerHTML binding" }, + ], + content, + ), + ...findTemplateLiteralHtml(content), + ]; }, }; From 9bfc0e0155b1264921c162e342b6014941b8d15d Mon Sep 17 00:00:00 2001 From: Andy McClenaghan Date: Tue, 5 May 2026 15:04:01 +1000 Subject: [PATCH 2/2] Format xss matcher --- packages/scanner/src/matchers/xss.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/scanner/src/matchers/xss.ts b/packages/scanner/src/matchers/xss.ts index e2e3c4b..7ba7a87 100644 --- a/packages/scanner/src/matchers/xss.ts +++ b/packages/scanner/src/matchers/xss.ts @@ -13,7 +13,8 @@ function hasHtmlTagAfter(line: string, start: number): boolean { if (line.charCodeAt(i) !== 60 /* < */) continue; const next = line.charCodeAt(i + 1); const afterSlash = next === 47 /* / */ ? line.charCodeAt(i + 2) : next; - if ((afterSlash >= 65 && afterSlash <= 90) || (afterSlash >= 97 && afterSlash <= 122)) return true; + if ((afterSlash >= 65 && afterSlash <= 90) || (afterSlash >= 97 && afterSlash <= 122)) + return true; } return false; }