Repository navigation
Security
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
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.
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"]
-
The HTML string. Anything at all: any length up to
MAX_INPUT, any bytes the caller decoded, any nesting, any tag soup. - A selector string. Trusted when it's the program's own, untrusted when the program passes a user's query through.
- The editing API. The program's own calls, with any node ids and names, including ids it kept from before an edit.
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_withwith a budget returnsParseError::Timeoutinstead 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), andparse_withis the form that returns an error instead.
| 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.
-
mainchanges 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.
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
Parsec is Apache-2.0 and not yet released; these pages describe main. Found something wrong here? Open an issue or ask in Discussions.
Repository · Issues · Discussions · Roadmap · Security policy · Licence