Skip to content

Report unclosed script/style/title tail as text at EOF - #64

Open
oalders wants to merge 1 commit into
masterfrom
nul-byte
Open

Report unclosed script/style/title tail as text at EOF#64
oalders wants to merge 1 commit into
masterfrom
nul-byte

Conversation

@oalders

@oalders oalders commented Jul 27, 2026

Copy link
Copy Markdown
Member

Problem

While reviewing the literal-mode handling I found that an unclosed script, style, or title element 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:

HTML::Parser->new(api_version => 3)->parse("<script>safe<img src=x onerror=boom>")->eof;
# 3.85: reports <img> as a start tag
# browsers: <img> is raw script text, no element is created

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 across script/style/xmp/iframe/title/textarea: the browser never creates the injected <img> element.

Fix

For an unclosed script/style/title at 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/plaintext already behaved this way and are untouched.

Impact

  • Well-formed input is unaffected — properly-closed elements never reach this path.
  • Only malformed/truncated input with an unclosed script/style/title changes, and it changes toward browser conformance.
  • No API changes.

Tests

New t/nul-literal-mode.t covers 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

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>
@oalders

oalders commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

@pjcj could I get a review from you?

@oalders oalders left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_cdata reset ordering is correct: the tail text event fires before is_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_buf and the buffer is retained until EOF, so this EOF-only fix covers all arrival patterns.
  • xml_mode never enters literal mode, so this path is unreachable there.
  • Event stream stays balanced: START → TEXT(tail) → END for script/style (implicit) and title (via pending_end_tag).
  • The Changes entry 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]

Comment thread hparser.c
/* rest is considered text */
break;
}
/* Unclosed script/style/title at end of document. A browser

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Comment thread hparser.c
* 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)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Comment thread t/nul-literal-mode.t
$parser->parse($html);
$parser->eof;

my $saw_img = grep { $_ eq 'img' } @start_tags;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant