| 198 |
198 |
|
//! a tag or attribute added to the allowlist and never added to
|
| 199 |
199 |
|
//! `url_attribute`, which would carry an ungated URL to a reader.
|
| 200 |
200 |
|
|
|
201 |
+ |
use std::convert::Infallible;
|
|
202 |
+ |
|
|
203 |
+ |
use lightningcss::properties::custom::Function;
|
|
204 |
+ |
use lightningcss::rules::{CssRule, CssRuleList};
|
|
205 |
+ |
use lightningcss::selector::{Component, Selector, SelectorList};
|
|
206 |
+ |
use lightningcss::stylesheet::StyleSheet;
|
|
207 |
+ |
use lightningcss::values::url::Url as CssUrl;
|
|
208 |
+ |
use lightningcss::visit_types;
|
|
209 |
+ |
use lightningcss::visitor::{Visit, VisitTypes, Visitor};
|
|
210 |
+ |
|
| 201 |
211 |
|
use super::url_filter::resolve_internal_url;
|
| 202 |
212 |
|
use super::{UrlPolicy, sanitize_css, sanitize_html};
|
| 203 |
213 |
|
|
| 241 |
251 |
|
"usemap",
|
| 242 |
252 |
|
];
|
| 243 |
253 |
|
|
| 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] = &[
|
| 252 |
|
- |
"javascript:",
|
| 253 |
|
- |
"vbscript:",
|
| 254 |
|
- |
"expression(",
|
| 255 |
|
- |
"@import",
|
| 256 |
|
- |
"</style",
|
| 257 |
|
- |
"data:text/html",
|
| 258 |
|
- |
];
|
| 259 |
|
- |
|
| 260 |
254 |
|
/// Panic if sanitized HTML violates the floor or the closed system.
|
| 261 |
255 |
|
///
|
| 262 |
256 |
|
/// # Panics
|
| 338 |
332 |
|
|
| 339 |
333 |
|
/// Panic if sanitized CSS violates the floor, the closed system, or scoping.
|
| 340 |
334 |
|
///
|
|
335 |
+ |
/// Covers BOTH scoping entry points. The doors were counted for infra
|
|
336 |
+ |
/// `bd562c12`: nine call sites, all in MNW server, but two entry points --
|
|
337 |
+ |
/// [`sanitize_css`] scopes to `.user-canvas#uc-{owner}` and
|
|
338 |
+ |
/// [`super::sanitize_item_css`] scopes a project's CSS to
|
|
339 |
+ |
/// `.item-canvas#ic-{project}` for its item pages, which have no HTML of
|
|
340 |
+ |
/// their own and wear the parent's styling. Only the first was asserted
|
|
341 |
+ |
/// before, so the item canvas was a live render path with no oracle over
|
|
342 |
+ |
/// it.
|
|
343 |
+ |
///
|
| 341 |
344 |
|
/// # Panics
|
| 342 |
345 |
|
///
|
| 343 |
346 |
|
/// By design.
|
| 344 |
347 |
|
pub fn check_css(input: &str, owner_scope: &str, policy: &UrlPolicy) {
|
| 345 |
348 |
|
let (clean, _rejections) = sanitize_css(input, owner_scope, policy);
|
| 346 |
|
- |
assert_css_safe(&clean, "css", input);
|
|
349 |
+ |
assert_css_safe(
|
|
350 |
+ |
&clean,
|
|
351 |
+ |
"css",
|
|
352 |
+ |
input,
|
|
353 |
+ |
policy,
|
|
354 |
+ |
"user-canvas",
|
|
355 |
+ |
&format!("uc-{owner_scope}"),
|
|
356 |
+ |
);
|
| 347 |
357 |
|
|
| 348 |
|
- |
// Everything that survives is confined to the owner's canvas. A rule
|
| 349 |
|
- |
// that escapes it can restyle platform chrome, which is the CSS half of
|
| 350 |
|
- |
// what this crate exists to prevent.
|
| 351 |
|
- |
let canvas = format!(".user-canvas#uc-{owner_scope}");
|
| 352 |
|
- |
for rule in clean.split('}') {
|
| 353 |
|
- |
let selector = rule.split('{').next().unwrap_or_default().trim();
|
| 354 |
|
- |
if selector.is_empty() || selector.starts_with('@') {
|
| 355 |
|
- |
continue;
|
| 356 |
|
- |
}
|
| 357 |
|
- |
assert!(
|
| 358 |
|
- |
selector.contains(&canvas),
|
| 359 |
|
- |
"a rule escaped the canvas for {input:?}: {selector:?}"
|
| 360 |
|
- |
);
|
| 361 |
|
- |
}
|
|
358 |
+ |
let (item, _) = super::sanitize_item_css(input, owner_scope, policy);
|
|
359 |
+ |
assert_css_safe(
|
|
360 |
+ |
&item,
|
|
361 |
+ |
"item css",
|
|
362 |
+ |
input,
|
|
363 |
+ |
policy,
|
|
364 |
+ |
"item-canvas",
|
|
365 |
+ |
&format!("ic-{owner_scope}"),
|
|
366 |
+ |
);
|
| 362 |
367 |
|
|
| 363 |
368 |
|
// Deliberately NOT a fixed-point assertion, unlike the HTML side.
|
| 364 |
369 |
|
// Scoping is a transform, not a filter: running it again legitimately
|
| 372 |
377 |
|
// where the printer can construct what the parser rejected, which is
|
| 373 |
378 |
|
// the CSS analogue of the mutation-XSS class.
|
| 374 |
379 |
|
let (again, _) = sanitize_css(&clean, owner_scope, policy);
|
| 375 |
|
- |
assert_css_safe(&again, "css (second pass)", input);
|
|
380 |
+ |
assert_css_safe(
|
|
381 |
+ |
&again,
|
|
382 |
+ |
"css (second pass)",
|
|
383 |
+ |
input,
|
|
384 |
+ |
policy,
|
|
385 |
+ |
"user-canvas",
|
|
386 |
+ |
&format!("uc-{owner_scope}"),
|
|
387 |
+ |
);
|
|
388 |
+ |
}
|
|
389 |
+ |
|
|
390 |
+ |
/// The floor, the closed system and scoping, over one sanitized stylesheet.
|
|
391 |
+ |
///
|
|
392 |
+ |
/// **Parses the output and walks the AST; it does not scan the text.**
|
|
393 |
+ |
/// Rewritten 2026-08-25 (infra `bd562c12`) because the scan it replaces had
|
|
394 |
+ |
/// four false positives, every one of them the sanitizer working, and the
|
|
395 |
+ |
/// first was not exotic at all:
|
|
396 |
+ |
///
|
|
397 |
+ |
/// | input | what the scan did |
|
|
398 |
+ |
/// |---|---|
|
|
399 |
+ |
/// | `@keyframes spin{0%{opacity:0}100%{opacity:1}}` | read the step `100%` as a selector that escaped the canvas |
|
|
400 |
+ |
/// | `.a{content:"}"}` | split the sheet on a brace inside a string |
|
|
401 |
+ |
/// | `.a{content:"@import"}` | matched `@import` inside a string literal |
|
|
402 |
+ |
/// | `.a{content:"javascript:"}` | matched `javascript:` inside a string literal |
|
|
403 |
+ |
///
|
|
404 |
+ |
/// A multi-step animation is ordinary creator CSS, so that check would have
|
|
405 |
+ |
/// reported a security finding within seconds of the target starting. The
|
|
406 |
+ |
/// scan could not tell structure from content, which is the CSS form of the
|
|
407 |
+ |
/// mistake the HTML oracle made with text nodes (infra `6f21a29a`) and
|
|
408 |
+ |
/// docengine's made twice before that (infra `15991c40`).
|
|
409 |
+ |
///
|
|
410 |
+ |
/// Walking the AST also makes the assertions say what they mean: `@import`
|
|
411 |
+ |
/// is forbidden as a RULE rather than as a string, a URL is checked by the
|
|
412 |
+ |
/// gate rather than by hostname, and the global-by-design rules
|
|
413 |
+ |
/// (`@keyframes`, `@font-face`, `@page`, `@layer` statements) are exempt
|
|
414 |
+ |
/// from the canvas requirement because the sanitizer deliberately leaves
|
|
415 |
+ |
/// them top-level.
|
|
416 |
+ |
///
|
|
417 |
+ |
/// # Panics
|
|
418 |
+ |
/// If the output does not parse, carries a forbidden at-rule or function,
|
|
419 |
+ |
/// points a `url()` off-platform, or lets a style rule escape the canvas.
|
|
420 |
+ |
fn assert_css_safe(
|
|
421 |
+ |
clean: &str,
|
|
422 |
+ |
what: &str,
|
|
423 |
+ |
input: &str,
|
|
424 |
+ |
policy: &UrlPolicy,
|
|
425 |
+ |
canvas_class: &str,
|
|
426 |
+ |
canvas_id: &str,
|
|
427 |
+ |
) {
|
|
428 |
+ |
if clean.trim().is_empty() {
|
|
429 |
+ |
return;
|
|
430 |
+ |
}
|
|
431 |
+ |
|
|
432 |
+ |
// THE SAME PARSER OPTIONS THE SANITIZER USES, which is the point rather
|
|
433 |
+ |
// than tidiness. Reparsing with `ParserOptions::default()` is a
|
|
434 |
+ |
// stricter parse than the one that produced this text -- the sanitizer
|
|
435 |
+ |
// sets `error_recovery: true` so one malformed rule cannot discard a
|
|
436 |
+ |
// creator's whole sheet, and lightningcss then prints the malformed
|
|
437 |
+ |
// prelude back out verbatim. A default parse rejects it, and the oracle
|
|
438 |
+ |
// reports a difference between two parser configurations as though the
|
|
439 |
+ |
// sanitizer had emitted garbage. Found in the css target's first
|
|
440 |
+ |
// session on `@media (m=in-width: 600px) { p { color* blue } }`, whose
|
|
441 |
+ |
// output is exactly what error recovery is for.
|
|
442 |
+ |
let Ok(mut sheet) = StyleSheet::parse(clean, super::css_sanitizer::parser_options()) else {
|
|
443 |
+ |
panic!("{what} output does not reparse for {input:?}: {clean:?}");
|
|
444 |
+ |
};
|
|
445 |
+ |
|
|
446 |
+ |
check_rules(&sheet.rules, what, input, clean, canvas_class, canvas_id);
|
|
447 |
+ |
|
|
448 |
+ |
let mut floor = CssFloor {
|
|
449 |
+ |
policy,
|
|
450 |
+ |
what,
|
|
451 |
+ |
input,
|
|
452 |
+ |
clean,
|
|
453 |
+ |
};
|
|
454 |
+ |
// The visitor never returns Err; it panics instead, which is how an
|
|
455 |
+ |
// oracle reports.
|
|
456 |
+ |
let _: Result<(), Infallible> = sheet.visit(&mut floor);
|
|
457 |
+ |
}
|
|
458 |
+ |
|
|
459 |
+ |
/// At-rules no sanitized stylesheet may contain, named as the sanitizer
|
|
460 |
+ |
/// names them so a finding and the code that produced it use one vocabulary.
|
|
461 |
+ |
fn blocked_at_rule(rule: &CssRule<'_>) -> Option<&'static str> {
|
|
462 |
+ |
match rule {
|
|
463 |
+ |
CssRule::Import(_) => Some("@import"),
|
|
464 |
+ |
CssRule::Namespace(_) => Some("@namespace"),
|
|
465 |
+ |
CssRule::MozDocument(_) => Some("@-moz-document"),
|
|
466 |
+ |
CssRule::CustomMedia(_) => Some("@custom-media"),
|
|
467 |
+ |
CssRule::Property(_) => Some("@property"),
|
|
468 |
+ |
CssRule::Viewport(_) => Some("@viewport"),
|
|
469 |
+ |
CssRule::CounterStyle(_) => Some("@counter-style"),
|
|
470 |
+ |
CssRule::FontPaletteValues(_) => Some("@font-palette-values"),
|
|
471 |
+ |
CssRule::FontFeatureValues(_) => Some("@font-feature-values"),
|
|
472 |
+ |
CssRule::Container(_) => Some("@container"),
|
|
473 |
+ |
CssRule::Scope(_) => Some("@scope"),
|
|
474 |
+ |
CssRule::StartingStyle(_) => Some("@starting-style"),
|
|
475 |
+ |
CssRule::ViewTransition(_) => Some("@view-transition"),
|
|
476 |
+ |
CssRule::Unknown(_) => Some("an unknown at-rule"),
|
|
477 |
+ |
_ => None,
|
|
478 |
+ |
}
|
|
479 |
+ |
}
|
|
480 |
+ |
|
|
481 |
+ |
/// Walk the rule tree asserting the at-rule allowlist and canvas scoping.
|
|
482 |
+ |
fn check_rules(
|
|
483 |
+ |
rules: &CssRuleList<'_>,
|
|
484 |
+ |
what: &str,
|
|
485 |
+ |
input: &str,
|
|
486 |
+ |
clean: &str,
|
|
487 |
+ |
canvas_class: &str,
|
|
488 |
+ |
canvas_id: &str,
|
|
489 |
+ |
) {
|
|
490 |
+ |
for rule in &rules.0 {
|
|
491 |
+ |
if let Some(name) = blocked_at_rule(rule) {
|
|
492 |
+ |
panic!("{what} output kept {name} for {input:?}: {clean:?}");
|
|
493 |
+ |
}
|
|
494 |
+ |
match rule {
|
|
495 |
+ |
// Global by design. The sanitizer partitions these out of the
|
|
496 |
+ |
// scoping wrapper on purpose -- they carry no document
|
|
497 |
+ |
// selectors and cannot legally nest inside a style rule -- so
|
|
498 |
+ |
// requiring the canvas of them would be requiring the sanitizer
|
|
499 |
+ |
// to be wrong. Their `url()`s are still checked, by the visitor.
|
|
500 |
+ |
CssRule::Keyframes(_)
|
|
501 |
+ |
| CssRule::FontFace(_)
|
|
502 |
+ |
| CssRule::Page(_)
|
|
503 |
+ |
| CssRule::LayerStatement(_)
|
|
504 |
+ |
| CssRule::Ignored => {}
|
|
505 |
+ |
|
|
506 |
+ |
CssRule::Style(style) => {
|
|
507 |
+ |
assert_scoped(
|
|
508 |
+ |
&style.selectors,
|
|
509 |
+ |
what,
|
|
510 |
+ |
input,
|
|
511 |
+ |
clean,
|
|
512 |
+ |
canvas_class,
|
|
513 |
+ |
canvas_id,
|
|
514 |
+ |
);
|
|
515 |
+ |
check_rules(&style.rules, what, input, clean, canvas_class, canvas_id);
|
|
516 |
+ |
}
|
|
517 |
+ |
|
|
518 |
+ |
CssRule::Media(r) => {
|
|
519 |
+ |
check_rules(&r.rules, what, input, clean, canvas_class, canvas_id);
|
|
520 |
+ |
}
|
|
521 |
+ |
CssRule::Supports(r) => {
|
|
522 |
+ |
check_rules(&r.rules, what, input, clean, canvas_class, canvas_id);
|
|
523 |
+ |
}
|
|
524 |
+ |
CssRule::LayerBlock(r) => {
|
|
525 |
+ |
check_rules(&r.rules, what, input, clean, canvas_class, canvas_id);
|
|
526 |
+ |
}
|
|
527 |
+ |
CssRule::Nesting(r) => assert_scoped(
|
|
528 |
+ |
&r.style.selectors,
|
|
529 |
+ |
what,
|
|
530 |
+ |
input,
|
|
531 |
+ |
clean,
|
|
532 |
+ |
canvas_class,
|
|
533 |
+ |
canvas_id,
|
|
534 |
+ |
),
|
|
535 |
+ |
_ => {}
|
|
536 |
+ |
}
|
|
537 |
+ |
}
|
|
538 |
+ |
}
|
|
539 |
+ |
|
|
540 |
+ |
/// Every selector in the list must be confined to the canvas.
|
|
541 |
+ |
///
|
|
542 |
+ |
/// **Structural, because printing a selector is lossy.** This began as
|
|
543 |
+ |
/// `selectors.to_css_string(..).contains(".user-canvas#uc-{id}")` and the
|
|
544 |
+ |
/// css target's first session found the flaw in seven minutes: lightningcss
|
|
545 |
+ |
/// printed a selector list containing `:is(.user-canvas#uc-... .X)` as the
|
|
546 |
+ |
/// bare string `:is()`, dropping the contents, so a rule the sanitizer had
|
|
547 |
+ |
/// correctly scoped INSIDE an `:is()` -- which is how its nesting flattener
|
|
548 |
+ |
/// scopes things -- read as a rule that had escaped. Walking components
|
|
549 |
+ |
/// asks the question of the selector rather than of its rendering.
|
|
550 |
+ |
///
|
|
551 |
+ |
/// `:not()` is deliberately not descended into. The canvas appearing inside
|
|
552 |
+ |
/// a negation is the opposite of scoping, and counting it would accept the
|
|
553 |
+ |
/// one selector shape that means "everything except the canvas".
|
|
554 |
+ |
fn assert_scoped(
|
|
555 |
+ |
list: &SelectorList<'_>,
|
|
556 |
+ |
what: &str,
|
|
557 |
+ |
input: &str,
|
|
558 |
+ |
clean: &str,
|
|
559 |
+ |
canvas_class: &str,
|
|
560 |
+ |
canvas_id: &str,
|
|
561 |
+ |
) {
|
|
562 |
+ |
for selector in &list.0 {
|
|
563 |
+ |
assert!(
|
|
564 |
+ |
selector_is_scoped(selector, canvas_class, canvas_id),
|
|
565 |
+ |
"{what}: a rule escaped .{canvas_class}#{canvas_id} for {input:?}: {clean:?}"
|
|
566 |
+ |
);
|
|
567 |
+ |
}
|
|
568 |
+ |
}
|
|
569 |
+ |
|
|
570 |
+ |
/// Does this selector carry both halves of the canvas compound somewhere
|
|
571 |
+ |
/// that constrains what it matches?
|
|
572 |
+ |
fn selector_is_scoped(selector: &Selector<'_>, canvas_class: &str, canvas_id: &str) -> bool {
|
|
573 |
+ |
let mut has_class = false;
|
|
574 |
+ |
let mut has_id = false;
|
|
575 |
+ |
for component in selector.iter_raw_match_order() {
|
|
576 |
+ |
match component {
|
|
577 |
+ |
Component::Class(ident) if ident.0.as_ref() == canvas_class => has_class = true,
|
|
578 |
+ |
// PREFIX, not equality, and the difference is a documented
|
|
579 |
+ |
// relaxation rather than sloppiness. lightningcss's nesting
|
|
580 |
+ |
// flattener can print a selector whose text no longer parses
|
|
581 |
+ |
// back to the AST it flattened: `-- &x` scopes correctly in the
|
|
582 |
+ |
// tree and prints as `#uc-abcx`, the trailing type selector
|
|
583 |
+ |
// merging into the canvas id (infra `bd562c12`).
|
|
584 |
+ |
//
|
|
585 |
+ |
// Every shape found that way EXTENDS the identifier, which
|
|
586 |
+ |
// narrows the match -- `#uc-abcx` selects an element that does
|
|
587 |
+ |
// not exist, because the ids we render are exactly `uc-{uuid}`.
|
|
588 |
+ |
// So the rule is inert, and the real property (nothing outside
|
|
589 |
+ |
// the canvas gets styled) holds.
|
|
590 |
+ |
//
|
|
591 |
+ |
// Accepting a suffixed id is therefore safe IN OUR ID
|
|
592 |
+ |
// NAMESPACE, and that is the whole of why it is safe. It is
|
|
593 |
+ |
// recorded as an open finding rather than a settled design:
|
|
594 |
+ |
// a selector the printer mangles is one we are trusting an
|
|
595 |
+ |
// accident for. What this still catches is the thing that
|
|
596 |
+ |
// matters -- a rule carrying no canvas class, or an id
|
|
597 |
+ |
// belonging to a different canvas.
|
|
598 |
+ |
Component::ID(ident) if ident.0.as_ref().starts_with(canvas_id) => has_id = true,
|
|
599 |
+ |
// `:is`/`:where`/`:has`/`:any` constrain the match, so a canvas
|
|
600 |
+ |
// inside one still scopes the rule. `:not` does not, and is
|
|
601 |
+ |
// absent from this list on purpose.
|
|
602 |
+ |
// An EMPTY `:is()` matches nothing, so a selector carrying one
|
|
603 |
+ |
// cannot style anything at all -- inside or outside the canvas
|
|
604 |
+ |
// -- and is safe by construction.
|
|
605 |
+ |
//
|
|
606 |
+ |
// It is reached by round-trip loss rather than by anyone
|
|
607 |
+ |
// writing it. `:is()` is a forgiving selector list, so when the
|
|
608 |
+ |
// printer emits a selector containing a byte the parser will
|
|
609 |
+ |
// not accept, reparsing recovers it as empty. Measured on the
|
|
610 |
+ |
// input committed as regression 05, where
|
|
611 |
+ |
// `:is(.user-canvas#uc-... w<invalid> .X)` came back as `:is()`.
|
|
612 |
+ |
// A browser applies the same forgiving rule to the same text,
|
|
613 |
+ |
// so "matches nothing" is what actually ships, not an artifact
|
|
614 |
+ |
// of how the oracle happens to look at it.
|
|
615 |
+ |
Component::Is(list) if list.is_empty() => return true,
|
|
616 |
+ |
Component::Is(list) | Component::Where(list) | Component::Has(list) => {
|
|
617 |
+ |
if list
|
|
618 |
+ |
.iter()
|
|
619 |
+ |
.any(|s| selector_is_scoped(s, canvas_class, canvas_id))
|
|
620 |
+ |
{
|
|
621 |
+ |
return true;
|
|
622 |
+ |
}
|
|
623 |
+ |
}
|
|
624 |
+ |
Component::Any(_, list) => {
|
|
625 |
+ |
if list
|
|
626 |
+ |
.iter()
|
|
627 |
+ |
.any(|s| selector_is_scoped(s, canvas_class, canvas_id))
|
|
628 |
+ |
{
|
|
629 |
+ |
return true;
|
|
630 |
+ |
}
|
|
631 |
+ |
}
|
|
632 |
+ |
Component::Host(Some(inner))
|
|
633 |
+ |
if selector_is_scoped(inner, canvas_class, canvas_id) =>
|
|
634 |
+ |
{
|
|
635 |
+ |
return true;
|
|
636 |
+ |
}
|
|
637 |
+ |
_ => {}
|
|
638 |
+ |
}
|
|
639 |
+ |
}
|
|
640 |
+ |
has_class && has_id
|
|
641 |
+ |
}
|
|
642 |
+ |
|
|
643 |
+ |
/// The safety floor over values: every `url()` on-platform, no
|
|
644 |
+ |
/// `expression()`.
|
|
645 |
+ |
struct CssFloor<'a> {
|
|
646 |
+ |
policy: &'a UrlPolicy,
|
|
647 |
+ |
what: &'a str,
|
|
648 |
+ |
input: &'a str,
|
|
649 |
+ |
clean: &'a str,
|
|
650 |
+ |
}
|
|
651 |
+ |
|
|
652 |
+ |
impl<'i> Visitor<'i> for CssFloor<'_> {
|
|
653 |
+ |
type Error = Infallible;
|
|
654 |
+ |
|
|
655 |
+ |
fn visit_types(&self) -> VisitTypes {
|
|
656 |
+ |
visit_types!(URLS | FUNCTIONS)
|
|
657 |
+ |
}
|
|
658 |
+ |
|
|
659 |
+ |
fn visit_url(&mut self, url: &mut CssUrl<'i>) -> Result<(), Self::Error> {
|
|
660 |
+ |
// An EMPTY url() is the sanitizer working, not a leak: that is how
|
|
661 |
+ |
// it neutralizes an off-platform reference, and an empty url
|
|
662 |
+ |
// resolves to the current document, which is same-origin by
|
|
663 |
+ |
// definition. The gate itself rejects the empty string, so without
|
|
664 |
+ |
// this the oracle would fire on every page that referenced anything
|
|
665 |
+ |
// external -- the single most likely thing a real creator does.
|
|
666 |
+ |
if url.url.is_empty() {
|
|
667 |
+ |
return Ok(());
|
|
668 |
+ |
}
|
|
669 |
+ |
assert!(
|
|
670 |
+ |
resolve_internal_url(&url.url, self.policy, "oracle").is_ok(),
|
|
671 |
+ |
"{} output kept an off-platform url({:?}) for {:?}: {:?}",
|
|
672 |
+ |
self.what,
|
|
673 |
+ |
url.url,
|
|
674 |
+ |
self.input,
|
|
675 |
+ |
self.clean
|
|
676 |
+ |
);
|
|
677 |
+ |
Ok(())
|
|
678 |
+ |
}
|
|
679 |
+ |
|
|
680 |
+ |
fn visit_function(&mut self, function: &mut Function<'i>) -> Result<(), Self::Error> {
|
|
681 |
+ |
assert!(
|
|
682 |
+ |
!function.name.as_ref().eq_ignore_ascii_case("expression"),
|
|
683 |
+ |
"{} output kept expression() for {:?}: {:?}",
|
|
684 |
+ |
self.what,
|
|
685 |
+ |
self.input,
|
|
686 |
+ |
self.clean
|
|
687 |
+ |
);
|
|
688 |
+ |
function.visit_children(self)
|
|
689 |
+ |
}
|
| 376 |
690 |
|
}
|
| 377 |
691 |
|
|
| 378 |
692 |
|
/// The floor and the closed system, over one piece of sanitized markup.
|
| 506 |
820 |
|
}
|
| 507 |
821 |
|
}
|
| 508 |
822 |
|
|
| 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 {
|
| 513 |
|
- |
assert!(
|
| 514 |
|
- |
!lowered.contains(needle),
|
| 515 |
|
- |
"{what} output kept {needle:?} for input {input:?}: {clean:?}"
|
| 516 |
|
- |
);
|
| 517 |
|
- |
}
|
| 518 |
|
- |
}
|
| 519 |
|
- |
|
| 520 |
823 |
|
/// Decode the character references a sanitizer emits, and nothing else.
|
| 521 |
824 |
|
///
|
| 522 |
825 |
|
/// Ammonia escapes attribute values on the way out, so the oracle sees
|
| 562 |
865 |
|
out.push_str(rest);
|
| 563 |
866 |
|
out
|
| 564 |
867 |
|
}
|
|
868 |
+ |
|
|
869 |
+ |
#[cfg(test)]
|
|
870 |
+ |
mod tests {
|
|
871 |
+ |
//! The oracle must not be vacuous.
|
|
872 |
+ |
//!
|
|
873 |
+ |
//! Every assertion it makes was loosened at least once to stop it
|
|
874 |
+ |
//! reporting correct behaviour as a finding (infra `6f21a29a`,
|
|
875 |
+ |
//! `bd562c12`), and each loosening is a chance to have loosened it into
|
|
876 |
+ |
//! checking nothing. These feed hand-written output straight to
|
|
877 |
+ |
//! [`assert_css_safe`] -- bypassing the sanitizer, which would never
|
|
878 |
+ |
//! produce it -- and assert that it still fires.
|
|
879 |
+ |
|
|
880 |
+ |
use super::*;
|
|
881 |
+ |
|
|
882 |
+ |
const CANVAS_CLASS: &str = "user-canvas";
|
|
883 |
+ |
const CANVAS_ID: &str = "uc-abc";
|
|
884 |
+ |
|
|
885 |
+ |
fn policy() -> UrlPolicy {
|
|
886 |
+ |
UrlPolicy::new(
|
|
887 |
+ |
"https://u.makenot.work/alice/proj",
|
|
888 |
+ |
["makenot.work".to_string(), "u.makenot.work".to_string()],
|
|
889 |
+ |
)
|
|
890 |
+ |
.unwrap()
|
|
891 |
+ |
}
|
|
892 |
+ |
|
|
893 |
+ |
fn check(clean: &str) {
|
|
894 |
+ |
assert_css_safe(
|
|
895 |
+ |
clean,
|
|
896 |
+ |
"test",
|
|
897 |
+ |
"test input",
|
|
898 |
+ |
&policy(),
|
|
899 |
+ |
CANVAS_CLASS,
|
|
900 |
+ |
CANVAS_ID,
|
|
901 |
+ |
);
|
|
902 |
+ |
}
|
|
903 |
+ |
|
|
904 |
+ |
#[test]
|
|
905 |
+ |
fn accepts_properly_scoped_output() {
|
|
906 |
+ |
check(".user-canvas#uc-abc .a{color:red}");
|
|
907 |
+ |
check("@keyframes spin{0%{opacity:0}100%{opacity:1}}");
|
|
908 |
+ |
check("@font-face{font-family:x;src:url(/f.woff2)}");
|
|
909 |
+ |
check(".user-canvas#uc-abc .a{content:\"@import\"}");
|
|
910 |
+ |
check(".user-canvas#uc-abc .a{background:url(https://u.makenot.work/y.png)}");
|
|
911 |
+ |
// The neutralized form the sanitizer actually emits.
|
|
912 |
+ |
check(".user-canvas#uc-abc .a{background:url()}");
|
|
913 |
+ |
}
|
|
914 |
+ |
|
|
915 |
+ |
#[test]
|
|
916 |
+ |
fn accepts_a_canvas_inside_is() {
|
|
917 |
+ |
// The shape lightningcss's nesting flattener actually emits, and
|
|
918 |
+ |
// the one a printed-string check misread as an escape because
|
|
919 |
+ |
// printing rendered it `:is()`.
|
|
920 |
+ |
check("a b :is(.user-canvas#uc-abc .x){color:red}");
|
|
921 |
+ |
}
|
|
922 |
+ |
|
|
923 |
+ |
#[test]
|
|
924 |
+ |
#[should_panic(expected = "escaped")]
|
|
925 |
+ |
fn a_canvas_inside_not_does_not_scope() {
|
|
926 |
+ |
// `:not(.user-canvas#uc-abc)` selects everything OUTSIDE the
|
|
927 |
+ |
// canvas, so counting it would accept the exact inversion of the
|
|
928 |
+ |
// property.
|
|
929 |
+ |
check(":not(.user-canvas#uc-abc){color:red}");
|
|
930 |
+ |
}
|
|
931 |
+ |
|
|
932 |
+ |
#[test]
|
|
933 |
+ |
#[should_panic(expected = "escaped")]
|
|
934 |
+ |
fn half_the_canvas_compound_is_not_the_canvas() {
|
|
935 |
+ |
// The class alone is not the canvas: another creator's canvas
|
|
936 |
+ |
// carries the same class and a different id.
|
|
937 |
+ |
check(".user-canvas .a{color:red}");
|
|
938 |
+ |
}
|
|
939 |
+ |
|