Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@ developer-facing record.

## [Unreleased]

### Reflected HTML context handling
- Keep HTML template contents inert, including nested templates and apparent
closing tags inside raw text, comments or attributes.
- Track SVG and MathML namespaces, HTML integration points, foreign-content
breakouts, CDATA and self-closing foreign elements.
- Respect escaped script states and complete tag/attribute boundaries before
changing context. Duplicate annotation encoding attributes use the first value.
- Expand the pinned Chromium corpus to 1,255 context comparisons and add 13
public API checks. Keep the probe partial: frameset handling, complete tree
construction and proof of script execution remain outside these fixes.

### Repeater response bytes
- Preserve wire responses and inspection body bytes separately in snapshots and
per-tab history. Current responses survive project switches and app restarts;
Expand Down
309 changes: 209 additions & 100 deletions Src/Core/Networking/xss_logic.cpp

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions Src/Core/Networking/xss_reflected.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,12 @@ Result test(const Request &reqIn) {
for (const QString &param : params) {
if (result.requestsSent >= kMaxSends) break;
const QString marker = randMarker();
const QString tag = "<" + marker + ">"; // the executable proof
const QString tag = "<" + marker + ">"; // element-injection marker
const auto r = send(queryWith(req.query, param, tag));
if (!r.ok) continue;
// Must be an HTML response, the tag must reflect with raw (unencoded)
// angle brackets, and that reflection must sit in element content --
// not a comment, raw-text element, or attribute -- to actually run.
// not a comment, raw-text element, attribute or inert template.
if (!canExecuteHtml(r.parsed.headers)) continue;
const QString body = QString::fromUtf8(r.parsed.body);
// Check EVERY occurrence, case-insensitively: an app may normalize the
Expand Down
15 changes: 7 additions & 8 deletions Src/Core/Networking/xss_reflected.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,11 @@

// Reflected cross-site scripting (CWE-79). User input echoed into a response
// without context-correct encoding lets an attacker inject markup that runs in
// the victim's session. We confirm it conservatively: inject a random-marker
// tag and flag ONLY when (a) the response is HTML, (b) the angle brackets come
// back UNENCODED, and (c) the reflection sits in executable element content --
// not inside a comment, a raw-text element (script/style/textarea/...), or an
// attribute value, where the brackets are inert. An entity-encoded reflection
// (&lt;…&gt;) or a JSON/text response never matches, so findings are real.
// the victim's session. This probe injects a random-marker tag and checks that
// the response can render HTML, the brackets return unencoded, and the marker
// is outside comments, raw text, attributes and inert template contents.
// It detects element-content injection with a reduced HTML parser; it does not
// prove arbitrary script execution or model every browser tree-building rule.
// Scope: element-content injection; quote-only attribute breakout (where '<'
// is encoded but the delimiter quote isn't), DOM-based, and stored XSS are out
// of scope for this reflected check.
Expand All @@ -21,7 +20,7 @@ namespace Nullock::Core::XssReflected {

struct Hit {
QString param; // parameter the payload went into
QString context; // "html" (executable element content)
QString context; // "html" (element-content injection)
QString payload; // the injected value (decoded)
QString evidence; // the raw reflected marker found
};
Expand Down Expand Up @@ -55,7 +54,7 @@ QStringList defaultParams();

// Exposed for tests (pure logic; live in xss_logic.cpp):
// isHtmlContentType -- can the effective Content-Type render/sniff HTML?
// inExecutingHtmlContext -- is the marker at `at` in runnable element content?
// inExecutingHtmlContext -- is the marker at `at` in active element content?
// buildRequest -- render the GET, stripping CR/LF from method/host/path/query
// and dropping any CR/LF-bearing carried header.
// queryWith -- set `param` to `value` (percent-encoded), preserving others.
Expand Down
34 changes: 32 additions & 2 deletions Tests/ui/xss_context_browser_test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,36 @@ for (const name of ['script', 'style', 'textarea', 'title', 'xmp', 'noscript',
for (const prefix of ['<!-->', '<!--->', '<!-- normal -->', '<!-- normal --!>', '<!bogus>']) {
cases.push([prefix + marker, true]);
}
// Namespace and template cases use the pinned browser as the reference. Keep
// malformed closing tags in the matrix: balanced snippets miss context changes.
for (const prefix of ['', '<template>', '<template><template>', '<div><template>',
'<template><svg>', '<svg>', '<svg><g>', '<svg><foreignObject>', '<svg><title>',
'<math>', '<math><mi>', '<math><annotation-xml encoding="text/html">']) {
for (const middle of ['', '<template>', '<div>', '<p>', '<table>', '<svg><g>',
'<title>', '<style>', '<script>', '<math><mtext>']) {
for (const suffix of ['', '</template>', '</svg>', '</math>', '</div>', '</p>',
'</title>', '</style>', '</script>']) cases.push([prefix + middle + suffix + marker]);
}
}
for (const encoding of ['text/html', 'application/xhtml+xml', 'TEXT/HTML', 'text&#47;html',
'text&sol;html', 'application/xhtml&plus;xml', 'text/html ', ' text/html', 'text/plain']) {
for (const attr of [`encoding="${encoding}"`, `encoding='${encoding}'`, `encoding=${encoding}`,
`encoding=bogus encoding="${encoding}"`, `encoding="${encoding}" encoding=bogus`])
cases.push([`<math><annotation-xml ${attr}><title>${marker}`]);
}
for (const prefix of ['<template/>', '<template><!-- </template> -->',
'<template><div title="</template>">', '<template><textarea></template></textarea>',
'<template><script></template></script>', '<template><svg><title></template>',
'<svg><title/ >', '<svg><title a=/>', '<svg><title a="/">', '<svg><title a=x"y>',
'<script><!--<script/>', '<script><!--<script ></script>', '<script><!--<script></script>-->',
'<script><!--<script>-->', '<script><!--</script>', '<script></ſcript>',
'<template><script></script x=">">', '<template><script></script x=',
'<svg><font color=red><title>', '<svg><font><title>', '<math><mi><mglyph><title>',
'<math><mi><malignmark><title>', '<svg><![CDATA[>', '<svg><![CDATA[foo]]>',
'<svg><title><![CDATA[>', '<svg><title><![CDATA[foo]]>',
'<math><mtext><![CDATA[>', '<math><annotation-xml encoding="text/html"><![CDATA[>',
'<template><svg><template></template>', '<template><svg><template></template></template>',
'<template><svg><template></svg></template>', '</!', '</?', '</<', '</>']) cases.push([prefix + marker]);
(async () => {
const browser = await chromium.launch({ headless: true });
try {
Expand All @@ -26,10 +56,10 @@ for (const prefix of ['<!-->', '<!--->', '<!-- normal -->', '<!-- normal --!>',
{ encoding: 'utf8', timeout: 15000 });
assert.ifError(result.error);
assert.equal(result.status, 0, result.stderr);
assert.equal(JSON.parse(result.stdout), expected, `analyzer: ${JSON.stringify(body)}`);
await page.setContent(body);
const parsed = await page.locator('nlk0a1b2c3d').count() > 0;
assert.equal(parsed, expected, `browser: ${JSON.stringify(body)}`);
if (expected !== undefined) assert.equal(parsed, expected, `browser: ${JSON.stringify(body)}`);
assert.equal(JSON.parse(result.stdout), parsed, `analyzer/browser: ${JSON.stringify(body)}`);
}
console.log(`PASS: ${cases.length} HTML context browser/analyzer comparisons`);
} finally {
Expand Down
45 changes: 45 additions & 0 deletions Tests/xss_reflected/xss_reflected_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,51 @@ int main(int argc, char **argv) {
chk("closed comment restores element content", inExecutingHtmlContext(body, body.indexOf(TAG)));
}

// These outcomes are also checked against Chromium in the browser corpus.
const struct { const char *prefix; bool element; } contexts[] = {
{"<template>", false}, {"<template/>", false},
{"<template><template></template>", false},
{"<template><div></template>", true},
{"<template><table></template>", true},
{"<template><svg><title></template>", true},
{"<template><script></template></script>", false},
{"<template><textarea></template></textarea>", false},
{"<template><!-- </template> -->", false},
{"<template><div title=\"</template>\">", false},
{"<svg><title>", true}, {"<svg><style>", true},
{"<svg><script>", true}, {"<svg><textarea>", true},
{"<svg><title><style>", false},
{"<svg><foreignObject><template>", false},
{"<svg><template>", true},
{"<svg><title/ >", true}, {"<svg><title a=/>", true},
{"<svg><g><div><title>", false},
{"<svg><font color=red><title>", false},
{"<svg><font><title>", true},
{"<math><mi><title>", false},
{"<math><mi><mglyph><title>", true},
{"<math><mi><malignmark><title>", true},
{"<math><annotation-xml encoding=\"text/html\"><title>", false},
{"<math><annotation-xml encoding=\"text&#47;html\"><title>", false},
{"<math><annotation-xml encoding=\"application/xhtml&plus;xml\"><title>", false},
{"<math><annotation-xml encoding=bogus encoding=\"text/html\"><title>", true},
{"<math><annotation-xml encoding=\"text/html \" ><title>", true},
{"<svg><![CDATA[>", false}, {"<svg><![CDATA[foo]]>", true},
{"<svg><title><![CDATA[>", true},
{"<script><!--<script></script>", false},
{"<script><!--<script></script>--></script>", true},
{"<script><!--<script>-->", false},
{"<script></\xc5\xbf" "cript>", false},
{"<script></script attr=\"", false},
{"<script></script attr=\">\">", true},
{"</!", false}, {"</?", false}, {"</<", false}, {"</>", true},
};
for (const auto &test : contexts) {
const QString body = QString::fromUtf8(test.prefix) + TAG;
chk(test.prefix, inExecutingHtmlContext(body, body.indexOf(TAG)) == test.element);
}
chk("a later active reflection survives an earlier template reflection",
runs("<template><nlk0a1b2c3d></template><nlk0a1b2c3d>"));

// ===== queryWith: preserve others, replace target, percent-encode =====
chk("queryWith replaces target, keeps others, encodes",
[](){ const QString q = queryWith("a=1&b=2", "b", "<nlk>");
Expand Down
1 change: 1 addition & 0 deletions docs/changelog.html
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ <h1 class="h1-page grad-text" style="margin:10px 0 0;">What shipped</h1>
<div class="rel-body">
<p class="rel-note">The development build includes corrected security checks, certificate-cache validation and Repeater CLI fixes; v3.7.0 remains the latest public release, and the v3.8.0 notes below are a draft.</p>
<ul>
<li><span class="tag fix">fix</span> <strong>Reflected HTML respects templates and namespaces.</strong> Inert HTML templates no longer produce findings, while SVG and MathML elements follow their own parsing contexts. Nested templates, CDATA, escaped script text and malformed tag boundaries are covered by 1,255 Chromium comparisons and 13 new live API checks. The roadmap keeps the probe partial because framesets and full browser parsing still need work; a reflected marker does not prove script execution.</li>
<li><span class="tag fix">fix</span> <strong>Repeater preserves binary responses.</strong> Request Hex honors UTF-8 or Latin-1. Response Hex and <strong>Save bytes</strong> use either the body bytes or the original wire response, including compression and chunk framing. Downloads keep the full content beyond the 64 KiB preview. Current responses survive project reloads; past sends retain exact bytes for the current session. Empty compressed bodies and CRLF-separated Body views are also corrected.</li>
<li><span class="tag fix">fix</span> <strong>Build follow-up and roadmap corrections.</strong> The <a href="https://github.com/Bikebrainz/Nullock/blob/Nullock/docs/reviews/2026-09-29-build-follow-up.md">September 29 checklist</a> records remaining byte-fidelity, HTML-context, cancellation, fixture-isolation and certificate-lifecycle work. Scope enforcement evidence is refreshed, and the native window's blocking Send path is called out separately.</li>
<li><span class="tag fix">fix</span> <strong>Request files keep their binary bodies.</strong> Repeater CLI file and stdin loading preserves NUL bytes, non-UTF-8 data and trailing newlines. Invalid input leaves the draft unchanged; an empty file explicitly clears it. Tab summaries distinguish fresh tabs from completed responses, and duplicated tabs clear their old response metadata. Empty drafts and blank targets are rejected instead of reporting an old response as a successful send.</li>
Expand Down
24 changes: 17 additions & 7 deletions docs/reviews/2026-09-29-build-follow-up.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,11 @@ API and browser regressions are included in CI.

### 2. Correct the remaining reflected-XSS HTML contexts

- [ ] Handle inert template contents and SVG/MathML parsing contexts.
- [ ] Expand the pinned-browser corpus before changing the parser or its grade.
- [x] Handle inert template contents and SVG/MathML parsing contexts.
- [x] Expand the pinned-browser corpus before changing the parser or its grade.
- [ ] Model frameset insertion modes and continue checking malformed tree construction.

**Evidence:** after the tag-boundary fixes, a 97-context browser comparison left
**Original evidence:** after the tag-boundary fixes, a 97-context browser comparison left
two mismatches for the marker `<nlk0a1b2c3d>`:

| HTML | Native marker classification | Chromium element present |
Expand All @@ -67,10 +68,19 @@ The browser result describes this marker's parsed context, not a proof that an
arbitrary script payload executes. Existing committed browser coverage lives in
`Tests/ui/xss_context_browser_test.cjs` and `xss_media_types_browser_test.cjs`.

**Done when:** the corpus covers nested templates, foreign-content integration
points, malformed nesting and script/raw-text states, with native results
matching the pinned browser. Keep XML execution, stored XSS and DOM XSS outside
the claim unless they receive their own implementation and verification.
**Completed September 30:** [2bc4bcfa](https://github.com/Bikebrainz/Nullock/commit/2bc4bcfa)
tracks HTML templates, SVG/MathML namespaces and integration points, foreign
breakouts, CDATA, escaped script states and complete attribute boundaries.
The two original failures now match Chromium. The focused native suite passes
329 assertions and the committed browser corpus passes 1,255 comparisons.
Thirteen new public-API fixtures cover active and inert contexts, including a
later active reflection after an earlier inert template reflection.

**Remaining evidence:** `<frameset><nlk0a1b2c3d>` and
`<frameset></frameset><nlk0a1b2c3d>` still return true in the reduced parser while
Chromium creates no marker element. Keep the grade partial until these and
other tree-construction gaps are addressed. XML execution, stored XSS, DOM XSS
and proof of arbitrary script execution remain outside this marker check.

### 3. Finish Repeater cancellation and native-window responsiveness

Expand Down
Loading
Loading