Skip to content

Security

James Conway edited this page Nov 4, 2026 · 1 revision

This page is Parsec's assurance case: what an attacker can control, what Parsec promises in return, and the argument, defence by defence, for why those promises hold. If you're deciding whether to put Parsec in front of pages you didn't write, start here.

Important

Found a way to break one of these promises? Please report it privately, as SECURITY.md explains, not in a public issue.

On this page: The threat model · Trust boundaries · What Parsec promises · How each weakness is countered · How the project itself is protected · What isn't covered yet

The threat model

Parsec's job is to read HTML that someone else wrote. Assume that someone is hostile. They've read Parsec's source and built a page to make it crash, hang, eat memory or touch memory it shouldn't, in the program that embeds it.

They can't run code inside that program, because Parsec runs no scripts (Roadmap: not planned). And they don't control the program's own calls into Parsec: which selectors it runs or which edits it makes, unless the program passes their input straight through.

Trust boundaries

flowchart LR
    A["Untrusted page (HTML text)"] -->|"parse, parse_with, parse_fragment"| P["Parsec"]
    S["Selector text (untrusted if the program passes it on)"] -->|"SelectorList::parse"| P
    E["Edits (the program's own calls)"] -->|"set_attr, append, insert_before, ..."| P
    P --> D["Document: owned data, no unsafe access from outside"]
Loading
  1. The HTML string. Anything at all: any length up to MAX_INPUT, any bytes the caller decoded, any nesting, any tag soup.
  2. A selector string. Trusted when it's the program's own, untrusted when the program passes a user's query through.
  3. The editing API. The program's own calls, with any node ids and names, including ids it kept from before an edit.

What Parsec promises

For any input across those boundaries:

  • No memory unsafety. No out-of-bounds read or write, no use after free, no data race.
  • No unbounded time where you ask for a bound. parse_with with a budget returns ParseError::Timeout instead of running on.
  • No crash from depth. The page decides how deep the tree is, never how deep Parsec's call stack goes.
  • No panic on malformed input. Every string parses to a tree, as the HTML Standard requires. The panics that remain are documented ones on calls the program makes (oversize input to parse), and parse_with is the form that returns an error instead.

How each weakness is countered

Weakness What an attacker would try Defence Evidence
Use after free, dangling pointers Keep a node, remove it, use it Nodes are ids into an arena that never frees or reuses a slot; no node pointer is ever handed out Thread safety and memory safety
Out-of-bounds access, buffer overflow Lengths and offsets the parser doesn't expect Safe Rust with bounds checks everywhere, and one unsafe block with its reasoning written beside it The one unsafe block
Data races Share a document across threads Document is plain owned data: Send and Sync by construction, edits need &mut Threads
Stack exhaustion Nesting tens of thousands deep Tree walks, select, serialization and copying keep their own stacks or follow the tree's links. Selector parsing and matching recurse with the selector's own nesting, which is capped at 32 A test copies 20,000 levels of nesting on a 256 KB stack (Limits)
Quadratic or worse time Patterns that make each tag rescan the stack Counts of open elements by tag and by name, a flag per node for "is open", and the spec's own bounds on the adoption agency Limits and hostile input, plus tests that fail if four times the input takes over eight times as long
Unbounded memory Inputs that grow buffers faster than the input A cap on input size, and text that's moved once instead of copied on every append Limits and hostile input
Hangs Anything slow that's been missed The per-parse time budget turns it into an error Parsing
Malicious dependencies A compromised crate One runtime dependency (memchr), pinned in Cargo.lock, checked by cargo-deny and Dependabot Cargo.toml

On top of the design, three checks keep looking for what the design missed:

  • Fuzzing, nightly, of document and fragment parsing in 13 contexts, with AddressSanitizer and the tree's invariants checked after every input.
  • CodeQL, with the extended query suite, on every pull request and weekly.
  • Hostile-input tests, each added with the fix for an input found to be slow or crashing. They run in every build.

How the project itself is protected

  • main changes only through pull requests that pass CI on Linux, macOS and Windows. Force pushes and deletion are blocked, and release tags can't be moved.
  • The organization requires two-factor authentication. Secret scanning with push protection is on.
  • Every GitHub Action is pinned to a full commit hash. Workflows run with read-only tokens unless a job needs more, and say so.
  • The parser core is shared with Netweir. A drift check fails the build if Parsec's copy differs from the commit it claims to come from, and it accepts only a full commit hash.
  • Vulnerabilities are reported privately and answered within a week, as SECURITY.md explains.

What isn't covered yet

Honest gaps, each with its issue:

  • A recorded, dated security review of the parser against this case: #58.
  • Linear time proven for every input, rather than for the patterns tested so far. Until then, set a budget: #10 adds a depth limit too.
  • Run-time assertions inside the parser for fuzzing to trip: #52.
  • Signed releases, once there are releases: #57.

See also: Thread safety and memory safety · Limits and hostile input · SECURITY.md

Clone this wiki locally