Skip to content

Fix out-of-bounds panic when a meta content value ends in a bare charset token#762

Open
noahskelton wants to merge 1 commit into
servo:mainfrom
noahskelton:fix-meta-charset-oob-panic
Open

Fix out-of-bounds panic when a meta content value ends in a bare charset token#762
noahskelton wants to merge 1 commit into
servo:mainfrom
noahskelton:fix-meta-charset-oob-panic

Conversation

@noahskelton

@noahskelton noahskelton commented Jul 7, 2026

Copy link
Copy Markdown

AI Disclosure

This fix was made with the help of Claude Fable 5 and reviewed additionally with Codex GPT 5.5. The bug was encountered in the wild (production HTML scraping pipeline feeding pages through a downstream binding of this crate), and the fix and regression tests have been reviewed and verified by hand.

Summary

extract_a_character_encoding_from_a_meta_element in html5ever/src/encoding.rs panics with an out-of-bounds index when a <meta http-equiv="Content-Type"> element's content value ends in a bare charset token with no = after it, e.g. content="text/html; charset". Because the function runs on the parser's hot path (the in-head <meta> rule in tree_builder/rules.rs calls it for every http-equiv="Content-Type" element), any such page hard-panics parse_document. In embedded/FFI contexts this surfaces as an uncatchable process abort:

fatal runtime error: failed to initiate panic, error 5, aborting

Pages like this exist in the wild — typically a Content-Type meta truncated mid-attribute (...; charset), or pages that literally emit content="text/html; charset".

Root cause

After matching the word charset, step 3 of the WHATWG "extract a character encoding from a meta element" algorithm skips trailing ASCII whitespace, which can leave position == input.len() when nothing follows the token. Step 4 then reads the next byte with an unchecked index:

// Step 4. If the next character is not a U+003D EQUALS SIGN (=), ...
if input.as_bytes()[position] == b'=' {
    break;
}
thread 'main' panicked at html5ever/src/encoding.rs:39:12:
index out of bounds: the len is 18 but the index is 18

(18 is the length of text/html; charset.)

Reproducer

Minimal offending page:

<!doctype html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset">
</head>
<body>hello world</body>
</html>

Any parse of it reaches the panic, e.g. with this temporary example (not included in the PR):

// rcdom/examples/repro_charset.rs
use html5ever::parse_document;
use html5ever::tendril::TendrilSink;
use markup5ever_rcdom::RcDom;

fn main() {
    let html = std::fs::read_to_string(std::env::args().nth(1).unwrap()).unwrap();
    let _ = parse_document(RcDom::default(), Default::default()).one(html);
    println!("parsed ok");
}
cargo run -p markup5ever_rcdom --example repro_charset -- repro.html

Before this change it aborts at encoding.rs:39 with the backtrace pointing at extract_a_character_encoding_from_a_meta_element; after it, it prints parsed ok.

The fix

Guard the step-4 lookahead with .get(). Per the spec, if there is no next character the outer loop can never find another charset match, so the function returns None (no encoding):

match input.as_bytes().get(position) {
    Some(b'=') => break,
    Some(_) => {},
    // The input ends after "charset" (plus optional whitespace), so there is no encoding.
    None => return None,
}

This is the only unchecked index in the function — everything after the = already uses .get(position)? or range slices, which are safe at position == input.len().

Regression test

meta_element_charset_at_end_does_not_panic covers the three ways position can land at the end of the input: "text/html; charset", "charset", and "charset \t" (trailing whitespace). All three panic before the fix and return None after it.

cargo test -p html5ever, cargo fmt and cargo clippy -p html5ever all pass.

…set token

extract_a_character_encoding_from_a_meta_element indexed one byte past
the end of the input when the content value ended in "charset" with
optional trailing whitespace and no "=" after it (e.g.
content="text/html; charset"). Guard the step-4 lookahead with .get()
and return None when the input ends there.
@github-actions github-actions Bot added the V-non-breaking A non-breaking change label Jul 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

V-non-breaking A non-breaking change

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant