Skip to content

Commit 32b09f3

Browse files
committed
feat(bug-detectors): detect DOM sink XSS
Instrument innerHTML, outerHTML, srcdoc, insertAdjacentHTML,\ndocument.write/writeln, and React dangerouslySetInnerHTML\nthrough a bundler-agnostic AST plugin. Route all DOM sink values through the existing XSS analyzer and\ncover the new layer with instrumentation tests plus JSDOM-based\nCLI and Jest fixtures.
1 parent bf6044e commit 32b09f3

8 files changed

Lines changed: 794 additions & 3 deletions

File tree

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
/*
2+
* Copyright 2026 Code Intelligence GmbH
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import {
18+
instrumentAndEvalWith,
19+
instrumentWith,
20+
} from "../../instrumentor/plugins/testhelpers";
21+
22+
globalThis.JazzerJS = new Map();
23+
24+
const { xssDomSinkInstrumentationPlugin } =
25+
require("./xss") as typeof import("./xss");
26+
27+
const expectInstrumentation = instrumentWith(xssDomSinkInstrumentationPlugin());
28+
const expectInstrumentationAndEval = instrumentAndEvalWith(
29+
xssDomSinkInstrumentationPlugin(),
30+
);
31+
32+
describe("XSS DOM sink instrumentation", () => {
33+
beforeEach(() => {
34+
globalThis.XSSDetector = {
35+
analyzeHtmlForXss: jest.fn(),
36+
reportDomSinkArgsIfNeeded: jest.fn((values) => values),
37+
reportDomSinkIfNeeded: jest.fn((value) => value),
38+
};
39+
});
40+
41+
it("wraps innerHTML assignments once and preserves assignment semantics", () => {
42+
const input = `
43+
|let calls = 0
44+
|const element = { innerHTML: "" }
45+
|function payload() {
46+
| calls++
47+
| return '<img src=x onerror="jaz_zer_xss">'
48+
|}
49+
|const result = element.innerHTML = payload();
50+
|({ result, calls })`;
51+
const output = `
52+
|let calls = 0;
53+
|const element = {
54+
| innerHTML: ""
55+
|};
56+
|function payload() {
57+
| calls++;
58+
| return '<img src=x onerror="jaz_zer_xss">';
59+
|}
60+
|const result = (_jazzerXssTmp => {
61+
| globalThis.XSSDetector.reportDomSinkIfNeeded(_jazzerXssTmp, "innerHTML");
62+
| return _jazzerXssTmp;
63+
|})(element.innerHTML = payload());
64+
|({
65+
| result,
66+
| calls
67+
|});`;
68+
69+
const result = expectInstrumentationAndEval<{
70+
result: string;
71+
calls: number;
72+
}>(input, output);
73+
expect(result).toEqual({
74+
calls: 1,
75+
result: '<img src=x onerror="jaz_zer_xss">',
76+
});
77+
expect(globalThis.XSSDetector.reportDomSinkIfNeeded).toHaveBeenCalledWith(
78+
'<img src=x onerror="jaz_zer_xss">',
79+
"innerHTML",
80+
);
81+
});
82+
83+
it("wraps insertAdjacentHTML and preserves call results", () => {
84+
const input = `
85+
|let calls = 0
86+
|const element = {
87+
| insertAdjacentHTML(position, html) {
88+
| calls++
89+
| return position + html
90+
| }
91+
|}
92+
|const result = element.insertAdjacentHTML("beforeend", "<script>jaz_zer_xss</script>");
93+
|({ result, calls })`;
94+
const output = `
95+
|let calls = 0;
96+
|const element = {
97+
| insertAdjacentHTML(position, html) {
98+
| calls++;
99+
| return position + html;
100+
| }
101+
|};
102+
|const result = element.insertAdjacentHTML("beforeend", globalThis.XSSDetector.reportDomSinkIfNeeded("<script>jaz_zer_xss</script>", "insertAdjacentHTML"));
103+
|({
104+
| result,
105+
| calls
106+
|});`;
107+
108+
const result = expectInstrumentationAndEval<{
109+
result: string;
110+
calls: number;
111+
}>(input, output);
112+
expect(result).toEqual({
113+
calls: 1,
114+
result: "beforeend<script>jaz_zer_xss</script>",
115+
});
116+
});
117+
118+
it("wraps document.write arguments once", () => {
119+
const input = `
120+
|let calls = 0
121+
|const document = {
122+
| writes: [],
123+
| write(...args) {
124+
| this.writes.push(args.join(""))
125+
| return args.length
126+
| }
127+
|}
128+
|function payload() {
129+
| calls++
130+
| return "<script>jaz_zer_xss</script>"
131+
|}
132+
|const result = document.write(payload(), "!", payload());
133+
|({ result, calls, writes: document.writes })`;
134+
const output = `
135+
|let calls = 0;
136+
|const document = {
137+
| writes: [],
138+
| write(...args) {
139+
| this.writes.push(args.join(""));
140+
| return args.length;
141+
| }
142+
|};
143+
|function payload() {
144+
| calls++;
145+
| return "<script>jaz_zer_xss</script>";
146+
|}
147+
|const result = document.write(...globalThis.XSSDetector.reportDomSinkArgsIfNeeded([payload(), "!", payload()], "document.write"));
148+
|({
149+
| result,
150+
| calls,
151+
| writes: document.writes
152+
|});`;
153+
const result = expectInstrumentationAndEval<{
154+
calls: number;
155+
result: number;
156+
writes: string[];
157+
}>(input, output);
158+
expect(result.calls).toBe(2);
159+
expect(result.result).toBe(3);
160+
expect(result.writes).toEqual([
161+
"<script>jaz_zer_xss</script>!<script>jaz_zer_xss</script>",
162+
]);
163+
expect(
164+
globalThis.XSSDetector.reportDomSinkArgsIfNeeded,
165+
).toHaveBeenCalledTimes(1);
166+
});
167+
168+
it("wraps dangerouslySetInnerHTML independent of the factory name", () => {
169+
const input = `
170+
|const n = (_tag, props) => props
171+
|const payload = '<img src=x onerror="jaz_zer_xss">';
172+
|n("div", { dangerouslySetInnerHTML: { __html: payload } });`;
173+
const output = `
174+
|const n = (_tag, props) => props;
175+
|const payload = '<img src=x onerror="jaz_zer_xss">';
176+
|n("div", {
177+
| dangerouslySetInnerHTML: {
178+
| __html: globalThis.XSSDetector.reportDomSinkIfNeeded(payload, "dangerouslySetInnerHTML")
179+
| }
180+
|});`;
181+
182+
expectInstrumentation(input, output);
183+
});
184+
185+
it("fast-exits on unrelated assignments and object properties", () => {
186+
const input = `
187+
|const node = { textContent: "", innerText: "" }
188+
|node.textContent = "safe";
189+
|node.innerText = "safe";
190+
|({ dangerouslySetInnerHtml: { __html: "safe" } });`;
191+
const output = `
192+
|const node = {
193+
| textContent: "",
194+
| innerText: ""
195+
|};
196+
|node.textContent = "safe";
197+
|node.innerText = "safe";
198+
|({
199+
| dangerouslySetInnerHtml: {
200+
| __html: "safe"
201+
| }
202+
|});`;
203+
204+
expectInstrumentation(input, output);
205+
});
206+
});

packages/bug-detectors/internal/xss.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,24 @@ describe("XSS detector", () => {
6363
),
6464
).toBeUndefined();
6565
});
66+
67+
it("detects outerHTML fragments with active attributes", () => {
68+
expect(analyzeHtmlForXss('<div onmouseover="jaz_zer_xss"></div>')).toBe(
69+
"event handler attribute onmouseover",
70+
);
71+
});
72+
73+
it("detects insertAdjacentHTML fragments with executable script", () => {
74+
expect(
75+
analyzeHtmlForXss("<section><script>jaz_zer_xss</script></section>"),
76+
).toBe("script element");
77+
});
78+
79+
it("detects document.write fragments with javascript URLs", () => {
80+
expect(analyzeHtmlForXss('<a href="javascript:jaz_zer_xss">x</a>')).toBe(
81+
"javascript URL attribute href",
82+
);
83+
});
6684
});
6785

6886
describe("response sink handling", () => {

0 commit comments

Comments
 (0)