Skip to main content

max / makenotwork

custom-pages: a libFuzzer target for the HTML sanitizer The harness half of infra 6f21a29a. The oracle and the seed corpora came with the extraction; what was missing was the fuzz crate and a measured session to size the soak entry. The oracle needed four corrections and every one was the same failure -- the check reporting the sanitizer working as a finding, which is worse than no check because it trains people to skim the tier. Each input is committed under fuzz/regressions/. 1. It scanned the whole output for the substring "//evil", a hostname out of one seed file rather than a property. It fired on a relative path: in <img src="ޘttps://evil.com/a.png"> the leading byte is not ASCII, so that is not a scheme and a browser resolves the value against u.makenot.work. What replaces it walks the output tag by tag, reads attribute names, skips quoted values by construction, and states the closed system as the real property -- every surviving URL attribute must be accepted by the URL gate, checked over a superset of the attributes the sanitizer gates so that a tag added to the allowlist and never added to url_attribute is caught. 2. sanitize_html(clean) == clean is false: attribute order differs when an off-platform href is dropped. 3. Convergence by the third pass is false: <h5> misnesting needs three. 4. A budget of 16 passes is false: nested <pre> needs 17. (3) and (4) are one lesson -- a constant is not a property, it is the nesting depth of whichever input was tried first. The bound is derived now: nesting depth cannot exceed input length, so input.len().max(32) is sufficient by construction and exceeding it means the chain is not converging rather than converging slowly. "The output never grows" was checked as a candidate property and is false: over the 16,437-input corpus, 122 grow on the way to their fixed point and 15,814 settle in one pass. fuzz/Cargo.lock is committed and load-bearing. A fresh resolve takes parcel_selectors 0.28.3, whose patch bump moved it to cssparser 0.37 while the exactly-pinned lightningcss alpha.71 is on 0.33; the two meet and the build fails with 61 type errors inside lightningcss. 2,753,527 runs over thirty minutes on fw13, 1,528 exec/s, peak RSS 591 MB, no findings once the oracle was correct.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-26 03:23 UTC
Signed with PGP, not checked
Commit: b323a2f8a9885880341cc1e29dec6b5f417f4480
Parent: ebb40ed
9 files changed, +934 insertions, -56 deletions
@@ -157,27 +157,104 @@
157 157 //!
158 158 //! Both are asserted over *output*, never over input. What a creator may
159 159 //! write is not the question; what may reach a rendered page is.
160 + //!
161 + //! ## The HTML side parses; it does not scan
162 + //!
163 + //! Rewritten 2026-08-25 (infra `6f21a29a`), on the first session of the
164 + //! html soak target, which found a false positive in under a minute:
165 + //!
166 + //! ```text
167 + //! input: <img src=ޘttps://evil.com/a.png">
168 + //! output: <img src="ޘttps://evil.com/a.png&quot;">
169 + //! ```
170 + //!
171 + //! The oracle had scanned the whole output for the substring `//evil` and
172 + //! panicked. But `ޘttps:` is not a scheme -- the leading byte is not ASCII,
173 + //! so under WHATWG parsing the value is a *relative path*, and a browser
174 + //! resolves it against `u.makenot.work` like any other. The sanitizer was
175 + //! right and the oracle was wrong, which is the failure mode that matters
176 + //! most here: a check that reports correct behaviour as a finding is worse
177 + //! than no check, because it trains people to skim the tier. The same trap
178 + //! cost docengine's oracle three attempts (infra `15991c40`).
179 + //!
180 + //! Two things were wrong with the scan, and both are now gone:
181 + //!
182 + //! 1. **`//evil` was never a property.** It is a hostname out of one seed
183 + //! file. Nothing about the crate's contract mentions it, and an attacker
184 + //! who owns a domain not called `evil` was never in scope.
185 + //! 2. **Whole-output substring matching cannot tell markup from text.** A
186 + //! page whose prose reads `javascript:` is a page the sanitizer correctly
187 + //! passes through as escaped text, and the scan would fire on it forever.
188 + //!
189 + //! What replaces it walks the output tag by tag, reads attribute NAMES, and
190 + //! skips quoted values by construction, so escaped content inside one can
191 + //! never reach the assertions. The closed-system property is then stated as
192 + //! what it actually is: every URL-bearing attribute that survived must be
193 + //! accepted by the very gate the sanitizer is supposed to route it through.
194 + //!
195 + //! That check is deliberately **not** tautological. It runs against a
196 + //! superset of the attribute names [`super::html_sanitizer`] gates, so the
197 + //! bug it exists to catch is the one nobody would write a unit test for:
198 + //! a tag or attribute added to the allowlist and never added to
199 + //! `url_attribute`, which would carry an ungated URL to a reader.
160 200
201 + use super::url_filter::resolve_internal_url;
161 202 use super::{UrlPolicy, sanitize_css, sanitize_html};
162 203
163 - /// Attribute prefixes that make markup executable. `on*` covers every
164 - /// handler HTML has and every one it grows.
165 - const EXECUTABLE_ATTR_PREFIXES: &[&str] = &["on"];
204 + /// Element names no sanitized output may contain, whatever the input was.
205 + ///
206 + /// Matched as parsed tag names rather than as substrings, for the reason
207 + /// written on [`assert_html_safe`].
208 + const FORBIDDEN_TAGS: &[&str] = &[
209 + "script", "iframe", "object", "embed", "form", "svg", "math", "base", "meta", "link",
210 + "style", "template", "noscript", "input", "button", "textarea", "select", "applet",
211 + "frame", "frameset",
212 + ];
166 213
167 - /// Substrings that must never appear in sanitized output, whatever the
168 - /// input was. Lowercased before matching, so case games do not evade it.
169 - const NEVER_IN_OUTPUT: &[&str] = &[
170 - "<script",
171 - "</script",
214 + /// Attribute values that must never appear on a URL-bearing attribute.
215 + ///
216 + /// The URL gate rejects all of these already, by resolving rather than by
217 + /// matching. They are kept as a named, independent floor so that a finding
218 + /// says "a script scheme reached a reader" rather than the more abstract
219 + /// "the gate would have rejected this".
220 + const FORBIDDEN_SCHEMES: &[&str] = &["javascript:", "vbscript:", "data:text/html"];
221 +
222 + /// Attributes whose value a browser fetches or navigates to.
223 + ///
224 + /// A SUPERSET of what [`super::html_sanitizer`] gates, on purpose: see the
225 + /// module docs. These are the names that make a value a live capability
226 + /// rather than a string.
227 + const URL_ATTRS: &[&str] = &[
228 + "href",
229 + "src",
230 + "srcset",
231 + "action",
232 + "formaction",
233 + "poster",
234 + "xlink:href",
235 + "data",
236 + "codebase",
237 + "background",
238 + "cite",
239 + "longdesc",
240 + "profile",
241 + "usemap",
242 + ];
243 +
244 + /// Substrings that must never appear in sanitized CSS.
245 + ///
246 + /// Still a scan, because CSS output is not markup and has no text nodes to
247 + /// confuse with structure. The CSS oracle is the business of infra
248 + /// `bd562c12`, the css_sanitizer soak target, which is where the same
249 + /// question -- is a substring here a property, or a seed's hostname? --
250 + /// gets asked of this list.
251 + const NEVER_IN_CSS: &[&str] = &[
172 252 "javascript:",
173 253 "vbscript:",
174 254 "expression(",
175 - "<iframe",
176 - "<object",
177 - "<embed",
178 - "<form",
179 - "srcdoc",
180 255 "@import",
256 + "</style",
257 + "data:text/html",
181 258 ];
182 259
183 260 /// Panic if sanitized HTML violates the floor or the closed system.
@@ -186,17 +263,76 @@
186 263 ///
187 264 /// By design. It is an oracle, and a panic is how it reports.
188 265 pub fn check_html(input: &str, policy: &UrlPolicy) {
189 - let (clean, _rejections) = sanitize_html(input, policy);
190 - assert_safe(&clean, "html", input);
266 + // Every pass must satisfy the floor, and the chain must reach a fixed
267 + // point. Both halves matter and they catch different things.
268 + //
269 + // THE FLOOR ON EVERY PASS is the mutation-XSS property: the risk is
270 + // that something downstream parses the same bytes twice and the second
271 + // parse reveals markup the first pass did not emit.
272 + //
273 + // SETTLING is the weaker structural property, and getting its STRENGTH
274 + // right took two findings from this target's first sessions (infra
275 + // `6f21a29a`). It began as `sanitize_html(clean) == clean`, which is
276 + // false:
277 + //
278 + // <a href="HtTpS://E0" al/id>case game
279 + // pass 1: <a rel="nofollow ugc" id="">case game</a>
280 + // pass 2: <a id="" rel="nofollow ugc">case game</a>
281 + //
282 + // The href is off-platform and every pass correctly drops it; what
283 + // differs is ATTRIBUTE ORDER, because pass one still had an `href` in
284 + // hand when ammonia ordered the attributes. No browser distinguishes
285 + // the two.
286 + //
287 + // Convergence by the third pass -- docengine's answer to the same
288 + // question, infra `15991c40` -- was then also too strong. Deeply
289 + // misnested input (`<h5>` inside `<h5>` inside `<a>`, committed under
290 + // `fuzz/regressions/`) has html5ever's tree builder peeling roughly one
291 + // level of nesting per pass, and that case needs three. A fixed small
292 + // number is not a property; it is the nesting depth of whichever input
293 + // happened to be tried first.
294 + //
295 + // So what is asserted is what is actually true and actually worth
296 + // having: **it settles**. A sanitizer that never reaches a fixed point
297 + // is one where each parse sees something new, which is the real defect
298 + // this guards. Nothing in this crate rewrites its own output in
299 + // production anyway -- sanitization is render-time over the raw stored
300 + // HTML -- so the later passes exist to model a downstream reparse
301 + // rather than a code path we run.
302 + //
303 + // THE BOUND IS DERIVED FROM THE INPUT, not chosen. A pass that changes
304 + // anything removes at least one level of the misnesting the tree
305 + // builder is unwinding, and nesting depth cannot exceed the input
306 + // length, so the input's own length is sufficient: blowing through it
307 + // means the chain is not converging at all rather than converging
308 + // slowly. The `32` floor gives tiny inputs room.
309 + //
310 + // A CONSTANT WAS TRIED FIRST AND WAS WRONG THREE TIMES, which is why.
311 + // `twice == clean` fired on attribute order; convergence-by-the-third-
312 + // pass -- docengine's answer to the same question, infra `15991c40` --
313 + // fired on `<h5>` misnesting that needs three; a budget of 16 fired on
314 + // nested `<pre>` that needs 17. Every time, the finding was about the
315 + // constant rather than about the sanitizer.
316 + //
317 + // Measured over the 16,437-input corpus this target had built by then:
318 + // 15,814 settle in a single pass, the tail runs 2 to 10, and 122 of
319 + // them GROW on the way to their fixed point -- so "the output never
320 + // gets longer" was checked as a candidate property and is false.
321 + let budget = input.len().max(32);
191 322
192 - // Sanitizing twice must change nothing. A sanitizer whose output is not
193 - // a fixed point is one where a second pass could reveal markup the
194 - // first pass created, which is how allowlist filters have historically
195 - // been escaped.
196 - let (again, _) = sanitize_html(&clean, policy);
197 - assert_eq!(
198 - again, clean,
199 - "sanitize_html is not idempotent for {input:?}"
323 + let (mut clean, _rejections) = sanitize_html(input, policy);
324 + assert_html_safe(&clean, "html", input, policy);
325 +
326 + for pass in 2..=budget {
327 + let (next, _) = sanitize_html(&clean, policy);
328 + assert_html_safe(&next, &format!("html (pass {pass})"), input, policy);
329 + if next == clean {
330 + return;
331 + }
332 + clean = next;
333 + }
334 + panic!(
335 + "sanitize_html had not settled after {budget} passes for {input:?}\n last: {clean:?}"
200 336 );
201 337 }
202 338
@@ -207,7 +343,7 @@
207 343 /// By design.
208 344 pub fn check_css(input: &str, owner_scope: &str, policy: &UrlPolicy) {
209 345 let (clean, _rejections) = sanitize_css(input, owner_scope, policy);
210 - assert_safe(&clean, "css", input);
346 + assert_css_safe(&clean, "css", input);
211 347
212 348 // Everything that survives is confined to the owner's canvas. A rule
213 349 // that escapes it can restyle platform chrome, which is the CSS half of
@@ -236,48 +372,194 @@
236 372 // where the printer can construct what the parser rejected, which is
237 373 // the CSS analogue of the mutation-XSS class.
238 374 let (again, _) = sanitize_css(&clean, owner_scope, policy);
239 - assert_safe(&again, "css (second pass)", input);
375 + assert_css_safe(&again, "css (second pass)", input);
240 376 }
241 377
242 - /// The two properties, over one piece of sanitized output.
243 - fn assert_safe(clean: &str, what: &str, input: &str) {
244 - let lowered = clean.to_ascii_lowercase();
378 + /// The floor and the closed system, over one piece of sanitized markup.
379 + ///
380 + /// **Parses tag and attribute names; never pattern-matches the whole
381 + /// string.** That distinction is the correctness of this function rather
382 + /// than an optimisation. Quoted attribute values are skipped whole, so
383 + /// entity-escaped content sitting inside one -- which is the sanitizer
384 + /// working -- can never reach an assertion.
385 + ///
386 + /// Splitting on `<` is sound BECAUSE of what is under test: a sanitizer
387 + /// that escapes text is the premise, so an unescaped `<` in the output can
388 + /// only be a tag. If that ever stopped being true, the tag-name assertion
389 + /// is what would fire, which is the right failure.
390 + ///
391 + /// # Panics
392 + /// If any tag is a forbidden element, carries an event-handler attribute,
393 + /// or points a URL-bearing attribute anywhere but on-platform.
394 + fn assert_html_safe(clean: &str, what: &str, input: &str, policy: &UrlPolicy) {
395 + let lower = clean.to_ascii_lowercase();
396 + let bytes = lower.as_bytes();
397 + let fail =
398 + |msg: &str| -> ! { panic!("{what} {msg}\n input: {input:?}\n output: {clean:?}") };
245 399
246 - for needle in NEVER_IN_OUTPUT {
400 + let mut i = 0;
401 + while let Some(off) = lower[i..].find('<') {
402 + let mut p = i + off + 1;
403 + if bytes.get(p) == Some(&b'/') {
404 + p += 1;
405 + }
406 + let name_start = p;
407 + while p < bytes.len()
408 + && (bytes[p].is_ascii_alphanumeric() || bytes[p] == b':' || bytes[p] == b'-')
409 + {
410 + p += 1;
411 + }
412 + let tag = &lower[name_start..p];
413 + if FORBIDDEN_TAGS.contains(&tag) {
414 + fail(&format!("emitted a <{tag}> element"));
415 + }
416 +
417 + // Attributes, until the tag closes.
418 + while p < bytes.len() && bytes[p] != b'>' {
419 + if bytes[p].is_ascii_whitespace() || bytes[p] == b'/' {
420 + p += 1;
421 + continue;
422 + }
423 + let attr_start = p;
424 + while p < bytes.len()
425 + && !bytes[p].is_ascii_whitespace()
426 + && bytes[p] != b'='
427 + && bytes[p] != b'>'
428 + {
429 + p += 1;
430 + }
431 + let attr = &lower[attr_start..p];
432 +
433 + // Event handlers, by shape rather than by name, so a handler
434 + // HTML grows after this was written is still caught.
435 + if attr.starts_with("on") && attr.len() > 2 {
436 + fail(&format!("emitted the event handler {attr:?} on <{tag}>"));
437 + }
438 +
439 + while p < bytes.len() && bytes[p].is_ascii_whitespace() {
440 + p += 1;
441 + }
442 + if bytes.get(p) != Some(&b'=') {
443 + continue;
444 + }
445 + p += 1;
446 + while p < bytes.len() && bytes[p].is_ascii_whitespace() {
447 + p += 1;
448 + }
449 + let (value, next) = match bytes.get(p) {
450 + Some(&q @ (b'"' | b'\'')) => {
451 + let vs = p + 1;
452 + let mut e = vs;
453 + while e < bytes.len() && bytes[e] != q {
454 + e += 1;
455 + }
456 + (&lower[vs..e.min(bytes.len())], (e + 1).min(bytes.len()))
457 + }
458 + _ => {
459 + let vs = p;
460 + let mut e = vs;
461 + while e < bytes.len() && !bytes[e].is_ascii_whitespace() && bytes[e] != b'>'
462 + {
463 + e += 1;
464 + }
465 + (&lower[vs..e], e)
466 + }
467 + };
468 +
469 + if URL_ATTRS.contains(&attr) {
470 + // A browser decodes character references before it resolves
471 + // a URL, so the oracle has to as well. Skipping this step
472 + // is how `&#106;avascript:` reads as harmless.
473 + let decoded = decode_entities(value);
474 + let v = decoded.trim();
475 +
476 + for bad in FORBIDDEN_SCHEMES {
477 + if v.starts_with(bad) {
478 + fail(&format!("pointed {attr}= at {bad:?} on <{tag}>"));
479 + }
480 + }
481 +
482 + // The closed system, stated as the property rather than as
483 + // a list of hostnames: whatever survived here must be
484 + // something the URL gate accepts. `srcset` is a list, so
485 + // each candidate's first token is checked separately.
486 + let candidates: Vec<&str> = if attr == "srcset" {
487 + v.split(',')
488 + .map(str::trim)
489 + .filter(|c| !c.is_empty())
490 + .map(|c| c.split_whitespace().next().unwrap_or(""))
491 + .collect()
492 + } else {
493 + vec![v]
494 + };
495 + for candidate in candidates {
496 + if resolve_internal_url(candidate, policy, "oracle").is_err() {
497 + fail(&format!(
498 + "kept an off-platform {attr}={candidate:?} on <{tag}>"
499 + ));
500 + }
501 + }
502 + }
503 + p = next;
504 + }
505 + i = p.max(i + off + 1);
506 + }
507 + }
508 +
509 + /// The floor, over one piece of sanitized CSS.
510 + fn assert_css_safe(clean: &str, what: &str, input: &str) {
511 + let lowered = clean.to_ascii_lowercase();
512 + for needle in NEVER_IN_CSS {
247 513 assert!(
248 514 !lowered.contains(needle),
249 515 "{what} output kept {needle:?} for input {input:?}: {clean:?}"
250 516 );
251 517 }
518 + }
252 519
253 - // Event handlers, found by shape rather than by name, so a handler
254 - // added to HTML after this was written is still caught.
255 - for prefix in EXECUTABLE_ATTR_PREFIXES {
256 - let pattern = format!(" {prefix}");
257 - let mut rest = lowered.as_str();
258 - while let Some(at) = rest.find(&pattern) {
259 - let tail = &rest[at + 1..];
260 - let name: String = tail.chars().take_while(char::is_ascii_alphabetic).collect();
261 - // ` on` only matters when it is an attribute, i.e. the name is
262 - // immediately followed by `=`. Prose inside a text node is not.
263 - let is_attr = tail[name.len()..].starts_with('=');
264 - assert!(
265 - !(is_attr && name.starts_with(prefix) && name.len() > prefix.len()),
266 - "{what} output kept the handler attribute {name:?} for input {input:?}"
267 - );
268 - rest = &rest[at + 1..];
520 + /// Decode the character references a sanitizer emits, and nothing else.
521 + ///
522 + /// Ammonia escapes attribute values on the way out, so the oracle sees
523 + /// `&quot;` where a browser sees `"`. Only the five named references HTML
524 + /// serialization produces, plus numeric ones, are decoded: this exists to
525 + /// read the sanitizer's own output correctly, not to reimplement html5ever's
526 + /// entity table.
527 + fn decode_entities(value: &str) -> String {
528 + let mut out = String::with_capacity(value.len());
529 + let mut rest = value;
530 + while let Some(at) = rest.find('&') {
531 + out.push_str(&rest[..at]);
532 + let tail = &rest[at..];
533 + let Some(end) = tail.find(';') else {
534 + out.push_str(tail);
535 + return out;
536 + };
537 + let entity = &tail[1..end];
538 + match entity {
539 + "quot" => out.push('"'),
540 + "apos" => out.push('\''),
541 + "amp" => out.push('&'),
542 + "lt" => out.push('<'),
543 + "gt" => out.push('>'),
544 + _ => {
545 + let numeric = entity.strip_prefix('#').and_then(|n| {
546 + let code = match n.strip_prefix('x').or_else(|| n.strip_prefix('X')) {
547 + Some(hex) => u32::from_str_radix(hex, 16).ok()?,
548 + None => n.parse::<u32>().ok()?,
549 + };
550 + char::from_u32(code)
551 + });
552 + match numeric {
553 + Some(c) => out.push(c),
554 + // Not a reference this function knows. Keep it verbatim
555 + // rather than guessing; an unknown entity is text.
556 + None => out.push_str(&tail[..=end]),
557 + }
558 + }
269 559 }
560 + rest = &tail[end + 1..];
270 561 }
271 -
272 - // The closed system, checked on the printed output: no scheme-relative
273 - // or absolute reference to a host the policy did not allow can be
274 - // present, because the sanitizers strip rather than rewrite.
275 - for marker in ["http://", "//evil", "data:text/html"] {
276 - assert!(
277 - !lowered.contains(marker),
278 - "{what} output kept an off-platform reference {marker:?} for input {input:?}: \
279 - {clean:?}"
280 - );
281 - }
562 + out.push_str(rest);
563 + out
282 564 }
283 565 }
@@ -1,0 +1,4 @@
1 + target
2 + corpus
3 + artifacts
4 + coverage
@@ -1,0 +1,1160 @@
1 + # This file is automatically @generated by Cargo.
2 + # It is not intended for manual editing.
3 + version = 4
4 +
5 + [[package]]
6 + name = "ahash"
7 + version = "0.8.12"
8 + source = "registry+https://github.com/rust-lang/crates.io-index"
9 + checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
10 + dependencies = [
11 + "cfg-if",
12 + "getrandom 0.3.4",
13 + "once_cell",
14 + "version_check",
15 + "zerocopy",
16 + ]
17 +
18 + [[package]]
19 + name = "ammonia"
20 + version = "4.1.4"
21 + source = "registry+https://github.com/rust-lang/crates.io-index"
22 + checksum = "dc6d763210e2eb7670d1a5183a08bebefa3f97db2a738a684f2ce00bd49f681d"
23 + dependencies = [
24 + "cssparser 0.37.0",
25 + "html5ever",
26 + "maplit",
27 + "url",
28 + ]
29 +
30 + [[package]]
31 + name = "arbitrary"
32 + version = "1.4.2"
33 + source = "registry+https://github.com/rust-lang/crates.io-index"
34 + checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
35 + dependencies = [
36 + "derive_arbitrary",
37 + ]
38 +
39 + [[package]]
40 + name = "bitflags"
41 + version = "2.13.1"
42 + source = "registry+https://github.com/rust-lang/crates.io-index"
43 + checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
44 +
45 + [[package]]
46 + name = "cc"
47 + version = "1.4.4"
48 + source = "registry+https://github.com/rust-lang/crates.io-index"
49 + checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273"
50 + dependencies = [
51 + "find-msvc-tools",
52 + "jobserver",
53 + "libc",
54 + "shlex",
55 + ]
56 +
57 + [[package]]
58 + name = "cfg-if"
59 + version = "1.0.4"
60 + source = "registry+https://github.com/rust-lang/crates.io-index"
61 + checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
62 +
63 + [[package]]
64 + name = "const-str"
65 + version = "0.3.2"
66 + source = "registry+https://github.com/rust-lang/crates.io-index"
67 + checksum = "21077772762a1002bb421c3af42ac1725fa56066bfc53d9a55bb79905df2aaf3"
68 + dependencies = [
69 + "const-str-proc-macro",
70 + ]
71 +
72 + [[package]]
73 + name = "const-str-proc-macro"
74 + version = "0.3.2"
75 + source = "registry+https://github.com/rust-lang/crates.io-index"
76 + checksum = "5e1e0fdd2e5d3041e530e1b21158aeeef8b5d0e306bc5c1e3d6cf0930d10e25a"
77 + dependencies = [
78 + "proc-macro2",
79 + "quote",
80 + "syn 1.0.109",
81 + ]
82 +
83 + [[package]]
84 + name = "convert_case"
85 + version = "0.6.0"
86 + source = "registry+https://github.com/rust-lang/crates.io-index"
87 + checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca"
88 + dependencies = [
89 + "unicode-segmentation",
90 + ]
91 +
92 + [[package]]
93 + name = "cssparser"
94 + version = "0.33.0"
95 + source = "registry+https://github.com/rust-lang/crates.io-index"
96 + checksum = "9be934d936a0fbed5bcdc01042b770de1398bf79d0e192f49fa7faea0e99281e"
97 + dependencies = [
98 + "cssparser-macros",
99 + "dtoa-short",
100 + "itoa",
101 + "phf 0.11.3",
102 + "smallvec",
103 + ]
104 +
105 + [[package]]
106 + name = "cssparser"
107 + version = "0.37.0"
108 + source = "registry+https://github.com/rust-lang/crates.io-index"
109 + checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98"
110 + dependencies = [
111 + "dtoa-short",
112 + "itoa",
113 + "smallvec",
114 + ]
115 +
116 + [[package]]
117 + name = "cssparser-color"
118 + version = "0.1.0"
119 + source = "registry+https://github.com/rust-lang/crates.io-index"
120 + checksum = "556c099a61d85989d7af52b692e35a8d68a57e7df8c6d07563dc0778b3960c9f"
121 + dependencies = [
122 + "cssparser 0.33.0",
123 + ]
124 +
125 + [[package]]
126 + name = "cssparser-macros"
127 + version = "0.6.1"
128 + source = "registry+https://github.com/rust-lang/crates.io-index"
129 + checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
130 + dependencies = [
131 + "quote",
132 + "syn 2.0.119",
133 + ]
134 +
135 + [[package]]
136 + name = "custom-pages"
137 + version = "0.1.0"
138 + dependencies = [
139 + "ammonia",
140 + "lightningcss",
141 + "tracing",
142 + "url",
143 + ]
144 +
145 + [[package]]
146 + name = "custom-pages-fuzz"
147 + version = "0.0.0"
148 + dependencies = [
149 + "custom-pages",
150 + "libfuzzer-sys",
151 + ]
152 +
153 + [[package]]
154 + name = "data-encoding"
155 + version = "2.11.1"
156 + source = "registry+https://github.com/rust-lang/crates.io-index"
157 + checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
158 +
159 + [[package]]
160 + name = "derive_arbitrary"
161 + version = "1.4.2"
162 + source = "registry+https://github.com/rust-lang/crates.io-index"
163 + checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
164 + dependencies = [
165 + "proc-macro2",
166 + "quote",
167 + "syn 2.0.119",
168 + ]
169 +
170 + [[package]]
171 + name = "displaydoc"
172 + version = "0.2.7"
173 + source = "registry+https://github.com/rust-lang/crates.io-index"
174 + checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
175 + dependencies = [
176 + "proc-macro2",
177 + "quote",
178 + "syn 3.0.4",
179 + ]
180 +
181 + [[package]]
182 + name = "dtoa"
183 + version = "1.0.11"
184 + source = "registry+https://github.com/rust-lang/crates.io-index"
185 + checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
186 +
187 + [[package]]
188 + name = "dtoa-short"
189 + version = "0.3.5"
190 + source = "registry+https://github.com/rust-lang/crates.io-index"
191 + checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
192 + dependencies = [
193 + "dtoa",
194 + ]
195 +
196 + [[package]]
197 + name = "either"
198 + version = "1.18.0"
199 + source = "registry+https://github.com/rust-lang/crates.io-index"
200 + checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
201 +
202 + [[package]]
203 + name = "equivalent"
204 + version = "1.0.2"
205 + source = "registry+https://github.com/rust-lang/crates.io-index"
206 + checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
207 +
208 + [[package]]
209 + name = "fastrand"
210 + version = "2.5.0"
211 + source = "registry+https://github.com/rust-lang/crates.io-index"
212 + checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
213 +
214 + [[package]]
215 + name = "find-msvc-tools"
216 + version = "0.1.11"
217 + source = "registry+https://github.com/rust-lang/crates.io-index"
218 + checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
219 +
220 + [[package]]
221 + name = "form_urlencoded"
222 + version = "1.2.2"
223 + source = "registry+https://github.com/rust-lang/crates.io-index"
224 + checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
225 + dependencies = [
226 + "percent-encoding",
227 + ]
228 +
229 + [[package]]
230 + name = "getrandom"
231 + version = "0.3.4"
232 + source = "registry+https://github.com/rust-lang/crates.io-index"
233 + checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
234 + dependencies = [
235 + "cfg-if",
236 + "libc",
237 + "r-efi 5.3.0",
238 + "wasip2",
239 + ]
240 +
241 + [[package]]
242 + name = "getrandom"
243 + version = "0.4.3"
244 + source = "registry+https://github.com/rust-lang/crates.io-index"
245 + checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
246 + dependencies = [
247 + "cfg-if",
248 + "libc",
249 + "r-efi 6.0.0",
250 + ]
251 +
252 + [[package]]
253 + name = "hashbrown"
254 + version = "0.17.1"
255 + source = "registry+https://github.com/rust-lang/crates.io-index"
256 + checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
257 +
258 + [[package]]
259 + name = "html5ever"
260 + version = "0.39.0"
261 + source = "registry+https://github.com/rust-lang/crates.io-index"
262 + checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
263 + dependencies = [
264 + "log",
265 + "markup5ever",
266 + ]
267 +
268 + [[package]]
269 + name = "icu_collections"
270 + version = "2.3.0"
271 + source = "registry+https://github.com/rust-lang/crates.io-index"
272 + checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
273 + dependencies = [
274 + "displaydoc",
275 + "potential_utf",
276 + "utf8_iter",
277 + "yoke",
278 + "zerofrom",
279 + "zerovec",
280 + ]
281 +
282 + [[package]]
283 + name = "icu_locale_core"
284 + version = "2.3.0"
285 + source = "registry+https://github.com/rust-lang/crates.io-index"
286 + checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
287 + dependencies = [
288 + "displaydoc",
289 + "litemap",
290 + "tinystr",
291 + "writeable",
292 + "zerovec",
293 + ]
294 +
295 + [[package]]
296 + name = "icu_normalizer"
297 + version = "2.3.0"
298 + source = "registry+https://github.com/rust-lang/crates.io-index"
299 + checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
300 + dependencies = [
301 + "icu_collections",
302 + "icu_normalizer_data",
303 + "icu_properties",
304 + "icu_provider",
305 + "smallvec",
306 + "zerovec",
307 + ]
308 +
309 + [[package]]
310 + name = "icu_normalizer_data"
311 + version = "2.3.0"
312 + source = "registry+https://github.com/rust-lang/crates.io-index"
313 + checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
314 +
315 + [[package]]
316 + name = "icu_properties"
317 + version = "2.3.0"
318 + source = "registry+https://github.com/rust-lang/crates.io-index"
319 + checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
320 + dependencies = [
321 + "displaydoc",
322 + "icu_collections",
323 + "icu_locale_core",
324 + "icu_properties_data",
325 + "icu_provider",
326 + "zerotrie",
327 + "zerovec",
328 + ]
329 +
330 + [[package]]
331 + name = "icu_properties_data"
332 + version = "2.3.0"
333 + source = "registry+https://github.com/rust-lang/crates.io-index"
334 + checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
335 +
336 + [[package]]
337 + name = "icu_provider"
338 + version = "2.3.1"
339 + source = "registry+https://github.com/rust-lang/crates.io-index"
340 + checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
341 + dependencies = [
342 + "displaydoc",
343 + "icu_locale_core",
344 + "writeable",
345 + "yoke",
346 + "zerofrom",
347 + "zerotrie",
348 + "zerovec",
349 + ]
350 +
351 + [[package]]
352 + name = "idna"
353 + version = "1.1.0"
354 + source = "registry+https://github.com/rust-lang/crates.io-index"
355 + checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
356 + dependencies = [
357 + "idna_adapter",
358 + "smallvec",
359 + "utf8_iter",
360 + ]
361 +
362 + [[package]]
363 + name = "idna_adapter"
364 + version = "1.2.2"
365 + source = "registry+https://github.com/rust-lang/crates.io-index"
366 + checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
367 + dependencies = [
368 + "icu_normalizer",
369 + "icu_properties",
370 + ]
371 +
372 + [[package]]
373 + name = "indexmap"
374 + version = "2.14.0"
375 + source = "registry+https://github.com/rust-lang/crates.io-index"
376 + checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
377 + dependencies = [
378 + "equivalent",
379 + "hashbrown",
380 + "serde",
381 + "serde_core",
382 + ]
383 +
384 + [[package]]
385 + name = "itertools"
386 + version = "0.10.5"
387 + source = "registry+https://github.com/rust-lang/crates.io-index"
388 + checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
389 + dependencies = [
390 + "either",
391 + ]
392 +
393 + [[package]]
394 + name = "itoa"
395 + version = "1.0.18"
396 + source = "registry+https://github.com/rust-lang/crates.io-index"
397 + checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
398 +
399 + [[package]]
400 + name = "jobserver"
401 + version = "0.1.35"
402 + source = "registry+https://github.com/rust-lang/crates.io-index"
403 + checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
404 + dependencies = [
405 + "getrandom 0.4.3",
406 + "libc",
407 + ]
408 +
409 + [[package]]
410 + name = "lazy_static"
411 + version = "1.5.0"
412 + source = "registry+https://github.com/rust-lang/crates.io-index"
413 + checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
414 +
415 + [[package]]
416 + name = "libc"
417 + version = "0.2.189"
418 + source = "registry+https://github.com/rust-lang/crates.io-index"
419 + checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
420 +
421 + [[package]]
422 + name = "libfuzzer-sys"
423 + version = "0.4.13"
424 + source = "registry+https://github.com/rust-lang/crates.io-index"
425 + checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2"
426 + dependencies = [
427 + "arbitrary",
428 + "cc",
429 + ]
430 +
431 + [[package]]
432 + name = "lightningcss"
433 + version = "1.0.0-alpha.71"
434 + source = "registry+https://github.com/rust-lang/crates.io-index"
435 + checksum = "cb6314c2f0590ac93c86099b98bb7ba8abcf759bfd89604ffca906472bb54937"
436 + dependencies = [
437 + "ahash",
438 + "bitflags",
439 + "const-str",
440 + "cssparser 0.33.0",
441 + "cssparser-color",
442 + "data-encoding",
443 + "getrandom 0.3.4",
444 + "indexmap",
445 + "itertools",
446 + "lazy_static",
447 + "lightningcss-derive",
448 + "parcel_selectors",
449 + "pastey",
450 + "pathdiff",
451 + "smallvec",
452 + ]
453 +
454 + [[package]]
455 + name = "lightningcss-derive"
456 + version = "1.0.0-alpha.43"
457 + source = "registry+https://github.com/rust-lang/crates.io-index"
458 + checksum = "84c12744d1279367caed41739ef094c325d53fb0ffcd4f9b84a368796f870252"
459 + dependencies = [
460 + "convert_case",
461 + "proc-macro2",
462 + "quote",
463 + "syn 1.0.109",
464 + ]
465 +
466 + [[package]]
467 + name = "litemap"
468 + version = "0.8.3"
469 + source = "registry+https://github.com/rust-lang/crates.io-index"
470 + checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
471 +
472 + [[package]]
473 + name = "lock_api"
474 + version = "0.4.14"
475 + source = "registry+https://github.com/rust-lang/crates.io-index"
476 + checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
477 + dependencies = [
478 + "scopeguard",
479 + ]
480 +
481 + [[package]]
482 + name = "log"
483 + version = "0.4.34"
484 + source = "registry+https://github.com/rust-lang/crates.io-index"
485 + checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
486 +
487 + [[package]]
488 + name = "maplit"
489 + version = "1.0.2"
490 + source = "registry+https://github.com/rust-lang/crates.io-index"
491 + checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
492 +
493 + [[package]]
494 + name = "markup5ever"
495 + version = "0.39.0"
496 + source = "registry+https://github.com/rust-lang/crates.io-index"
497 + checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
498 + dependencies = [
499 + "log",
500 + "tendril",
Lines truncated
@@ -1,0 +1,35 @@
1 + [package]
2 + name = "custom-pages-fuzz"
3 + version = "0.0.0"
4 + publish = false
5 + edition = "2024"
6 +
7 + [package.metadata]
8 + cargo-fuzz = true
9 +
10 + [dependencies]
11 + # `arbitrary` with `derive` is what lets a target take `&str` instead of
12 + # `&[u8]`. Both inputs here are text grammars -- HTML and CSS -- and byte input
13 + # would spend most of the fuzzer's budget rediscovering UTF-8 before it reached
14 + # an angle bracket or a brace.
15 + libfuzzer-sys = { version = "0.4", features = ["arbitrary-derive"] }
16 +
17 + [dependencies.custom-pages]
18 + path = ".."
19 +
20 + # `fuzz/Cargo.lock` IS COMMITTED, and it is load-bearing rather than incidental.
21 + # A fresh resolve of this crate picks `parcel_selectors` 0.28.3, whose patch bump
22 + # moved it to `cssparser` 0.37 while the exactly-pinned `lightningcss`
23 + # alpha.71 is still on 0.33. The two then meet in one graph and the build fails
24 + # with 61 type errors inside lightningcss, none of them about anything in this
25 + # tree. That is the same pre-1.0 hazard the parent manifest documents on its
26 + # lightningcss pin, arriving one level down where the exact pin cannot reach it,
27 + # so the lock is what holds `parcel_selectors` at 0.28.2. Do not `cargo update`
28 + # this directory without building afterwards.
29 +
30 + [[bin]]
31 + name = "html"
32 + path = "fuzz_targets/html.rs"
33 + test = false
34 + doc = false
35 + bench = false
@@ -1,0 +1,55 @@
1 + //! Structured fuzz over the custom-pages HTML sanitizer.
2 + //!
3 + //! Row 1 of `astra-soak-overview`, and the one named there as most likely to
4 + //! find something: this is the filter standing between creator-authored HTML
5 + //! and every reader of a public page, so a finding here is a security finding
6 + //! rather than a robustness one.
7 + //!
8 + //! ## The oracle lives in the crate, not here
9 + //!
10 + //! Everything asserted is `custom_pages::oracle::check_html`. The committed
11 + //! replay in `tests/regressions.rs` calls the same function on stable, so a
12 + //! crash found here becomes a permanent test by copying one file into
13 + //! `fuzz/regressions/`, and neither side can drift into checking less than the
14 + //! other. The shape is `MNW/shared/git-command`'s, copied deliberately.
15 + //!
16 + //! What it asserts, over output and never over input: the safety floor (nothing
17 + //! executable survives -- no `<script>`, no `on*` handler, no `javascript:`),
18 + //! the closed system (no URL survives that resolves off-platform), and
19 + //! idempotence of `sanitize_html`. The last is the mutation-XSS property, where
20 + //! a second parse of the same bytes sees markup the first pass did not emit.
21 + //!
22 + //! Not-panicking is the weakest thing a fuzz target can assert, and a target
23 + //! that asserts only that reports clean forever while the allowlist quietly
24 + //! starts letting an `onerror` through.
25 + //!
26 + //! ## The policy is a fixture, not fuzzed input
27 + //!
28 + //! `UrlPolicy` is configuration the server supplies, not something an attacker
29 + //! reaches. Fuzzing it would spend the budget on host lists nobody deploys and
30 + //! would make every finding ambiguous between "the sanitizer is wrong" and
31 + //! "this policy was nonsense". It is the same fixture `tests/regressions.rs`
32 + //! uses, so a crash replays there unchanged.
33 +
34 + #![no_main]
35 +
36 + use libfuzzer_sys::fuzz_target;
37 + use std::sync::LazyLock;
38 +
39 + /// The owner scope woven into the canvas selector. Any fixed UUID does; this is
40 + /// the one the regression replay uses.
41 + static POLICY: LazyLock<custom_pages::UrlPolicy> = LazyLock::new(|| {
42 + custom_pages::UrlPolicy::new(
43 + "https://u.makenot.work/alice/proj",
44 + [
45 + "makenot.work".to_string(),
46 + "u.makenot.work".to_string(),
47 + "cdn.makenot.work".to_string(),
48 + ],
49 + )
50 + .expect("the fixture policy is well-formed")
51 + });
52 +
53 + fuzz_target!(|input: &str| {
54 + custom_pages::oracle::check_html(input, &POLICY);
55 + });
@@ -1,0 +1,1 @@
1 + <img src=ޘttps://evil.com/a.png">
Binary file
@@ -1,0 +1,1 @@
1 + &stop:m/g<s><<><s<:/<s>g/<a>g<i><p5@<p>: <s>g<s>g<p5>:<s>< href=""">da<h5>>(>><<h5[>"<h5>>(ta ul</q>mg<a href=""">da<h5>>(img<a href=""">da<h5>>(>><<h5[>"<h5>>(ta ul</q>mg<a href=""">da<h5>>(>><<h5[>>><s>:/<i<s><table helgm/g<s<mci>rshm.coe/<s>g/<a>gl
Binary file