max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
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""> | |
| 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 `javascript:` 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 | + | /// `"` 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 |