Skip to main content

max / makenotwork

3.7 KB · 83 lines History Blame Raw
1 //! Structured fuzz over the custom-pages CSS sanitizer.
2 //!
3 //! Row 2 of `astra-soak-overview`, and the other half of the same trust
4 //! boundary as the `html` target: creator-authored input rendered on a public
5 //! page. What CSS can do that HTML cannot is escape the user canvas and restyle
6 //! platform chrome, so scoping is asserted here alongside the safety floor.
7 //!
8 //! ## The oracle lives in the crate, not here
9 //!
10 //! Everything asserted is `custom_pages::oracle::check_css`, the same function
11 //! `tests/regressions.rs` replays on stable. A crash found here becomes a
12 //! permanent test by copying one file into `fuzz/regressions/`, and neither
13 //! side can drift into checking less than the other.
14 //!
15 //! It covers BOTH scoping entry points. The doors were counted before this was
16 //! written, as the task instructs: nine call sites, all in MNW server, but two
17 //! entry points -- `sanitize_css` for a profile or project page and
18 //! `sanitize_item_css` for the item pages that wear the parent project's
19 //! styling re-scoped to `.item-canvas#ic-`. Only the first had an oracle over
20 //! it until this target was built.
21 //!
22 //! ## It parses the output rather than scanning it
23 //!
24 //! Worth knowing before reading a finding from this target. The oracle it
25 //! replaced matched substrings over the whole stylesheet and had four false
26 //! positives, the plainest of which was a two-step `@keyframes`: the step
27 //! `100%` read as a selector that had escaped the canvas. Ordinary creator CSS,
28 //! reported as a security finding. What ships reparses the printed sheet and
29 //! walks the AST, so `@import` is forbidden as a rule rather than as a string
30 //! and a `content: "@import"` is what it is -- text.
31
32 #![no_main]
33
34 use libfuzzer_sys::fuzz_target;
35 use std::sync::LazyLock;
36
37 /// The owner scope woven into the canvas selector, and the one
38 /// `tests/regressions.rs` replays with, so a crash reproduces there unchanged.
39 /// It must stay id-safe: the sanitizer refuses a scope that is not, and fuzzing
40 /// that refusal would only measure `is_id_safe`.
41 const OWNER_SCOPE: &str = "11111111-1111-1111-1111-111111111111";
42
43 static POLICY: LazyLock<custom_pages::UrlPolicy> = LazyLock::new(|| {
44 custom_pages::UrlPolicy::new(
45 "https://u.makenot.work/alice/proj",
46 [
47 "makenot.work".to_string(),
48 "u.makenot.work".to_string(),
49 "cdn.makenot.work".to_string(),
50 ],
51 )
52 .expect("the fixture policy is well-formed")
53 });
54
55 /// The oracle runs on its own thread, with a large stack.
56 ///
57 /// Not a workaround for a defect in the crate: the SANITIZER is fine on a 2 MB
58 /// stack, which is what tokio gives a worker, and that was measured against the
59 /// input that prompted this (243 bytes in, 381 KB of flattened CSS out, survives
60 /// at 2 MB). What needs the room is the ORACLE's own reparse of that output,
61 /// which recurses per nesting level in a build where AddressSanitizer makes
62 /// every frame several times its normal size.
63 ///
64 /// The alternative was to stop checking large outputs, which would have blinded
65 /// the target on exactly the inputs most worth checking -- the ones that
66 /// amplify. Stack is cheaper than coverage.
67 fn checked(input: &str) {
68 let owned = input.to_string();
69 let handle = std::thread::Builder::new()
70 .stack_size(256 * 1024 * 1024)
71 .spawn(move || custom_pages::oracle::check_css(&owned, OWNER_SCOPE, &POLICY))
72 .expect("spawning the oracle thread");
73 // Propagate a panic rather than swallowing it: the panic IS the finding, and
74 // a target that joined and ignored the result would report clean forever.
75 if let Err(payload) = handle.join() {
76 std::panic::resume_unwind(payload);
77 }
78 }
79
80 fuzz_target!(|input: &str| {
81 checked(input);
82 });
83