Skip to content

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

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

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.
@simonwuelker

Copy link
Copy Markdown
Member

Servo does not accept AI generated contributions: https://book.servo.org/contributing/getting-started.html#ai-contributions

@noahskelton

Copy link
Copy Markdown
Author

@simonwuelker thankyou for fixing the bug in another pull request, i'm very aware that open source maintainers are overburdened with AI generated pull requests, but I did make every effort to be honest about AI usage here and included a very clear repro and fix.

I don't think you should punish honest disclosures with such a dismissal. The irony here is that AI was able to find this bug in the first place. I think at the very least an acknowledgement of submitting a patch and a bug is warranted (of course you have complete freedom to do what you want here, I just want to express my thoughts clearly).

Regardless, thanks for fixing the issue.

@simonwuelker

Copy link
Copy Markdown
Member

but I did make every effort to be honest about AI usage here and included a very clear repro and fix.

Our policy is not just caused by maintainer burden but also by ethical and environmental concerns: https://book.servo.org/contributing/getting-started.html#ai-contributions.
Regardless, I agree that it is unfortunate that those who openly disclose AI usage get their PRs closed while contributors who don't disclose anything might be able to sneak AI contributions in. I'm not aware of any better way to enforce the policy though.

I think at the very least an acknowledgement of submitting a patch and a bug is warranted

My comment was only related to this PR, not the issue. I don't deny that this was a real bug (whether it was found by AI or not) and I'm glad you took the time to report it.

@noahskelton

Copy link
Copy Markdown
Author

@simonwuelker That's fair - to be clear I just think a small acknowledgement in your original comment would have been the nice thing to do, I disagree with the AI policy but also can respect and understand it, but I think a more nuanced approach is beneficial for everyone (and would encourage other contributors).

Thanks!

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.

2 participants