Passante A Passante publication

En Passante

In Passing
Field Kit

When both sanitizer layers go blind at once

Layered defence only holds if the layers fail differently — so any input your rules could not fully parse must be emitted as a violation, not passed through as clean.

What happened

On 2026-07-29 a two-layer HTML sanitizer returned `{ ok: true, violations: [] }` for `<img/onerror=alert(1) src=x>` — a solidus immediately after the tag name, live-confirmed executing in Safari — because the tag-matching regex demanded `\s+` between attributes while the parser layer refused to recognise the construct as an element at all; closing it took the suite from 630 to 648 passing tests.

2026-07-29 TypeScript · Node · two-layer HTML sanitizer measured: suite 630 → 648 passing, 96 files unchanged
4 prompts, in order
  1. Map both layers against one bypass A string is confirmed executing in a browser but your sanitizer reports no violations.
  2. Add a fail-closed unparseable-tag gate You have found that a tag can be seen by a coarse rule but skipped by every attribute rule.
  3. Re-test at every regex level You widened the tag-level pattern and want to declare the bypass closed.
  4. Scope the parser pre-normalization narrowly Your HTML parser silently fails on a construct and you are tempted to share one regex across layers.

1Map both layers against one bypass

Run it when
A string is confirmed executing in a browser but your sanitizer reports no violations.
It asks you for
The exact bypass string, the source of every rule in both layers, and the verbatim output of running the string through each layer alone.
You get back
A per-layer trace, the exact character class or separator at fault in each, and a verdict on whether the two blind spots coincide.
It refuses if
The bypass cannot be reproduced, or browser tokenizer behaviour is asserted rather than observed.
copy this into a model
<stance>
You are reviewing a sanitizer that just let a live XSS through. You have chased the persuasive wrong answer before: "layer one missed it, layer two would have caught it" — which is true right up until the day one input shape defeats both, and that is the only day that matters. You also know that a parser returning no elements looks identical, from the outside, to a parser that cleaned the input. Treat every layer as guilty until its output is read verbatim.
</stance>

<inputs>
Ask for, and wait for, each of these before starting:
1. The exact bypass string, byte for byte, unescaped.
2. The source of every rule in the regex/contract layer — tag matcher, open-tag matcher, per-attribute matchers.
3. The source of the parse/normalize layer and which library does the parsing.
4. The verbatim output of running the string through each layer in isolation.
If any of these is missing, say which one and stop. Do not infer regex behaviour from a description of it.
</inputs>

<method>

1. Run the string through the parser layer alone. Record the element count from a `querySelectorAll('*')`-equivalent and the exact `toString()` output. Zero nodes with output identical to the input means the parser never recognised an element — it did not sanitise anything. State that conclusion explicitly.
2. Run the string through the regex layer alone. For each rule, report matched or not matched, and if not matched, the precise sub-expression that failed.
3. Establish what a browser tokenizer actually does with the string: enumerate the attribute names it parses. If this has not been observed in a browser, say so and treat the question as unsettled rather than guessing.
4. For each non-matching regex, name the character class doing separator duty (e.g. `\s+`) and the character in the input it rejects.
5. Decide: did the string escape both layers, or did one cover the other? If both, mark this a coinciding blind spot and say so in one sentence.
6. Check version control for a recent rewrite of any rule that failed. A tightened separator class in a previous fix is a common origin. If the history is not available, say the origin is unknown.

</method>

<deliver>

- Per-layer trace, verbatim outputs quoted
- The exact character class at fault, per rule
- Browser-observed attribute list, or a statement that it was not observed
- Coincidence verdict: both blind, or one covering
- Suspected regression origin, or "unknown"

</deliver>

<refuse>
Do not propose a patch in this pass. Do not describe what a tokenizer "should" do in place of what was observed. Do not report a layer as "working" because it produced no violations — that is the symptom, not the evidence. If the bypass does not reproduce on the user's machine, stop and say the investigation cannot start.
</refuse>

2Add a fail-closed unparseable-tag gate

Run it when
You have found that a tag can be seen by a coarse rule but skipped by every attribute rule.
It asks you for
The coarse tag matcher, the full open-tag matcher, the violation type list, and where the allow-list check happens.
You get back
A cross-check that emits a violation for any tag not fully covered by the open-tag matcher, plus the fixture pair that proves it fires.
It refuses if
There is no violation channel that can fail the whole result, or no known-BAD fixture is supplied.
copy this into a model
<stance>
You are hardening a gate that returned PASS on input it never examined. You have seen the seductive fix — widen the one regex that missed — and you know it treats the symptom. The structural bug is that a tag could be recognised well enough to be allow-listed by name while no attribute rule ever inspected it. The rule you are enforcing is simple: no PASS on unexamined input, including the gate's own internals.
</stance>

<inputs>
Ask for, and wait for:
1. The coarse tag matcher — the regex that finds `<tagname` and feeds the allow-list.
2. The full open-tag matcher that attribute rules depend on.
3. The list of violation types the gate can emit today.
4. The code path where an allow-listed tag short-circuits further checks.
If the gate has no way to fail an entire result, say so and stop — there is nothing to fail closed into.
</inputs>

<method>

1. Enumerate, from the two regexes, the set of inputs where the coarse matcher finds a tag and the full matcher does not produce a complete match covering it. Give at least two concrete strings in that set.
2. Write a cross-check that walks every coarse match and asks whether it falls entirely inside some complete open-tag match. Any coarse match not fully covered emits a new violation type — name it for what it means: the tag could not be inspected by the attribute rules.
3. Make the violation message name the offending tag. A gate that fails without saying what it failed on will be disabled by the next person on call.
4. Place the cross-check before the allow-list short-circuit, so an allow-listed name cannot skip it.
5. Build the fixture pair: one known-BAD input that this gate and only this gate catches — a digit-leading attribute name such as `<img 1x=y ...>` works because no legitimate rule matches it — and one known-GOOD input plus one previously-fixed input (an uppercase attribute name, say) that must stay clean.
6. Run both. If the BAD fixture does not produce the new violation, the gate is not wired in; fix that before anything else.

</method>

<deliver>

- The uncovered-input set, with concrete example strings
- The cross-check implementation
- The new violation type and its message format
- The BAD/GOOD/regression fixture trio and their results
- A standing comment on the open-tag regex recording why the separator class is what it is

</deliver>

<refuse>
Do not implement the gate as a warning or a log line; it must be able to fail the result. Do not accept a known-BAD fixture that some other existing rule already catches — it proves nothing about this gate. If the user cannot supply an input that only the new gate catches, stop and say the gate is unverifiable as written.
</refuse>

3Re-test at every regex level

Run it when
You widened the tag-level pattern and want to declare the bypass closed.
It asks you for
The diff of the widened pattern and every downstream regex that reads attributes out of a matched tag.
You get back
A level-by-level table of which patterns still require the old separator, and the end-to-end result on the raw bypass.
It refuses if
The end-to-end run against the raw bypass string has not been executed after the change.
copy this into a model
<stance>
You are checking a fix that looks finished. The trap here is real and was hit once already: widening the separator class in the tag-level pattern made the whole tag match, and the fix looked complete by inspection — but the per-attribute patterns underneath still required whitespace immediately before each attribute name, so an end-to-end run against the raw bypass still returned `{ ok: true, violations: [] }`. Reasoning about the fix said done; running it said no. You trust running it.
</stance>

<inputs>
Ask for, and wait for:
1. The diff of the widened separator class, before and after.
2. Every downstream regex that extracts or inspects a single attribute — presentational-attribute checks, event-handler checks, dangerous-protocol checks, anything else.
3. The verbatim end-to-end result of running the raw bypass string through the full pipeline after the change.
If item 3 has not been run, stop and ask for it. Do not proceed on a description of the result.
</inputs>

<method>

1. Confirm the widened class is a strict superset of the previous one. If it is, no coverage regression is possible from that change alone, and you can say so by construction. If it is not a superset, flag every input the old class matched and the new one does not.
2. List every regex from input 2. For each, quote the sub-expression immediately preceding the attribute NAME. Mark it as widened or still whitespace-only.
3. Any regex still whitespace-only is a live hole even though the tag now matches. Widen the same leading separator there, and say plainly that this was found by running, not by reasoning.
4. Re-run end to end. Quote the verbatim result. A non-empty violations array is the only acceptable outcome; anything else means another level is still narrow.
5. Re-run the full suite. Report the pass count before and after and the number of files touched. If a test fails, rerun it unchanged before attributing it to this work — a timing-flaky test that passes clean on rerun with identical code is not a regression, and should be named as unrelated rather than silently ignored.

</method>

<deliver>

- Superset check verdict for the widened class
- Level-by-level table: regex, leading separator, widened yes/no
- Verbatim end-to-end result on the raw bypass
- Suite counts before and after, plus any test called out as unrelated and why

</deliver>

<refuse>
Do not declare the bypass closed on the strength of the tag-level match alone. Do not report the suite as green if a test failed and you did not rerun it. If the end-to-end result is still an empty violations array, say the fix is incomplete and return to step 2 rather than explaining why it should have worked.
</refuse>

4Scope the parser pre-normalization narrowly

Run it when
Your HTML parser silently fails on a construct and you are tempted to share one regex across layers.
It asks you for
Evidence of which positions the parser already tolerates, and the self-closing cases that must stay untouched.
You get back
A minimal rewrite covering only the position that defeats the parser, with the tolerated and self-closing cases proven unchanged.
It refuses if
The tolerated positions have not been tested, or self-closing tags have not been shown to survive.
copy this into a model
<stance>
You are writing a pre-processing step in front of a parser that goes silent on one input shape. The tempting move is to share a single regex with the other sanitizer layer — one definition, one place to fix. That is wrong here, and knowing why is the whole point: the two defects are different sizes. The rule layer has zero native tolerance for the character and needs it everywhere; the parser already tolerates it almost everywhere, and fails at exactly one position. A shared regex would either over-widen the pre-processing or under-widen the rules.
</stance>

<inputs>
Ask for, and wait for:
1. The construct that defeats the parser, and the observed failure signature — element count and `toString()` output.
2. Test results for the same character in other positions inside a tag, e.g. between attributes (`<img src=x /onerror=...>`, `<table width=600/border=1>`). Actual parse results, not expectations.
3. The self-closing forms that must survive untouched (`<br/>`, `<img src="x"/>`) and their current parse results.
If item 2 has not been run, stop. You cannot scope a fix without knowing the boundary of the defect.
</inputs>

<method>

1. From the evidence, state the exact position where the parser fails and confirm every other position already parses. If any other position also fails, the scope is wider than one rewrite and you must say so.
2. Write the narrowest rewrite that covers only that position — for the case at hand, `<tagname/` followed by anything that is not the closing bracket, rewritten to `<tagname ` with a space.
3. Prove the negative space: the rewrite must not touch a genuine self-close. Run the self-closing fixtures through the rewrite and show the output is byte-identical.
4. Apply the rewrite once, immediately before the single top-level parse call. If parsing happens in more than one place, say so — a pre-processor applied in one of two paths is a new blind spot.
5. Write down, next to the code, why this regex is narrower than the rule layer's and must not be merged with it. Without that note the next reader will unify them.
6. Add tests for each case: the bypass, each tolerated position, each self-close.

</method>

<deliver>

- Failure boundary: the one position that breaks, and the positions proven fine
- The minimal rewrite
- Byte-identical proof for self-closing inputs
- Parse-call audit: how many entry points, and whether all are covered
- The comment explaining why the two layers keep separate regexes

</deliver>

<refuse>
Do not widen the rewrite past the position you have evidence for. Do not merge this regex with the rule layer's without evidence that both defects are the same size. If self-closing tags have not been shown to survive unchanged, stop and say the rewrite is unsafe to ship.
</refuse>
How you know it worked

Feed the raw bypass string end-to-end and assert the result is not `{ ok: true, violations: [] }`; separately assert a known-BAD fixture with a digit-leading attribute name (`<img 1x=y ...>`) emits the unparseable-tag violation, while the known-GOOD fixture and an uppercase-attribute fixture stay clean. If the known-BAD fixture does not fire, the gate is decorative.

from a real incident, 2026-07-29 · stack TypeScript · Node · two-layer HTML sanitizer · verified by suite 630 → 648 passing, 96 files unchanged · xsshtml-sanitizationregexfail-closed

← every Field Kit