1use crate::Result;
2use cssparser::{
3 AtRuleParser, BasicParseErrorKind, CowRcStr, ParseError, Parser, ParserState,
4 QualifiedRuleParser, SourcePosition, StyleSheetParser, ToCss, Token,
5};
6use std::borrow::Cow;
7use std::fmt;
8
9use super::attr_sanitize::is_unsafe_render_resource_url_value;
10use super::util::{checkpoint_loop, find_tag_end_with_checkpoints, find_with_checkpoints};
11use crate::svg::pipeline::{SvgPostprocessContext, SvgPostprocessor};
12
13const CSS_NESTING_HARD_LIMIT: u8 = 64;
14
15#[derive(Debug, Clone, Copy, Default)]
16pub struct SanitizeCssPostprocessor;
17
18impl SvgPostprocessor for SanitizeCssPostprocessor {
19 fn name(&self) -> &'static str {
20 "sanitize-css"
21 }
22
23 fn process<'a>(
24 &self,
25 svg: Cow<'a, str>,
26 ctx: &SvgPostprocessContext<'_>,
27 ) -> Result<Cow<'a, str>> {
28 apply_sanitize_style_elements(svg, || ctx.checkpoint())
29 }
30}
31
32pub(crate) fn apply_sanitize_style_elements<'a>(
33 svg: Cow<'a, str>,
34 mut checkpoint: impl FnMut() -> Result<()>,
35) -> Result<Cow<'a, str>> {
36 checkpoint()?;
37 if find_with_checkpoints(&svg, "<style", &mut checkpoint)?.is_none() {
38 return Ok(svg);
39 }
40 sanitize_style_elements_with_checkpoints(&svg, &mut checkpoint).map(Cow::Owned)
41}
42
43pub(crate) fn sanitize_style_elements_with_checkpoints<E>(
44 svg: &str,
45 checkpoint: &mut impl FnMut() -> std::result::Result<(), E>,
46) -> std::result::Result<String, E> {
47 if find_with_checkpoints(svg, "<style", checkpoint)?.is_none() {
48 return Ok(svg.to_string());
49 }
50 let mut out = String::with_capacity(svg.len());
51 let mut cursor = 0;
52
53 while let Some(rel_start) = find_with_checkpoints(&svg[cursor..], "<style", checkpoint)? {
54 let start = cursor + rel_start;
55 out.push_str(&svg[cursor..start]);
56
57 let Some(open_end) = find_tag_end_with_checkpoints(svg, start, checkpoint)? else {
58 out.push_str(&svg[start..]);
59 return Ok(out);
60 };
61
62 let content_start = open_end + 1;
63 let Some(rel_close_start) =
64 find_with_checkpoints(&svg[content_start..], "</style", checkpoint)?
65 else {
66 out.push_str(&svg[start..]);
67 return Ok(out);
68 };
69 let close_start = content_start + rel_close_start;
70 let Some(close_end) = find_tag_end_with_checkpoints(svg, close_start, checkpoint)? else {
71 out.push_str(&svg[start..]);
72 return Ok(out);
73 };
74
75 out.push_str(&svg[start..=open_end]);
76 out.push_str(&sanitize_css_with_checkpoints(
77 &svg[content_start..close_start],
78 checkpoint,
79 )?);
80 out.push_str(&svg[close_start..=close_end]);
81 cursor = close_end + 1;
82 }
83
84 out.push_str(&svg[cursor..]);
85 checkpoint()?;
86 Ok(out)
87}
88
89#[cfg(test)]
90pub(crate) fn sanitize_css(css: &str) -> String {
91 process_stylesheet(css, CssProcessingMode::Sanitize).unwrap_or_default()
92}
93
94pub(super) fn sanitize_css_with_checkpoints<E>(
95 css: &str,
96 checkpoint: &mut impl FnMut() -> std::result::Result<(), E>,
97) -> std::result::Result<String, E> {
98 Ok(run_with_css_control(checkpoint, |control| {
99 process_stylesheet_with_control(css, CssProcessingMode::Sanitize, control)
100 })?
101 .unwrap_or_default())
102}
103
104#[cfg(test)]
105pub(super) fn sanitize_css_value(value: &str) -> Option<String> {
106 let mut parser = Parser::new(value);
107 let mut probe = || true;
108 let mut control = CssParseControl::new(&mut probe);
109 rewrite_component_values(
110 &mut parser,
111 CssProcessingMode::Sanitize,
112 CssNestingDepth::default(),
113 &mut control,
114 )
115 .ok()
116}
117
118pub(super) fn sanitize_css_value_with_checkpoints<E>(
119 value: &str,
120 checkpoint: &mut impl FnMut() -> std::result::Result<(), E>,
121) -> std::result::Result<Option<String>, E> {
122 Ok(run_with_css_control(checkpoint, |control| {
123 let mut parser = Parser::new(value);
124 rewrite_component_values(
125 &mut parser,
126 CssProcessingMode::Sanitize,
127 CssNestingDepth::default(),
128 control,
129 )
130 })?
131 .ok())
132}
133
134#[cfg(test)]
135pub(in crate::svg::pipeline) fn validate_resvg_css_stylesheet(
136 css: &str,
137) -> std::result::Result<(), String> {
138 process_stylesheet(css, CssProcessingMode::Validate).map(|_| ())
139}
140
141pub(in crate::svg::pipeline) fn validate_resvg_css_stylesheet_with_checkpoints<E>(
142 css: &str,
143 checkpoint: &mut impl FnMut() -> std::result::Result<(), E>,
144) -> std::result::Result<(), CssValidationError<E>> {
145 run_with_css_control(checkpoint, |control| {
146 process_stylesheet_with_control(css, CssProcessingMode::Validate, control).map(|_| ())
147 })
148 .map_err(CssValidationError::Checkpoint)?
149 .map_err(CssValidationError::Invalid)
150}
151
152#[cfg(test)]
153pub(in crate::svg::pipeline) fn validate_resvg_css_declaration_list(
154 css: &str,
155) -> std::result::Result<(), String> {
156 let mut parser = Parser::new(css);
157 let mut probe = || true;
158 let mut control = CssParseControl::new(&mut probe);
159 rewrite_declaration_list(
160 &mut parser,
161 CssProcessingMode::Validate,
162 CssNestingDepth::default(),
163 &mut control,
164 )
165 .map(|_| ())
166 .map_err(format_parse_error)
167}
168
169pub(in crate::svg::pipeline) fn validate_resvg_css_declaration_list_with_checkpoints<E>(
170 css: &str,
171 checkpoint: &mut impl FnMut() -> std::result::Result<(), E>,
172) -> std::result::Result<(), CssValidationError<E>> {
173 run_with_css_control(checkpoint, |control| {
174 let mut parser = Parser::new(css);
175 rewrite_declaration_list(
176 &mut parser,
177 CssProcessingMode::Validate,
178 CssNestingDepth::default(),
179 control,
180 )
181 .map(|_| ())
182 .map_err(format_parse_error)
183 })
184 .map_err(CssValidationError::Checkpoint)?
185 .map_err(CssValidationError::Invalid)
186}
187
188pub(in crate::svg::pipeline) enum CssValidationError<E> {
189 Invalid(String),
190 Checkpoint(E),
191}
192
193fn run_with_css_control<T, I, E>(
194 checkpoint: &mut impl FnMut() -> std::result::Result<(), E>,
195 run: impl FnOnce(&mut CssParseControl<'_>) -> std::result::Result<T, I>,
196) -> std::result::Result<std::result::Result<T, I>, E> {
197 checkpoint()?;
198 let mut checkpoint_error = None;
199 let result = {
200 let mut probe = || match checkpoint() {
201 Ok(()) => true,
202 Err(error) => {
203 checkpoint_error = Some(error);
204 false
205 }
206 };
207 let mut control = CssParseControl::new(&mut probe);
208 run(&mut control)
209 };
210 if let Some(error) = checkpoint_error {
211 return Err(error);
212 }
213 checkpoint()?;
214 Ok(result)
215}
216
217#[derive(Debug, Clone, Copy, PartialEq, Eq)]
218enum CssProcessingMode {
219 Sanitize,
220 Validate,
221}
222
223struct CssParseControl<'a> {
224 checkpoint: &'a mut dyn FnMut() -> bool,
225 iterations: usize,
226 cancelled: bool,
227}
228
229impl<'a> CssParseControl<'a> {
230 fn new(checkpoint: &'a mut dyn FnMut() -> bool) -> Self {
231 Self {
232 checkpoint,
233 iterations: 0,
234 cancelled: false,
235 }
236 }
237
238 fn step(&mut self) -> std::result::Result<(), ParseError<CssViolation>> {
239 self.observe().map_err(ParseError::custom)
240 }
241
242 fn observe(&mut self) -> std::result::Result<(), CssViolation> {
243 if self.cancelled {
244 return Err(CssViolation::Cancelled);
245 }
246
247 self.iterations = self.iterations.saturating_add(1);
248 let checkpoint_result = checkpoint_loop(self.iterations, &mut || {
249 if (self.checkpoint)() { Ok(()) } else { Err(()) }
250 });
251 if checkpoint_result.is_err() {
252 self.cancelled = true;
253 return Err(CssViolation::Cancelled);
254 }
255 Ok(())
256 }
257}
258
259#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
260struct CssNestingDepth(u8);
261
262impl CssNestingDepth {
263 fn descend(self) -> std::result::Result<Self, ParseError<CssViolation>> {
264 if self.0 >= CSS_NESTING_HARD_LIMIT {
265 return Err(ParseError::custom(CssViolation::NestingLimit));
266 }
267 Ok(Self(self.0 + 1))
268 }
269}
270
271#[derive(Debug, Clone, PartialEq, Eq)]
272enum CssViolation {
273 Animation,
274 BadToken,
275 Cancelled,
276 Degrees,
277 EmptyDeclaration,
278 ExternalImageFunction,
279 MarkerReference,
280 NestingLimit,
281 RootSelector,
282 UnclosedBlock,
283 UnsafeUrl,
284 UnsupportedAtRule(String),
285}
286
287impl fmt::Display for CssViolation {
288 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
289 match self {
290 Self::Animation => f.write_str("CSS animation is not part of the resvg-safe contract"),
291 Self::BadToken => f.write_str("malformed CSS token"),
292 Self::Cancelled => f.write_str("CSS processing was cancelled"),
293 Self::Degrees => {
294 f.write_str("CSS angle units are not accepted by the resvg-safe contract")
295 }
296 Self::EmptyDeclaration => f.write_str("empty CSS declaration"),
297 Self::ExternalImageFunction => {
298 f.write_str("CSS image-set functions can reference external resources")
299 }
300 Self::MarkerReference => {
301 f.write_str("CSS marker references are not part of the resvg-safe contract")
302 }
303 Self::NestingLimit => write!(
304 f,
305 "CSS nesting exceeds the resvg-safe hard limit of {CSS_NESTING_HARD_LIMIT}"
306 ),
307 Self::RootSelector => {
308 f.write_str(":root rules are not part of the resvg-safe contract")
309 }
310 Self::UnclosedBlock => f.write_str("unclosed CSS block or function"),
311 Self::UnsafeUrl => f.write_str("unsafe CSS URL"),
312 Self::UnsupportedAtRule(name) => {
313 write!(f, "@{name} is not part of the resvg-safe contract")
314 }
315 }
316 }
317}
318
319#[derive(Debug, Clone, Copy, PartialEq, Eq)]
320enum AtRuleBody {
321 Declarations,
322 RuleList,
323}
324
325#[derive(Debug)]
326enum AtRulePrelude {
327 Drop,
328 Keep {
329 name: String,
330 prelude: String,
331 body: AtRuleBody,
332 },
333}
334
335struct ResvgCssRuleParser<'control, 'checkpoint> {
336 mode: CssProcessingMode,
337 depth: CssNestingDepth,
338 control: &'control mut CssParseControl<'checkpoint>,
339}
340
341impl<'i> AtRuleParser<'i> for ResvgCssRuleParser<'_, '_> {
342 type Prelude = AtRulePrelude;
343 type AtRule = String;
344 type Error = CssViolation;
345
346 fn parse_prelude(
347 &mut self,
348 name: CowRcStr<'i>,
349 input: &mut Parser<'i>,
350 ) -> std::result::Result<Self::Prelude, ParseError<Self::Error>> {
351 let prelude = rewrite_component_values(input, self.mode, self.depth, self.control)?;
352 let normalized_name = name.to_ascii_lowercase();
353 let body = match normalized_name.as_str() {
354 "font-face" | "page" => Some(AtRuleBody::Declarations),
355 "container" | "document" | "layer" | "media" | "scope" | "supports" => {
356 Some(AtRuleBody::RuleList)
357 }
358 _ => None,
359 };
360
361 let explicitly_removed = matches!(
362 normalized_name.as_str(),
363 "import" | "keyframes" | "-webkit-keyframes"
364 );
365 if explicitly_removed || body.is_none() {
366 if self.mode == CssProcessingMode::Validate {
367 return Err(ParseError::custom(CssViolation::UnsupportedAtRule(
368 normalized_name,
369 )));
370 }
371 return Ok(AtRulePrelude::Drop);
372 }
373
374 Ok(AtRulePrelude::Keep {
375 name: name.to_string(),
376 prelude,
377 body: body.expect("checked above"),
378 })
379 }
380
381 fn rule_without_block(
382 &mut self,
383 prelude: Self::Prelude,
384 _start: &ParserState,
385 ) -> std::result::Result<Self::AtRule, ()> {
386 match prelude {
387 AtRulePrelude::Drop => Ok(String::new()),
388 AtRulePrelude::Keep { .. } => Err(()),
389 }
390 }
391
392 fn parse_block(
393 &mut self,
394 prelude: Self::Prelude,
395 _start: &ParserState,
396 input: &mut Parser<'i>,
397 ) -> std::result::Result<Self::AtRule, ParseError<Self::Error>> {
398 let depth = self.depth.descend()?;
399 let AtRulePrelude::Keep {
400 name,
401 prelude,
402 body,
403 } = prelude
404 else {
405 consume_component_values(input, depth, self.control)?;
406 return Ok(String::new());
407 };
408
409 let body = match body {
410 AtRuleBody::Declarations => {
411 rewrite_declaration_list(input, self.mode, depth, self.control)?
412 }
413 AtRuleBody::RuleList => rewrite_rule_list(input, self.mode, depth, self.control)?,
414 };
415 Ok(format!("@{name}{prelude}{{{body}}}"))
416 }
417}
418
419impl<'i> QualifiedRuleParser<'i> for ResvgCssRuleParser<'_, '_> {
420 type Prelude = Option<String>;
421 type QualifiedRule = String;
422 type Error = CssViolation;
423
424 fn parse_prelude(
425 &mut self,
426 input: &mut Parser<'i>,
427 ) -> std::result::Result<Self::Prelude, ParseError<Self::Error>> {
428 let prelude = rewrite_component_values(input, self.mode, self.depth, self.control)?;
429 if selector_contains_root(&prelude, self.depth, self.control).map_err(ParseError::custom)? {
430 if self.mode == CssProcessingMode::Validate {
431 return Err(ParseError::custom(CssViolation::RootSelector));
432 }
433 return Ok(None);
434 }
435 Ok(Some(prelude))
436 }
437
438 fn parse_block(
439 &mut self,
440 prelude: Self::Prelude,
441 _start: &ParserState,
442 input: &mut Parser<'i>,
443 ) -> std::result::Result<Self::QualifiedRule, ParseError<Self::Error>> {
444 let depth = self.depth.descend()?;
445 let Some(prelude) = prelude else {
446 consume_component_values(input, depth, self.control)?;
447 return Ok(String::new());
448 };
449 let declarations = rewrite_declaration_list(input, self.mode, depth, self.control)?;
450 Ok(format!("{prelude}{{{declarations}}}"))
451 }
452}
453
454#[cfg(test)]
455fn process_stylesheet(css: &str, mode: CssProcessingMode) -> std::result::Result<String, String> {
456 let mut probe = || true;
457 let mut control = CssParseControl::new(&mut probe);
458 process_stylesheet_with_control(css, mode, &mut control)
459}
460
461fn process_stylesheet_with_control(
462 css: &str,
463 mode: CssProcessingMode,
464 control: &mut CssParseControl<'_>,
465) -> std::result::Result<String, String> {
466 let mut input = Parser::new(css);
467 rewrite_rule_list(&mut input, mode, CssNestingDepth::default(), control)
468 .map_err(format_parse_error)
469}
470
471fn rewrite_rule_list(
472 input: &mut Parser<'_>,
473 mode: CssProcessingMode,
474 depth: CssNestingDepth,
475 control: &mut CssParseControl<'_>,
476) -> std::result::Result<String, ParseError<CssViolation>> {
477 let mut parser = ResvgCssRuleParser {
478 mode,
479 depth,
480 control,
481 };
482 let mut output = String::new();
483
484 for rule in StyleSheetParser::new(input, &mut parser) {
485 match rule {
486 Ok(rule) => output.push_str(&rule),
487 Err((error, _, _)) if is_cancelled_parse_error(&error) => return Err(error),
488 Err((error, _, _)) if mode == CssProcessingMode::Validate => return Err(error),
489 Err(_) => {}
490 }
491 }
492
493 Ok(output)
494}
495
496fn rewrite_declaration_list(
497 input: &mut Parser<'_>,
498 mode: CssProcessingMode,
499 depth: CssNestingDepth,
500 control: &mut CssParseControl<'_>,
501) -> std::result::Result<String, ParseError<CssViolation>> {
502 let mut output = String::new();
503
504 loop {
505 control.step()?;
506 let declaration_start = input.position();
507 if input.is_exhausted() {
508 output.push_str(input.slice_from(declaration_start));
509 return Ok(output);
510 }
511
512 let declaration = input.parse_until_after(cssparser::Delimiter::Semicolon, |declaration| {
513 let prefix_start = declaration.position();
514 let property = declaration.expect_ident_cloned()?;
515 declaration.expect_colon()?;
516 let value_start = declaration.position();
517 let prefix = declaration.slice(prefix_start..value_start).to_string();
518
519 if is_animation_property(&property) {
520 consume_component_values(declaration, depth, control)?;
521 if mode == CssProcessingMode::Validate {
522 return Err(ParseError::custom(CssViolation::Animation));
523 }
524 return Ok(None);
525 }
526
527 if is_marker_reference_property(&property) {
528 consume_component_values(declaration, depth, control)?;
529 if mode == CssProcessingMode::Validate {
530 return Err(ParseError::custom(CssViolation::MarkerReference));
531 }
532 return Ok(None);
533 }
534
535 let value = rewrite_component_values(declaration, mode, depth, control)?;
536 if value.trim().is_empty() {
537 return Err(ParseError::custom(CssViolation::EmptyDeclaration));
538 }
539 Ok(Some((prefix, value)))
540 });
541 let declaration_end = input.position();
542 let had_semicolon = input
543 .slice(declaration_start..declaration_end)
544 .trim_end()
545 .ends_with(';');
546
547 match declaration {
548 Ok(Some((prefix, value))) => {
549 output.push_str(&prefix);
550 output.push_str(&value);
551 if had_semicolon {
552 output.push(';');
553 }
554 }
555 Ok(None) => {}
556 Err(error) if is_cancelled_parse_error(&error) => return Err(error),
557 Err(error) if mode == CssProcessingMode::Validate => return Err(error),
558 Err(_) => {}
559 }
560 }
561}
562
563fn rewrite_component_values(
564 input: &mut Parser<'_>,
565 mode: CssProcessingMode,
566 depth: CssNestingDepth,
567 control: &mut CssParseControl<'_>,
568) -> std::result::Result<String, ParseError<CssViolation>> {
569 let mut output = String::new();
570
571 loop {
572 control.step()?;
573 let token_start = input.position();
574 let token = match input.next_including_whitespace() {
575 Ok(token) => token.clone(),
576 Err(error) if matches!(error.kind, BasicParseErrorKind::EndOfInput) => {
577 output.push_str(input.slice_from(token_start));
578 return Ok(output);
579 }
580 Err(error) => return Err(error.into()),
581 };
582 let token_end = input.position();
583
584 match token {
585 Token::Dimension {
586 value,
587 int_value,
588 has_sign,
589 unit,
590 } if unit.eq_ignore_ascii_case("deg") => {
591 if mode == CssProcessingMode::Validate {
592 return Err(ParseError::custom(CssViolation::Degrees));
593 }
594 output.push_str(
595 &Token::Number {
596 value,
597 int_value,
598 has_sign,
599 }
600 .to_css_string(),
601 );
602 }
603 Token::UnquotedUrl(url) => {
604 if is_unsafe_render_resource_url_value(&url) {
605 return Err(ParseError::custom(CssViolation::UnsafeUrl));
606 }
607 output.push_str(input.slice(token_start..token_end));
608 }
609 Token::Function(name) => {
610 if matches_external_image_function(&name) {
611 return Err(ParseError::custom(CssViolation::ExternalImageFunction));
612 }
613 let nested_depth = depth.descend()?;
614 output.push_str(input.slice(token_start..token_end));
615 let nested = input.parse_nested_block(|nested| {
616 if name.eq_ignore_ascii_case("url") {
617 rewrite_quoted_url(nested, mode, nested_depth, control)
618 } else {
619 rewrite_component_values(nested, mode, nested_depth, control)
620 }
621 })?;
622 ensure_source_closed_block(input, token_start, ')', control)?;
623 output.push_str(&nested);
624 output.push(')');
625 }
626 Token::ParenthesisBlock | Token::SquareBracketBlock | Token::CurlyBracketBlock => {
627 let nested_depth = depth.descend()?;
628 output.push_str(input.slice(token_start..token_end));
629 let nested = input.parse_nested_block(|nested| {
630 rewrite_component_values(nested, mode, nested_depth, control)
631 })?;
632 let close = match token {
633 Token::ParenthesisBlock => ')',
634 Token::SquareBracketBlock => ']',
635 Token::CurlyBracketBlock => '}',
636 _ => unreachable!(),
637 };
638 ensure_source_closed_block(input, token_start, close, control)?;
639 output.push_str(&nested);
640 output.push(close);
641 }
642 Token::BadUrl(_) | Token::BadString(_) => {
643 return Err(ParseError::custom(CssViolation::BadToken));
644 }
645 _ => output.push_str(input.slice(token_start..token_end)),
646 }
647 }
648}
649
650pub(in crate::svg::pipeline) fn matches_external_image_function(name: &str) -> bool {
651 name.eq_ignore_ascii_case("image-set") || name.eq_ignore_ascii_case("-webkit-image-set")
652}
653
654fn ensure_source_closed_block(
655 input: &Parser<'_>,
656 token_start: SourcePosition,
657 close: char,
658 control: &mut CssParseControl<'_>,
659) -> std::result::Result<(), ParseError<CssViolation>> {
660 let raw_block = input.slice(token_start..input.position());
661 if source_ends_with_close(raw_block, close, control).map_err(ParseError::custom)?
662 && source_closes_initial_block(raw_block, close, control).map_err(ParseError::custom)?
663 {
664 return Ok(());
665 }
666
667 Err(ParseError::custom(CssViolation::UnclosedBlock))
668}
669
670fn source_ends_with_close(
671 raw_block: &str,
672 close: char,
673 control: &mut CssParseControl<'_>,
674) -> std::result::Result<bool, CssViolation> {
675 for character in raw_block.chars().rev() {
676 control.observe()?;
677 if character.is_whitespace() {
678 continue;
679 }
680 return Ok(character == close);
681 }
682 Ok(false)
683}
684
685fn source_closes_initial_block(
686 raw_block: &str,
687 close: char,
688 control: &mut CssParseControl<'_>,
689) -> std::result::Result<bool, CssViolation> {
690 const SENTINEL: &str = "__merman_css_closed_block_sentinel__";
691
692 control.observe()?;
693 let mut probe = String::with_capacity(raw_block.len() + SENTINEL.len() + 1);
694 probe.push_str(raw_block);
695 probe.push(' ');
696 probe.push_str(SENTINEL);
697
698 let mut parser = Parser::new(&probe);
699 let Ok(token) = parser.next_including_whitespace().cloned() else {
700 return Ok(false);
701 };
702 if !opening_token_matches_close(&token, close) {
703 return Ok(false);
704 }
705
706 let nested_result = parser.parse_nested_block(|nested| {
707 loop {
708 control.step()?;
709 if nested.next_including_whitespace().is_err() {
710 break;
711 }
712 }
713 Ok::<_, ParseError<CssViolation>>(())
714 });
715 if let Err(error) = nested_result {
716 return if is_cancelled_parse_error(&error) {
717 Err(CssViolation::Cancelled)
718 } else {
719 Ok(false)
720 };
721 }
722
723 control.observe()?;
724 Ok(matches!(
725 parser.next(),
726 Ok(Token::Ident(name)) if name.as_ref() == SENTINEL
727 ))
728}
729
730fn opening_token_matches_close(token: &Token<'_>, close: char) -> bool {
731 match token {
732 Token::Function(_) | Token::ParenthesisBlock => close == ')',
733 Token::SquareBracketBlock => close == ']',
734 Token::CurlyBracketBlock => close == '}',
735 _ => false,
736 }
737}
738
739fn rewrite_quoted_url(
740 input: &mut Parser<'_>,
741 mode: CssProcessingMode,
742 depth: CssNestingDepth,
743 control: &mut CssParseControl<'_>,
744) -> std::result::Result<String, ParseError<CssViolation>> {
745 control.step()?;
746 let url_start = input.position();
747 let url = input.expect_string_cloned()?;
748 input.expect_exhausted()?;
749 if is_unsafe_render_resource_url_value(&url) {
750 return Err(ParseError::custom(CssViolation::UnsafeUrl));
751 }
752
753 let raw = input.slice_from(url_start);
754 let mut raw_parser = Parser::new(raw);
755 rewrite_component_values(&mut raw_parser, mode, depth, control)
756}
757
758fn consume_component_values(
759 input: &mut Parser<'_>,
760 depth: CssNestingDepth,
761 control: &mut CssParseControl<'_>,
762) -> std::result::Result<(), ParseError<CssViolation>> {
763 loop {
764 control.step()?;
765 let token = match input.next_including_whitespace() {
766 Ok(token) => token.clone(),
767 Err(error) if matches!(error.kind, BasicParseErrorKind::EndOfInput) => return Ok(()),
768 Err(error) => return Err(error.into()),
769 };
770 if matches!(
771 token,
772 Token::Function(_)
773 | Token::ParenthesisBlock
774 | Token::SquareBracketBlock
775 | Token::CurlyBracketBlock
776 ) {
777 let nested_depth = depth.descend()?;
778 input.parse_nested_block(|nested| {
779 consume_component_values(nested, nested_depth, control)
780 })?;
781 }
782 }
783}
784
785fn is_animation_property(property: &str) -> bool {
786 let property = property.to_ascii_lowercase();
787 property == "animation" || property.starts_with("animation-")
788}
789
790fn is_marker_reference_property(property: &str) -> bool {
791 matches!(
792 property.to_ascii_lowercase().as_str(),
793 "marker" | "marker-start" | "marker-mid" | "marker-end"
794 )
795}
796
797fn selector_contains_root(
798 selector: &str,
799 depth: CssNestingDepth,
800 control: &mut CssParseControl<'_>,
801) -> std::result::Result<bool, CssViolation> {
802 let mut parser = Parser::new(selector);
803 parser_contains_root_selector(&mut parser, depth, control).map_err(|error| match error.kind {
804 cssparser::ParseErrorKind::Custom(violation) => violation,
805 cssparser::ParseErrorKind::Basic(_) => CssViolation::BadToken,
806 })
807}
808
809fn parser_contains_root_selector(
810 input: &mut Parser<'_>,
811 depth: CssNestingDepth,
812 control: &mut CssParseControl<'_>,
813) -> std::result::Result<bool, ParseError<CssViolation>> {
814 let mut after_colon = false;
815 loop {
816 control.step()?;
817 let token = match input.next_including_whitespace() {
818 Ok(token) => token.clone(),
819 Err(error) if matches!(error.kind, BasicParseErrorKind::EndOfInput) => {
820 return Ok(false);
821 }
822 Err(error) => return Err(error.into()),
823 };
824 match token {
825 Token::Colon => after_colon = true,
826 Token::Ident(name) if after_colon && name.eq_ignore_ascii_case("root") => {
827 return Ok(true);
828 }
829 Token::Function(name) => {
830 if after_colon && name.eq_ignore_ascii_case("root") {
831 return Ok(true);
832 }
833 let nested_depth = depth.descend()?;
834 if input.parse_nested_block(|nested| {
835 parser_contains_root_selector(nested, nested_depth, control)
836 })? {
837 return Ok(true);
838 }
839 after_colon = false;
840 }
841 Token::ParenthesisBlock | Token::SquareBracketBlock | Token::CurlyBracketBlock => {
842 let nested_depth = depth.descend()?;
843 if input.parse_nested_block(|nested| {
844 parser_contains_root_selector(nested, nested_depth, control)
845 })? {
846 return Ok(true);
847 }
848 after_colon = false;
849 }
850 Token::WhiteSpace(_) => {}
851 _ => after_colon = false,
852 }
853 }
854}
855
856fn is_cancelled_parse_error(error: &ParseError<CssViolation>) -> bool {
857 matches!(
858 &error.kind,
859 cssparser::ParseErrorKind::Custom(CssViolation::Cancelled)
860 )
861}
862
863fn format_parse_error(error: ParseError<CssViolation>) -> String {
864 error.to_string()
865}
866
867#[cfg(test)]
868mod tests {
869 use super::*;
870
871 fn nested_function(depth: usize, leaf: &str) -> String {
872 format!("{}{}{}", "f(".repeat(depth), leaf, ")".repeat(depth))
873 }
874
875 fn nested_media(depth: usize, rule: &str) -> String {
876 format!(
877 "{}{}{}",
878 "@media all{".repeat(depth),
879 rule,
880 "}".repeat(depth)
881 )
882 }
883
884 #[test]
885 fn css_sanitize_removes_active_rules_and_declarations_after_tokenization() {
886 let css = r#"@\69mport url(\"https://example.com/a.css\");@k\65yframes spin{to{opacity:1}}:r\6fot{--x:red}.a{a\6eimation:spin 1s;fill:red}"#;
887 let out = sanitize_css(css);
888
889 assert_eq!(out, ".a{fill:red}");
890 }
891
892 #[test]
893 fn css_sanitize_rewrites_only_dimension_tokens() {
894 let css = r##".a{transform:rotate(45deg) rotate(-10.5DEG);content:\"45deg\";background:url(#a45deg);--name:foo45deg;--near:90deg-foo}"##;
895 let out = sanitize_css(css);
896
897 assert!(out.contains("rotate(45) rotate(-10.5)"), "{out}");
898 assert!(out.contains(r#"content:\"45deg\""#), "{out}");
899 assert!(out.contains("url(#a45deg)"), "{out}");
900 assert!(out.contains("--name:foo45deg"), "{out}");
901 assert!(out.contains("--near:90deg-foo"), "{out}");
902 }
903
904 #[test]
905 fn css_sanitize_drops_unsafe_urls_and_preserves_safe_image_sources() {
906 let css = r##".bad{fill:u\72l(\"j\61vascript:alert(1)\");stroke:red}.fragment{fill:url(#paint)}.image{background:url(data:image/png;base64,AAAA)}"##;
907 let out = sanitize_css(css);
908
909 assert_eq!(
910 out,
911 r##".bad{stroke:red}.fragment{fill:url(#paint)}.image{background:url(data:image/png;base64,AAAA)}"##
912 );
913 }
914
915 #[test]
916 fn css_sanitize_drops_escaped_external_image_functions() {
917 let css = r#".bad{background-image:im\61ge-set(\"https://example.test/x.png\" 1x);stroke:red}.also-bad{content:-webkit-image-set(\"https://example.test/y.png\" 1x);fill:blue}"#;
918 let out = sanitize_css(css);
919
920 assert_eq!(out, ".bad{stroke:red}.also-bad{fill:blue}");
921 assert!(validate_resvg_css_stylesheet(css).is_err());
922 }
923
924 #[test]
925 fn css_sanitize_removes_marker_references_that_cannot_be_preflighted() {
926 let css = r##".edge{marker:url(#all);marker-start:url(#start);marker-mid:url(#mid);marker-end:url(#end);stroke:red}"##;
927
928 assert_eq!(sanitize_css(css), ".edge{stroke:red}");
929 assert!(validate_resvg_css_stylesheet(css).is_err());
930 assert!(validate_resvg_css_declaration_list("marker-end:url(#end);stroke:red").is_err());
931 }
932
933 #[test]
934 fn css_sanitize_drops_external_render_resources() {
935 let css = r##".local{background:url(../image.png);stroke:red}.root{cursor:url("/tmp/cursor.svg"),auto;fill:blue}.remote{filter:url(https://example.com/filter.svg#blur);color:black}.fragment{fill:url(#paint)}.embedded{background:url(data:image/png;base64,AAAA)}.missing-comma{background:url(data:image/png);opacity:.5}.spaced{background:url("d a t a:image/png;base64,BBBB");opacity:.75}"##;
936
937 let out = sanitize_css(css);
938
939 assert!(!out.contains("../image.png"), "{out}");
940 assert!(!out.contains("/tmp/cursor.svg"), "{out}");
941 assert!(!out.contains("https://example.com"), "{out}");
942 assert!(out.contains(".local{stroke:red}"), "{out}");
943 assert!(out.contains(".root{fill:blue}"), "{out}");
944 assert!(out.contains(".remote{color:black}"), "{out}");
945 assert!(out.contains(".fragment{fill:url(#paint)}"), "{out}");
946 assert!(
947 out.contains(".embedded{background:url(data:image/png;base64,AAAA)}"),
948 "{out}"
949 );
950 assert!(out.contains(".missing-comma{opacity:.5}"), "{out}");
951 assert!(out.contains(".spaced{opacity:.75}"), "{out}");
952 assert!(!out.contains("url(data:image/png)"), "{out}");
953 assert!(!out.contains("d a t a:"), "{out}");
954 }
955
956 #[test]
957 fn css_validation_rejects_non_terminal_constructs() {
958 assert!(validate_resvg_css_stylesheet("@import 'a.css';").is_err());
959 assert!(validate_resvg_css_stylesheet(".a{animation:spin 1s}").is_err());
960 assert!(validate_resvg_css_stylesheet(".a{transform:rotate(45deg)}").is_err());
961 assert!(validate_resvg_css_stylesheet(".a{fill:url(javascript:x)}").is_err());
962 assert!(validate_resvg_css_stylesheet(".a{content:\"45deg\"}").is_ok());
963 }
964
965 #[test]
966 fn deg_component_rewrite_does_not_touch_strings_or_urls() {
967 assert_eq!(
968 sanitize_css_value(r##"rotate(.5deg) \"45deg\" url(#a45deg) foo45deg 90deg-foo"##)
969 .as_deref(),
970 Some(r##"rotate(0.5) \"45deg\" url(#a45deg) foo45deg 90deg-foo"##)
971 );
972 }
973
974 #[test]
975 fn css_sanitize_rejects_unclosed_component_value_blocks() {
976 assert_eq!(sanitize_css_value("5rl('file:///{animatiEtroke:#333"), None);
977 assert_eq!(sanitize_css_value("rotate(45deg"), None);
978 assert_eq!(sanitize_css_value("outer((red)"), None);
979 }
980
981 #[test]
982 fn controlled_css_sanitize_stops_during_declaration_traversal() {
983 let css = format!(".a{{{}}}", "fill:red;".repeat(512));
984 let mut checkpoints = 0usize;
985 let result = sanitize_css_with_checkpoints(&css, &mut || {
986 checkpoints += 1;
987 if checkpoints == 3 {
988 Err("cancelled")
989 } else {
990 Ok(())
991 }
992 });
993
994 assert_eq!(result, Err("cancelled"));
995 assert_eq!(checkpoints, 3);
996 }
997
998 #[test]
999 fn css_nesting_limit_is_inclusive_and_validation_reports_the_limit() {
1000 let exact = nested_function(CSS_NESTING_HARD_LIMIT.into(), "red");
1001 let over = nested_function(usize::from(CSS_NESTING_HARD_LIMIT) + 1, "red");
1002
1003 assert_eq!(sanitize_css_value(&exact).as_deref(), Some(exact.as_str()));
1004 assert_eq!(sanitize_css_value(&over), None);
1005 assert!(validate_resvg_css_declaration_list(&format!("fill:{exact}")).is_ok());
1006
1007 let error = validate_resvg_css_declaration_list(&format!("fill:{over}"))
1008 .expect_err("one nesting level past the hard limit must be rejected");
1009 assert!(error.contains("CSS nesting exceeds"), "{error}");
1010 assert!(
1011 error.contains(&CSS_NESTING_HARD_LIMIT.to_string()),
1012 "{error}"
1013 );
1014 }
1015
1016 #[test]
1017 fn css_nesting_limit_bounds_rules_selectors_and_drop_traversal() {
1018 let exact_rule_value = nested_function(usize::from(CSS_NESTING_HARD_LIMIT) - 1, "red");
1019 let over_rule_value = nested_function(CSS_NESTING_HARD_LIMIT.into(), "red");
1020 let declarations =
1021 format!(".exact{{fill:{exact_rule_value}}}.over{{fill:{over_rule_value};stroke:blue}}");
1022 assert_eq!(
1023 sanitize_css(&declarations),
1024 format!(".exact{{fill:{exact_rule_value}}}.over{{stroke:blue}}")
1025 );
1026
1027 let selector_exact = format!(
1028 "{}.exact-selector{}{{fill:red}}",
1029 ":is(".repeat(CSS_NESTING_HARD_LIMIT.into()),
1030 ")".repeat(CSS_NESTING_HARD_LIMIT.into())
1031 );
1032 let selector_over = format!(
1033 "{}.over-selector{}{{fill:red}}",
1034 ":is(".repeat(usize::from(CSS_NESTING_HARD_LIMIT) + 1),
1035 ")".repeat(usize::from(CSS_NESTING_HARD_LIMIT) + 1)
1036 );
1037 assert!(validate_resvg_css_stylesheet(&selector_exact).is_ok());
1038 let selector_error = validate_resvg_css_stylesheet(&selector_over)
1039 .expect_err("an over-limit selector function must be rejected");
1040 assert!(selector_error.contains("CSS nesting exceeds"));
1041 assert!(sanitize_css(&selector_over).is_empty());
1042
1043 let media_exact = nested_media(CSS_NESTING_HARD_LIMIT.into(), "");
1044 let media_over = nested_media(usize::from(CSS_NESTING_HARD_LIMIT) + 1, "");
1045 assert!(validate_resvg_css_stylesheet(&media_exact).is_ok());
1046 let media_error = validate_resvg_css_stylesheet(&media_over)
1047 .expect_err("an over-limit grouping rule must be rejected");
1048 assert!(media_error.contains("CSS nesting exceeds"));
1049 assert_eq!(
1050 sanitize_css(&media_over).matches("@media").count(),
1051 usize::from(CSS_NESTING_HARD_LIMIT)
1052 );
1053
1054 let deeply_nested = nested_function(4_096, "spin");
1055 let dropped = format!(
1056 ".animation{{animation:{deeply_nested};stroke:blue}}:root{{--x:{deeply_nested}}}@unknown x{{value:{deeply_nested}}}.safe{{fill:red}}"
1057 );
1058 assert_eq!(
1059 sanitize_css(&dropped),
1060 ".animation{stroke:blue}.safe{fill:red}"
1061 );
1062 }
1063}