Conversation
An unclosed literal-mode element (script, style, title) at the end of a document had its trailing bytes re-parsed as markup by the EOF flush, which exposed following tags as elements. This diverges from browsers, which keep the remaining bytes as the element's raw text and close it implicitly. The embedded NUL byte from the original repro is only one way to leave the element unclosed; a plainly unclosed element triggers the same behaviour with no NUL involved. Report the tail as text (preserving the element's CDATA-ness) and emit the end event instead of re-parsing it, matching browser behaviour. Well-formed input is unaffected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
@pjcj could I get a review from you? |
oalders
left a comment
There was a problem hiding this comment.
Code Review Intense Flow Summary
Reviewers Run
- ✅ General code-reviewer (always runs)
- ✅ Security review (C parser code touched; parsing-divergence class)
- ⏭️ Frontend (skipped: no UI files changed)
- ⏭️ SEO (skipped: no page templates/meta/routes)
- ⏭️ GEO (skipped: no content pages/schema)
- ⏭️ Playwright (skipped: no e2e tests, routes, or client-side interaction; new test is a Perl unit test)
Verdict
Both reviewers found no Critical issues and confirmed the fix is correct and complete. Both rebuilt the base commit and verified empirically that the old code reported the injected <img> as a real start event for </script\0>-style input, and that the fixed code reports the tail as a single text event across single-shot, chunked, mixed-case, and reused-parser scenarios. Full suite passes.
Security assessment: safe to merge; the change strictly reduces risk (closes a tokenizer-vs-browser divergence usable as a sanitizer-bypass primitive) and introduces no new memory-safety or DoS surface — the new report_event(E_TEXT, s, end, …) stays within the SvPV buffer, and s = end guarantees loop termination at lower cost than the old parse_buf re-entry.
Notable verified subtleties:
is_cdatareset ordering is correct: the tail text event fires beforeis_cdata = 0, so entity/dtext handling of the tail matches normal CDATA-element text, and the reset prevents state bleed into a reused parser.- Chunked input is safe: mid-stream, a NUL-broken close tag fails the close-tag check in
parse_bufand the buffer is retained until EOF, so this EOF-only fix covers all arrival patterns. xml_modenever enters literal mode, so this path is unreachable there.- Event stream stays balanced:
START → TEXT(tail) → ENDfor script/style (implicit) and title (viapending_end_tag). - The
Changesentry is accurate: only script/style/title are newly affected; xmp/iframe/textarea/plaintext already took the safe "rest is text" path.
Inline comments cover the two Important findings (one pre-existing asymmetry worth a maintainer decision, one test-coverage gap — the latter already addressed in a follow-up commit) and one Minor cosmetic point.
🤖 Review by Claude Code · model: claude-fable-5[1m]
| /* rest is considered text */ | ||
| break; | ||
| } | ||
| /* Unclosed script/style/title at end of document. A browser |
There was a problem hiding this comment.
[general] Important — pre-existing EOF asymmetry across literal-mode elements. This fix gives script/style an implicit close event and title a pending_end_tag close at EOF, but xmp/iframe/textarea/plaintext hit the early break just above and fall through to the generic "rest is text" path with no synthesized end event — the element is left open when literal_mode/is_cdata are zeroed during state reset (verified: <textarea>foo at EOF produces a start but no matching end). This is not a regression from this PR (those four elements were already safe from the tail-re-parse bug, and the Changes entry correctly lists only script/style/title), but the resulting asymmetry is worth an explicit maintainer decision: either accept it as documented behaviour or extend end-event synthesis to the other four in a follow-up.
| * every browser. So report the tail as text -- respecting the | ||
| * element's CDATA-ness, still recorded in p_state->is_cdata -- | ||
| * then emit the end event, but never re-parse it. */ | ||
| if (s < end) |
There was a problem hiding this comment.
[general+security] Minor — if (s < end) is always true here: nothing between the enclosing while (s < end) loop guard and this check mutates s. Harmless; either drop it or keep it as a defensive mirror of the genuinely-needed guard in the EOF flush path below (line ~1793) — if kept, a one-line comment saying it's defensive would stop future readers from hunting for the mutation.
| $parser->parse($html); | ||
| $parser->eof; | ||
|
|
||
| my $saw_img = grep { $_ eq 'img' } @start_tags; |
There was a problem hiding this comment.
[general+security] Important — test asserts only the negative, and only single-shot input. As committed, the test checks that no spurious <img> start event fires, but never asserts the positive contract from the Changes entry: that the tail (NUL included) is actually reported verbatim as text, and that it precedes the synthesized close event. It also never exercises chunked ->parse(...); ->parse(...); ->eof with the boundary inside the broken close tag — the arrival pattern the parser's buffering design exists for. A future regression that silently dropped the tail text or the close event would pass this test. Addressed in a follow-up commit on this branch: the hardened test adds verbatim-text, NUL-survival, and close-event-ordering assertions plus a chunked mode per case (18 cases → 132 assertions, all passing).
Problem
While reviewing the literal-mode handling I found that an unclosed
script,style, ortitleelement at the end of a document has its trailing bytes re-parsed as markup by the EOF flush. That exposes tags following the unclosed element as real start/end events, whereas every browser keeps those bytes as the element's raw text and closes it implicitly.The divergence is trivially reachable — no exotic input required:
A close tag broken by an embedded NUL (
</script\0>) is just one more way to leave the element unclosed and hits the same path. Verified against headless Chrome acrossscript/style/xmp/iframe/title/textarea: the browser never creates the injected<img>element.Fix
For an unclosed
script/style/titleat EOF, report the remaining bytes as the element's raw text (preserving its CDATA-ness) and emit the end event, instead of re-parsing the tail as markup.xmp/iframe/textarea/plaintextalready behaved this way and are untouched.Impact
script/style/titlechanges, and it changes toward browser conformance.Tests
New
t/nul-literal-mode.tcovers all six literal-mode elements, both NUL placements, and the plain-unclosed case (18 assertions). The three previously-divergent cases failed before the fix and pass after. Full suite: 482 tests pass.Investigated and implemented by Claude (Opus 4.8, model
claude-opus-4-8).🤖 Generated with Claude Code