Skip to main content

max / makenotwork

Extract MNW/shared/custom-pages, so the sanitizers are fuzzable alone Implements the ruling on infra e57220a4: extract, chosen against fuzz-in-place and against skipping the two soak targets. A fuzz target that has to build the whole server is a fuzz target nobody runs, and that is the only reason this moved. The 1,922 lines had zero `use crate::` to begin with, so it is a move rather than a refactor: the consumer supplies a UrlPolicy and an owner scope, and gets back sanitized output plus every reference that was stripped. `crate::custom_pages::` still resolves, through a re-export in lib.rs. The twenty-odd call sites and doc links that name it did not need to change, so this is a build-graph change and not a rename anyone has to chase. The one doc link that could not survive the move, url_filter's [`crate::config::Config`], is reworded rather than re-exported. Following the shape git-command set, the oracle lives in the crate rather than in a fuzz target: tests/regressions.rs runs it on stable and the soak tier's target will run it on nightly, so neither side can drift into checking less than the other. Not panicking is the weakest thing a target can assert, so the oracle asserts the safety floor (nothing executable survives either path) and the closed system (no URL survives that resolves off-platform), plus canvas scoping for CSS. 32 seeds are committed and replay on every cargo test. The oracle earned its place while being written: the obvious "sanitize twice, expect a fixed point" property is true of the HTML path and false of the CSS one, because scoping is a transform and not a filter. What must survive a second pass is safety, not identity, and the CSS oracle says so rather than asserting something convenient. Two version pins are load-bearing and both are commented where they live. lightningcss is pinned exactly because its CssRule enum is matched exhaustively on purpose -- a new at-rule must be a compile error and then a decision, never a silent pass-through, and alpha.72 adds PositionTry. The committed lock pins parcel_selectors to 0.28.2 because 0.28.3 moved to a cssparser major lightningcss itself does not use, which puts two incompatible cssparser versions in one graph. 68 crate tests, 3 replay tests, and the server's 1411 integration tests are green.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-25 18:32 UTC
Signed with PGP, not checked
Commit: f6be9175ccd0b33ec15d89c3e56b42371d62e42d
Parent: a813ee1
48 files changed, +1146 insertions, -174 deletions
M server/Cargo.lock +48 -39
@@ -2382,6 +2382,16 @@
2382 2382 "syn 2.0.118",
2383 2383 ]
2384 2384
2385 + [[package]]
2386 + name = "custom-pages"
2387 + version = "0.1.0"
2388 + dependencies = [
2389 + "ammonia",
2390 + "lightningcss",
2391 + "tracing",
2392 + "url",
2393 + ]
2394 +
2385 2395 [[package]]
2386 2396 name = "daachorse"
2387 2397 version = "3.0.2"
@@ -2865,7 +2875,7 @@
2865 2875 checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
2866 2876 dependencies = [
2867 2877 "libc",
2868 - "windows-sys 0.61.2",
2878 + "windows-sys 0.52.0",
2869 2879 ]
2870 2880
2871 2881 [[package]]
@@ -3650,7 +3660,7 @@
3650 3660 checksum = "1a791e6620676a875f362f3156ed213e73ca099a09bf992c18812abe65cc37b1"
3651 3661 dependencies = [
3652 3662 "bstr",
3653 - "hashbrown 0.17.1",
3663 + "hashbrown 0.15.5",
3654 3664 ]
3655 3665
3656 3666 [[package]]
@@ -5262,7 +5272,6 @@
5262 5272 name = "makenotwork"
5263 5273 version = "0.16.1"
5264 5274 dependencies = [
5265 - "ammonia",
5266 5275 "anyhow",
5267 5276 "apple-codesign",
5268 5277 "argon2",
@@ -5287,6 +5296,7 @@
5287 5296 "clap",
5288 5297 "const-oid 0.9.6",
5289 5298 "csv",
5299 + "custom-pages",
5290 5300 "dashmap",
5291 5301 "docengine",
5292 5302 "dotenvy",
@@ -5304,7 +5314,6 @@
5304 5314 "http-body-util",
5305 5315 "infer",
5306 5316 "jsonwebtoken",
5307 - "lightningcss",
5308 5317 "log",
5309 5318 "makeover",
5310 5319 "makeover-build",
@@ -6617,7 +6626,7 @@
6617 6626
6618 6627 [[package]]
6619 6628 name = "quasi-axum"
6620 - version = "0.56.0"
6629 + version = "0.59.0"
6621 6630 dependencies = [
6622 6631 "axum",
6623 6632 "http 1.4.2",
@@ -6628,7 +6637,7 @@
6628 6637
6629 6638 [[package]]
6630 6639 name = "quasi-basics"
6631 - version = "0.56.0"
6640 + version = "0.59.0"
6632 6641 dependencies = [
6633 6642 "makeover-layout",
6634 6643 "quasi-router",
@@ -6636,7 +6645,7 @@
6636 6645
6637 6646 [[package]]
6638 6647 name = "quasi-http"
6639 - version = "0.56.0"
6648 + version = "0.59.0"
6640 6649 dependencies = [
6641 6650 "form_urlencoded",
6642 6651 "http 1.4.2",
@@ -6645,7 +6654,7 @@
6645 6654
6646 6655 [[package]]
6647 6656 name = "quasi-router"
6648 - version = "0.56.0"
6657 + version = "0.59.0"
6649 6658 dependencies = [
6650 6659 "makeover-layout",
6651 6660 ]
@@ -6668,7 +6677,7 @@
6668 6677
6669 6678 [[package]]
6670 6679 name = "quasi-webview"
6671 - version = "0.56.0"
6680 + version = "0.59.0"
6672 6681 dependencies = [
6673 6682 "docengine",
6674 6683 "makeover-layout",
@@ -6744,7 +6753,7 @@
6744 6753 "once_cell",
6745 6754 "socket2",
6746 6755 "tracing",
6747 - "windows-sys 0.59.0",
6756 + "windows-sys 0.52.0",
6748 6757 ]
6749 6758
6750 6759 [[package]]
@@ -7307,7 +7316,7 @@
7307 7316 "errno",
7308 7317 "libc",
7309 7318 "linux-raw-sys 0.4.15",
7310 - "windows-sys 0.59.0",
7319 + "windows-sys 0.52.0",
7311 7320 ]
7312 7321
7313 7322 [[package]]
@@ -7320,7 +7329,7 @@
7320 7329 "errno",
7321 7330 "libc",
7322 7331 "linux-raw-sys 0.12.1",
7323 - "windows-sys 0.61.2",
7332 + "windows-sys 0.52.0",
7324 7333 ]
7325 7334
7326 7335 [[package]]
@@ -7400,7 +7409,7 @@
7400 7409 "security-framework 3.7.0",
7401 7410 "security-framework-sys",
7402 7411 "webpki-root-certs",
7403 - "windows-sys 0.61.2",
7412 + "windows-sys 0.52.0",
7404 7413 ]
7405 7414
7406 7415 [[package]]
@@ -8526,7 +8535,7 @@
8526 8535 "getrandom 0.4.3",
8527 8536 "once_cell",
8528 8537 "rustix 1.1.4",
8529 - "windows-sys 0.61.2",
8538 + "windows-sys 0.52.0",
8530 8539 ]
8531 8540
8532 8541 [[package]]
@@ -9952,7 +9961,7 @@
9952 9961 source = "registry+https://github.com/rust-lang/crates.io-index"
9953 9962 checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
9954 9963 dependencies = [
9955 - "windows-sys 0.61.2",
9964 + "windows-sys 0.48.0",
9956 9965 ]
9957 9966
9958 9967 [[package]]
@@ -10740,6 +10749,30 @@
10740 10749 "pkg-config",
10741 10750 ]
10742 10751
10752 + [[patch.unused]]
10753 + name = "quasi-immediate"
10754 + version = "0.59.0"
10755 +
10756 + [[patch.unused]]
10757 + name = "quasi-notifs"
10758 + version = "0.59.0"
10759 +
10760 + [[patch.unused]]
10761 + name = "quasi-store"
10762 + version = "0.1.0"
10763 +
10764 + [[patch.unused]]
10765 + name = "quasi-tauri"
10766 + version = "0.59.0"
10767 +
10768 + [[patch.unused]]
10769 + name = "synckit-client"
10770 + version = "0.9.1"
10771 +
10772 + [[patch.unused]]
10773 + name = "synckit-config"
10774 + version = "0.2.0"
10775 +
10743 10776 [[patch.unused]]
10744 10777 name = "kberg"
10745 10778 version = "0.1.0"
@@ -10751,27 +10784,3 @@
10751 10784 [[patch.unused]]
10752 10785 name = "painhours"
10753 10786 version = "0.1.0"
10754 -
10755 - [[patch.unused]]
10756 - name = "synckit-client"
10757 - version = "0.9.0"
10758 -
10759 - [[patch.unused]]
10760 - name = "synckit-config"
10761 - version = "0.2.0"
10762 -
10763 - [[patch.unused]]
10764 - name = "quasi-immediate"
10765 - version = "0.56.0"
10766 -
10767 - [[patch.unused]]
10768 - name = "quasi-notifs"
10769 - version = "0.56.0"
10770 -
10771 - [[patch.unused]]
10772 - name = "quasi-store"
10773 - version = "0.1.0"
10774 -
10775 - [[patch.unused]]
10776 - name = "quasi-tauri"
10777 - version = "0.56.0"
@@ -287,9 +287,10 @@
287 287 proptest = "1"
288 288 wiremock = "0.6"
289 289 pom-contract = { path = "../shared/pom-contract" }
290 - # The parity harness's normalizer. Already in the graph as ammonia's parser, so
291 - # this pins the same build rather than adding one; a dev-dependency because
292 - # nothing in the served binary parses HTML.
290 + # The parity harness's normalizer. Already in the graph as ammonia's parser --
291 + # reached through `custom-pages` since the sanitizers moved out -- so this pins
292 + # the same build rather than adding one; a dev-dependency because nothing in the
293 + # served binary parses HTML.
293 294 html5ever = "0.39"
294 295
295 296 [profile.release]
@@ -27,7 +27,14 @@
27 27 pub mod crypto;
28 28 pub mod csrf;
29 29 pub mod currency;
30 - pub mod custom_pages;
30 + /// Custom Pages sanitization, re-exported from the `custom-pages` crate.
31 + ///
32 + /// The module moved out of the server on 2026-08-25 (infra `3e6154ea`) so its
33 + /// fuzz targets stop needing a server build. The re-export keeps
34 + /// `crate::custom_pages::` meaning what it always did for the twenty-odd call
35 + /// sites and doc links that name it, so the extraction is a build-graph change
36 + /// and not a rename anyone has to chase.
37 + pub use custom_pages;
31 38 pub mod db;
32 39 pub mod email;
33 40 pub mod error;
@@ -26,8 +26,9 @@
26 26
27 27 /// What counts as "internal" for a given render context.
28 28 ///
29 - /// Built once from [`crate::config::Config`] and threaded through every
30 - /// sanitizer call. `base` is the origin the page renders on (the `u.` host);
29 + /// Built once by the consumer from its own configuration (the MNW server does
30 + /// this in `Config::custom_pages_policy`) and threaded through every sanitizer
31 + /// call. `base` is the origin the page renders on (the `u.` host);
31 32 /// `hosts` is the exact, case-insensitive allowlist of bare hostnames that
32 33 /// resolve to MNW.
33 34 #[derive(Debug, Clone)]
@@ -1,129 +1,0 @@
1 - //! Custom Pages sanitization.
2 - //!
3 - //! <!-- wiki: mnw-server-custom-pages -->
4 - //!
5 - //! Creators author raw HTML and CSS for their profile and project pages. This
6 - //! module turns that input into safe, closed-system page content: no scripting,
7 - //! no off-platform references, and CSS that cannot escape the user canvas to
8 - //! touch platform chrome.
9 - //!
10 - //! Three layers, one gate:
11 - //! - [`url_filter`], the single rule that every URL (HTML attribute or CSS
12 - //! `url()`) must resolve to MNW itself.
13 - //! - [`html_sanitizer`], an `ammonia` allowlist (structure, text, media; no
14 - //! script/embed/form/inline-style).
15 - //! - [`css_sanitizer`], a `lightningcss` pass that scopes all selectors to the
16 - //! canvas, filters at-rules, validates `url()`, and strips system-slot hiding.
17 - //!
18 - //! Sanitization is **render-time**, not write-time: the editor stores the
19 - //! creator's *raw* HTML/CSS, and `sanitize_page` runs on every render of the
20 - //! public page (on the cookieless, `default-src 'none'` host). The save path
21 - //! runs the sanitizer only to *count* what would be stripped, for the editor's
22 - //! blocked-references panel, it does not persist sanitized output. So the XSS
23 - //! boundary is the render call, not the database: never inline stored
24 - //! `custom_html`/`custom_css` anywhere without running them through this module
25 - //! first.
26 -
27 - mod css_sanitizer;
28 - mod html_sanitizer;
29 - mod url_filter;
30 -
31 - pub use css_sanitizer::{sanitize_css, sanitize_item_css};
32 - pub use html_sanitizer::sanitize_html;
33 - pub use url_filter::UrlPolicy;
34 -
35 - /// Why a single reference was stripped. Surfaced in the editor's
36 - /// blocked-references panel, the primary teaching surface for the
37 - /// closed-system rule.
38 - #[derive(Debug, Clone, PartialEq, Eq)]
39 - pub enum RejectionKind {
40 - /// URL resolved to an off-platform host.
41 - ExternalUrl,
42 - /// URL carried a non-https scheme (`data:`, `javascript:`, `mailto:`, ...).
43 - DisallowedScheme,
44 - /// URL could not be parsed.
45 - MalformedUrl,
46 - /// A CSS at-rule outside the allowlist (`@import`, `@namespace`, ...).
47 - BlockedAtRule,
48 - /// A dangerous CSS function (`expression()`).
49 - BlockedFunction,
50 - /// A property that would hide a non-removable system slot (`.mnw-*`).
51 - HidingProperty,
52 - /// A fast infinite animation (strobe guard).
53 - AnimationBudget,
54 - /// Stylesheet exceeded a complexity cap (DoS guard).
55 - ComplexityLimit,
56 - /// CSS that could not be parsed at all.
57 - MalformedCss,
58 - }
59 -
60 - /// One stripped reference, with enough context for the editor to point at it.
61 - #[derive(Debug, Clone, PartialEq, Eq)]
62 - pub struct Rejection {
63 - pub kind: RejectionKind,
64 - /// Human-readable origin, e.g. `"img src"`, `"css url()"`, `"@import"`.
65 - pub location: String,
66 - /// The value as the creator wrote it.
67 - pub original_value: String,
68 - /// One-line explanation shown to the creator.
69 - pub reason: String,
70 - }
71 -
72 - /// Maximum style rules in a sanitized sheet (quadratic-matching DoS guard).
73 - /// Far above any reasonable page.
74 - pub(crate) const MAX_RULES: usize = 5000;
75 - /// Maximum selectors across a sanitized sheet.
76 - pub(crate) const MAX_SELECTORS: usize = 10000;
77 -
78 - /// Sanitize a full custom page (HTML + CSS together).
79 - ///
80 - /// `owner_scope` is the id woven into the canvas selector
81 - /// `.user-canvas#uc-{owner_scope}` that all CSS is confined to, pass the
82 - /// owner's UUID. Returns sanitized HTML, sanitized CSS, and every reference the
83 - /// sanitizer stripped (deduplicated only by being appended in order).
84 - pub fn sanitize_page(
85 - html: &str,
86 - css: &str,
87 - owner_scope: &str,
88 - policy: &UrlPolicy,
89 - ) -> (String, String, Vec<Rejection>) {
90 - let (clean_html, mut rejections) = sanitize_html(html, policy);
91 - let (clean_css, css_rejections) = sanitize_css(css, owner_scope, policy);
92 - rejections.extend(css_rejections);
93 - (clean_html, clean_css, rejections)
94 - }
95 -
96 - #[cfg(test)]
97 - mod tests {
98 - use super::*;
99 -
100 - fn policy() -> UrlPolicy {
101 - UrlPolicy::new(
102 - "https://u.makenot.work/alice/proj",
103 - [
104 - "makenot.work".to_string(),
105 - "u.makenot.work".to_string(),
106 - "cdn.makenot.work".to_string(),
107 - ],
108 - )
109 - .unwrap()
110 - }
111 -
112 - #[test]
113 - fn page_sanitizes_both_and_collects_rejections() {
114 - let (html, css, rej) = sanitize_page(
115 - "<p>hi</p><script>evil()</script><img src=\"https://evil.com/x\">",
116 - "body { color: red } .x { background: url(https://evil.com/y) }",
117 - "11111111-1111-1111-1111-111111111111",
118 - &policy(),
119 - );
120 - assert!(html.contains("hi"));
121 - assert!(!html.contains("evil"));
122 - // CSS is scoped to the canvas.
123 - assert!(css.contains(".user-canvas#uc-11111111-1111-1111-1111-111111111111"));
124 - // body got neutralized into the canvas; off-platform url stripped.
125 - assert!(!css.contains("evil.com"));
126 - // At least the two external refs were recorded.
127 - assert!(rej.len() >= 2, "expected rejections, got {rej:?}");
128 - }
129 - }
@@ -1,0 +1,2 @@
1 + /target/
2 + .DS_Store
@@ -1,0 +1,1264 @@
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",
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 = "autocfg"
32 + version = "1.5.1"
33 + source = "registry+https://github.com/rust-lang/crates.io-index"
34 + checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
35 +
36 + [[package]]
37 + name = "bit-set"
38 + version = "0.8.0"
39 + source = "registry+https://github.com/rust-lang/crates.io-index"
40 + checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
41 + dependencies = [
42 + "bit-vec",
43 + ]
44 +
45 + [[package]]
46 + name = "bit-vec"
47 + version = "0.8.0"
48 + source = "registry+https://github.com/rust-lang/crates.io-index"
49 + checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
50 +
51 + [[package]]
52 + name = "bitflags"
53 + version = "2.13.1"
54 + source = "registry+https://github.com/rust-lang/crates.io-index"
55 + checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
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 + "proptest",
142 + "tracing",
143 + "url",
144 + ]
145 +
146 + [[package]]
147 + name = "data-encoding"
148 + version = "2.11.1"
149 + source = "registry+https://github.com/rust-lang/crates.io-index"
150 + checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
151 +
152 + [[package]]
153 + name = "displaydoc"
154 + version = "0.2.7"
155 + source = "registry+https://github.com/rust-lang/crates.io-index"
156 + checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
157 + dependencies = [
158 + "proc-macro2",
159 + "quote",
160 + "syn 3.0.4",
161 + ]
162 +
163 + [[package]]
164 + name = "dtoa"
165 + version = "1.0.11"
166 + source = "registry+https://github.com/rust-lang/crates.io-index"
167 + checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
168 +
169 + [[package]]
170 + name = "dtoa-short"
171 + version = "0.3.5"
172 + source = "registry+https://github.com/rust-lang/crates.io-index"
173 + checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
174 + dependencies = [
175 + "dtoa",
176 + ]
177 +
178 + [[package]]
179 + name = "either"
180 + version = "1.18.0"
181 + source = "registry+https://github.com/rust-lang/crates.io-index"
182 + checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
183 +
184 + [[package]]
185 + name = "equivalent"
186 + version = "1.0.2"
187 + source = "registry+https://github.com/rust-lang/crates.io-index"
188 + checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
189 +
190 + [[package]]
191 + name = "errno"
192 + version = "0.3.14"
193 + source = "registry+https://github.com/rust-lang/crates.io-index"
194 + checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
195 + dependencies = [
196 + "libc",
197 + "windows-sys",
198 + ]
199 +
200 + [[package]]
201 + name = "fastrand"
202 + version = "2.5.0"
203 + source = "registry+https://github.com/rust-lang/crates.io-index"
204 + checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
205 +
206 + [[package]]
207 + name = "fnv"
208 + version = "1.0.7"
209 + source = "registry+https://github.com/rust-lang/crates.io-index"
210 + checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
211 +
212 + [[package]]
213 + name = "form_urlencoded"
214 + version = "1.2.2"
215 + source = "registry+https://github.com/rust-lang/crates.io-index"
216 + checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
217 + dependencies = [
218 + "percent-encoding",
219 + ]
220 +
221 + [[package]]
222 + name = "getrandom"
223 + version = "0.3.4"
224 + source = "registry+https://github.com/rust-lang/crates.io-index"
225 + checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
226 + dependencies = [
227 + "cfg-if",
228 + "libc",
229 + "r-efi",
230 + "wasip2",
231 + ]
232 +
233 + [[package]]
234 + name = "hashbrown"
235 + version = "0.17.1"
236 + source = "registry+https://github.com/rust-lang/crates.io-index"
237 + checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
238 +
239 + [[package]]
240 + name = "html5ever"
241 + version = "0.39.0"
242 + source = "registry+https://github.com/rust-lang/crates.io-index"
243 + checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
244 + dependencies = [
245 + "log",
246 + "markup5ever",
247 + ]
248 +
249 + [[package]]
250 + name = "icu_collections"
251 + version = "2.3.0"
252 + source = "registry+https://github.com/rust-lang/crates.io-index"
253 + checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
254 + dependencies = [
255 + "displaydoc",
256 + "potential_utf",
257 + "utf8_iter",
258 + "yoke",
259 + "zerofrom",
260 + "zerovec",
261 + ]
262 +
263 + [[package]]
264 + name = "icu_locale_core"
265 + version = "2.3.0"
266 + source = "registry+https://github.com/rust-lang/crates.io-index"
267 + checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
268 + dependencies = [
269 + "displaydoc",
270 + "litemap",
271 + "tinystr",
272 + "writeable",
273 + "zerovec",
274 + ]
275 +
276 + [[package]]
277 + name = "icu_normalizer"
278 + version = "2.3.0"
279 + source = "registry+https://github.com/rust-lang/crates.io-index"
280 + checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
281 + dependencies = [
282 + "icu_collections",
283 + "icu_normalizer_data",
284 + "icu_properties",
285 + "icu_provider",
286 + "smallvec",
287 + "zerovec",
288 + ]
289 +
290 + [[package]]
291 + name = "icu_normalizer_data"
292 + version = "2.3.0"
293 + source = "registry+https://github.com/rust-lang/crates.io-index"
294 + checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
295 +
296 + [[package]]
297 + name = "icu_properties"
298 + version = "2.3.0"
299 + source = "registry+https://github.com/rust-lang/crates.io-index"
300 + checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
301 + dependencies = [
302 + "displaydoc",
303 + "icu_collections",
304 + "icu_locale_core",
305 + "icu_properties_data",
306 + "icu_provider",
307 + "zerotrie",
308 + "zerovec",
309 + ]
310 +
311 + [[package]]
312 + name = "icu_properties_data"
313 + version = "2.3.0"
314 + source = "registry+https://github.com/rust-lang/crates.io-index"
315 + checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
316 +
317 + [[package]]
318 + name = "icu_provider"
319 + version = "2.3.1"
320 + source = "registry+https://github.com/rust-lang/crates.io-index"
321 + checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
322 + dependencies = [
323 + "displaydoc",
324 + "icu_locale_core",
325 + "writeable",
326 + "yoke",
327 + "zerofrom",
328 + "zerotrie",
329 + "zerovec",
330 + ]
331 +
332 + [[package]]
333 + name = "idna"
334 + version = "1.1.0"
335 + source = "registry+https://github.com/rust-lang/crates.io-index"
336 + checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
337 + dependencies = [
338 + "idna_adapter",
339 + "smallvec",
340 + "utf8_iter",
341 + ]
342 +
343 + [[package]]
344 + name = "idna_adapter"
345 + version = "1.2.2"
346 + source = "registry+https://github.com/rust-lang/crates.io-index"
347 + checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
348 + dependencies = [
349 + "icu_normalizer",
350 + "icu_properties",
351 + ]
352 +
353 + [[package]]
354 + name = "indexmap"
355 + version = "2.14.0"
356 + source = "registry+https://github.com/rust-lang/crates.io-index"
357 + checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
358 + dependencies = [
359 + "equivalent",
360 + "hashbrown",
361 + "serde",
362 + "serde_core",
363 + ]
364 +
365 + [[package]]
366 + name = "itertools"
367 + version = "0.10.5"
368 + source = "registry+https://github.com/rust-lang/crates.io-index"
369 + checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
370 + dependencies = [
371 + "either",
372 + ]
373 +
374 + [[package]]
375 + name = "itoa"
376 + version = "1.0.18"
377 + source = "registry+https://github.com/rust-lang/crates.io-index"
378 + checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
379 +
380 + [[package]]
381 + name = "lazy_static"
382 + version = "1.5.0"
383 + source = "registry+https://github.com/rust-lang/crates.io-index"
384 + checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
385 +
386 + [[package]]
387 + name = "libc"
388 + version = "0.2.189"
389 + source = "registry+https://github.com/rust-lang/crates.io-index"
390 + checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
391 +
392 + [[package]]
393 + name = "lightningcss"
394 + version = "1.0.0-alpha.71"
395 + source = "registry+https://github.com/rust-lang/crates.io-index"
396 + checksum = "cb6314c2f0590ac93c86099b98bb7ba8abcf759bfd89604ffca906472bb54937"
397 + dependencies = [
398 + "ahash",
399 + "bitflags",
400 + "const-str",
401 + "cssparser 0.33.0",
402 + "cssparser-color",
403 + "data-encoding",
404 + "getrandom",
405 + "indexmap",
406 + "itertools",
407 + "lazy_static",
408 + "lightningcss-derive",
409 + "parcel_selectors",
410 + "pastey",
411 + "pathdiff",
412 + "smallvec",
413 + ]
414 +
415 + [[package]]
416 + name = "lightningcss-derive"
417 + version = "1.0.0-alpha.43"
418 + source = "registry+https://github.com/rust-lang/crates.io-index"
419 + checksum = "84c12744d1279367caed41739ef094c325d53fb0ffcd4f9b84a368796f870252"
420 + dependencies = [
421 + "convert_case",
422 + "proc-macro2",
423 + "quote",
424 + "syn 1.0.109",
425 + ]
426 +
427 + [[package]]
428 + name = "linux-raw-sys"
429 + version = "0.12.1"
430 + source = "registry+https://github.com/rust-lang/crates.io-index"
431 + checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
432 +
433 + [[package]]
434 + name = "litemap"
435 + version = "0.8.3"
436 + source = "registry+https://github.com/rust-lang/crates.io-index"
437 + checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
438 +
439 + [[package]]
440 + name = "lock_api"
441 + version = "0.4.14"
442 + source = "registry+https://github.com/rust-lang/crates.io-index"
443 + checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
444 + dependencies = [
445 + "scopeguard",
446 + ]
447 +
448 + [[package]]
449 + name = "log"
450 + version = "0.4.34"
451 + source = "registry+https://github.com/rust-lang/crates.io-index"
452 + checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
453 +
454 + [[package]]
455 + name = "maplit"
456 + version = "1.0.2"
457 + source = "registry+https://github.com/rust-lang/crates.io-index"
458 + checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
459 +
460 + [[package]]
461 + name = "markup5ever"
462 + version = "0.39.0"
463 + source = "registry+https://github.com/rust-lang/crates.io-index"
464 + checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
465 + dependencies = [
466 + "log",
467 + "tendril",
468 + "web_atoms",
469 + ]
470 +
471 + [[package]]
472 + name = "new_debug_unreachable"
473 + version = "1.0.6"
474 + source = "registry+https://github.com/rust-lang/crates.io-index"
475 + checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
476 +
477 + [[package]]
478 + name = "num-traits"
479 + version = "0.2.19"
480 + source = "registry+https://github.com/rust-lang/crates.io-index"
481 + checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
482 + dependencies = [
483 + "autocfg",
484 + ]
485 +
486 + [[package]]
487 + name = "once_cell"
488 + version = "1.21.4"
489 + source = "registry+https://github.com/rust-lang/crates.io-index"
490 + checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
491 +
492 + [[package]]
493 + name = "parcel_selectors"
494 + version = "0.28.2"
495 + source = "registry+https://github.com/rust-lang/crates.io-index"
496 + checksum = "54fd03f1ad26cb6b3ec1b7414fa78a3bd639e7dbb421b1a60513c96ce886a196"
497 + dependencies = [
498 + "bitflags",
499 + "cssparser 0.33.0",
500 + "log",
Lines truncated
@@ -1,0 +1,66 @@
1 + [package]
2 + name = "custom-pages"
3 + version = "0.1.0"
4 + edition = "2024"
5 + # MIT rather than PolyForm. The threat model asks whether someone could collect
6 + # rent with just this crate and contribute nothing; an HTML/CSS sanitizer is not
7 + # a service anyone can run, and someone who takes it still has to build a
8 + # product around it. The perimeter is the MNW server, which stays PolyForm.
9 + license = "MIT"
10 + # Internal shared crate, consumed by path inside this repo and never published.
11 + # `publish = false` is what makes that a cargo-enforced fact rather than a
12 + # convention: without it an accidental `cargo publish` is one command away.
13 + publish = false
14 +
15 + [dependencies]
16 + # The HTML allowlist. Version-matched to the server, which is the only consumer;
17 + # they resolve to one copy through the path dep either way.
18 + ammonia = "4"
19 + # The CSS parser/printer the scoping pass is built on. `default-features = false`
20 + # keeps the bundler, source maps and the JS/Node glue out: this crate parses a
21 + # stylesheet and prints it back, and nothing else.
22 + # Pinned exactly, not floated. This is a pre-1.0 alpha whose `CssRule` enum is
23 + # matched exhaustively here on purpose (a new at-rule must be a compile error
24 + # and then a deliberate allow-or-block decision, never a silent pass-through),
25 + # so a patch bump is a breaking change for this crate. alpha.72 adds
26 + # `PositionTry` and does exactly that.
27 + lightningcss = { version = "=1.0.0-alpha.71", default-features = false, features = ["visitor"] }
28 + url = "2.5.8"
29 + # Two warn-level lines in the CSS pass, where a cap is hit. The crate emits no
30 + # other diagnostics and takes no subscriber: the consumer owns that.
31 + tracing = "0.1"
32 +
33 + [dev-dependencies]
34 + proptest = "1"
35 +
36 + [lints.rust]
37 + unused = "warn"
38 + unreachable_pub = "warn"
39 +
40 + [lints.clippy]
41 + pedantic = { level = "warn", priority = -1 }
42 + # Allow-list tuned from a measured breakdown across server/multithreaded/pter
43 + # (2026-07-22). These are the high-churn / low-signal pedantic lints; everything
44 + # else in `pedantic` stays a warning. Keep this block identical across repos.
45 + module_name_repetitions = "allow"
46 + # Doc lints. No docs-completeness push is underway.
47 + missing_errors_doc = "allow"
48 + missing_panics_doc = "allow"
49 + doc_markdown = "allow"
50 + # Numeric casts. Endemic and mostly intentional in size and byte math.
51 + cast_possible_truncation = "allow"
52 + cast_sign_loss = "allow"
53 + cast_precision_loss = "allow"
54 + cast_possible_wrap = "allow"
55 + cast_lossless = "allow"
56 + # Subjective structure and style nags. High churn, low signal.
57 + must_use_candidate = "allow"
58 + too_many_lines = "allow"
59 + struct_excessive_bools = "allow"
60 + similar_names = "allow"
61 + items_after_statements = "allow"
62 + single_match_else = "allow"
63 + # Frequent false-positives in TUI and router-heavy code.
64 + match_same_arms = "allow"
65 + unnecessary_wraps = "allow"
66 + type_complexity = "allow"
@@ -1,0 +1,52 @@
1 + # custom-pages
2 +
3 + HTML and CSS sanitization for MNW Custom Pages: creator-authored page content
4 + turned into something safe to render.
5 +
6 + Creators write raw HTML and CSS for their profile and project pages. Nothing
7 + they write is trusted, and nothing sanitized here is stored — sanitization is
8 + render-time, over the raw input, on every render. The database holds what the
9 + creator typed; this crate is the boundary between that and a rendered page.
10 +
11 + It lives outside the MNW server for one reason: a fuzz target that has to build
12 + the whole server is a fuzz target nobody runs. The crate imports nothing from
13 + MNW. A caller supplies a `UrlPolicy` and an owner scope, and gets back sanitized
14 + output plus every reference that was stripped.
15 +
16 + ## What it promises
17 +
18 + **No execution.** Nothing that survives either path can run: no `<script>`, no
19 + event-handler attribute, no `javascript:`, no `expression()`, no `@import`.
20 +
21 + **A closed system.** No URL survives that resolves off-platform. Every reference
22 + — an HTML attribute, a `srcset` candidate, a CSS `url()` — must resolve to a
23 + host the policy names. References are stripped, never rewritten, so a creator's
24 + own URLs are left as they wrote them.
25 +
26 + **A scoped canvas.** Every CSS rule that survives is confined to
27 + `.user-canvas#uc-{owner}`. A stylesheet cannot reach platform chrome, and it
28 + cannot hide a non-removable system slot.
29 +
30 + Both of the first two, plus scoping, are asserted by `oracle`, which is a
31 + public module rather than a fuzzing-only one: `tests/regressions.rs` runs it on
32 + stable and the soak tier's target runs it on nightly, so neither side can drift
33 + into checking less than the other.
34 +
35 + ## Corpora
36 +
37 + `fuzz/seeds/{html,css}/` is human intent — the shapes worth reaching first.
38 + `fuzz/regressions/` is inputs that once found a bug; anything landing there is a
39 + permanent test by virtue of the directory. Both replay on every `cargo test`.
40 +
41 + ## The lightningcss pin
42 +
43 + `lightningcss` is pinned exactly, not floated. It is a pre-1.0 alpha whose
44 + `CssRule` enum is matched exhaustively here on purpose: a new at-rule must be a
45 + compile error and then a deliberate allow-or-block decision, never a silent
46 + pass-through. alpha.72 adds `PositionTry` and does exactly that.
47 +
48 + The committed lockfile also pins `parcel_selectors` to 0.28.2. lightningcss
49 + alpha.71 accepts `^0.28`, but 0.28.3 moved to a `cssparser` major that
50 + lightningcss itself does not use, so a fresh resolve puts two incompatible
51 + `cssparser` versions in one graph and lightningcss stops compiling. That is
52 + upstream's problem; the lockfile is the fix.
@@ -1,0 +1,10 @@
1 + # Regressions
2 +
3 + One file per input that once found a bug. `tests/regressions.rs` replays every
4 + file here through both oracles on every `cargo test`, so a fix that comes undone
5 + fails immediately and locally.
6 +
7 + Nothing here is ever deleted. Name a file for the bug it found, not for its
8 + contents.
9 +
10 + Empty is the good state.
@@ -1,0 +1,13 @@
1 + # Seed corpora
2 +
3 + Human intent: the shapes a fuzzer should reach on its first pass rather than
4 + rediscover. `html/` feeds `oracle::check_html`, `css/` feeds
5 + `oracle::check_css`.
6 +
7 + Every one of these is replayed by `tests/regressions.rs` as an ordinary
8 + `cargo test`, so a seed that starts violating the contract fails on a
9 + developer's machine and not only on the soak box.
10 +
11 + Add a seed when a shape is worth reaching and none of the existing files gets
12 + there. Add to `../regressions/` instead when an input once found a bug: that
13 + directory is the permanent record, and nothing there should ever be deleted.