selma 0.5.2 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9f9a2f98b54d8a383ef57ac7c8ef109b3849d07cb35bfa93a431e93c1a216e6d
4
- data.tar.gz: 774d712999e64ff76f145689a08bbcd6b127fed100671bef264c9522bde9f2e5
3
+ metadata.gz: 968ea670eb08eb49bbb5c305e257f87e4c38bd8371c9f0ba823600cc2be6c660
4
+ data.tar.gz: 83a5af8a2314d041ac6ae153db21271d57665c7b6a3ad851bdcb92c7753610d0
5
5
  SHA512:
6
- metadata.gz: 87907675ef6bd1b0bc54196f451edc99c4527f25cb14a0d3cc223dbe5f4671e68f5787487348b1c7a80e152bd515bc0cc8e43df57f997a36541115753c66c267
7
- data.tar.gz: f4bc7741b7a05506b8259e4c9e961d4230c340a32c98be96ba09ad67303e86d4925e0132001940aabcde55dc536348d41a85d1c0d0e290b2cfb68a0e9f82c4de
6
+ metadata.gz: 2673b5d305855725ffa53b343cdb6fa8da91903a914da50211eeff4acc17eee54837c29e6e8d19d20ad52c29075dc8ea2b2c9700b6cf0dfd255665aa0adfaccf
7
+ data.tar.gz: edd5982cef291e0fc666117c0ba0f6e7b18de03a007dc55b6a464b3fe0cb67db51435d86050ea01743694c65a8bd373debb606eff58a79a1e4fe361efa57912f
data/Cargo.lock CHANGED
@@ -508,6 +508,7 @@ dependencies = [
508
508
  "escapist",
509
509
  "lol_html",
510
510
  "magnus",
511
+ "memchr",
511
512
  "rb-sys",
512
513
  ]
513
514
 
data/README.md CHANGED
@@ -201,6 +201,10 @@ The structure of the `memory` options looks like this:
201
201
 
202
202
  Note that `preallocated_parsing_buffer_size` must always be less than `max_allowed_memory_usage`. See [the`lol_html` project documentation](https://docs.rs/lol_html/1.2.1/lol_html/struct.MemorySettings.html) to learn more about the default values.
203
203
 
204
+ ### Literal `<` in text is always escaped
205
+
206
+ Whenever a sanitizer is in use, a literal `<` in text is emitted as `&lt;`. That includes `<textarea>` and `<title>`, where entities are still decoded, but not raw-text elements such as `<style>`, where they are not. This is what stops a stray `<` from fusing with the text after it into a brand new tag once the sanitizer removes whatever sat in between, e.g. `<<script></script>img src=x onerror=alert(1)>`. Your own `handle_text_chunk` handlers still receive the original text, and anything they `replace` takes precedence. An unterminated trailing token (`foo <!-- `, `foo <img src=x`) is dropped rather than passed through, since emitted verbatim it would keep parsing into whatever page the fragment is embedded in.
207
+
204
208
  ## Benchmarks
205
209
 
206
210
  When `bundle exec rake benchmark`, two different benchmarks are calculated. Here are those results on my machine.
data/ext/selma/Cargo.toml CHANGED
@@ -13,6 +13,7 @@ rb-sys = { version = "*", default-features = false, features = [
13
13
  "stable-api-compiled-fallback",
14
14
  ] }
15
15
  lol_html = "3.0"
16
+ memchr = "2"
16
17
 
17
18
  [lib]
18
19
  name = "selma"
data/ext/selma/src/lib.rs CHANGED
@@ -7,6 +7,7 @@ pub mod html;
7
7
  pub mod native_ref_wrap;
8
8
  pub mod rewriter;
9
9
  pub mod sanitizer;
10
+ pub mod scan;
10
11
  pub mod selector;
11
12
  pub mod tags;
12
13
 
@@ -1,8 +1,8 @@
1
1
  use lol_html::{
2
- doc_comments, doctype, element,
2
+ doc_comments, doc_text, doctype,
3
3
  html_content::{Element, TextChunk},
4
- text, DocumentContentHandlers, ElementContentHandlers, HtmlRewriter, MemorySettings, Selector,
5
- Settings,
4
+ DocumentContentHandlers, ElementContentHandlers, HandlerResult, HtmlRewriter, MemorySettings,
5
+ Selector, Settings,
6
6
  };
7
7
  use magnus::{
8
8
  function, gc, method,
@@ -21,16 +21,34 @@ use std::{
21
21
  ops::Deref,
22
22
  primitive::str,
23
23
  rc::Rc,
24
+ sync::OnceLock,
24
25
  };
25
26
 
26
27
  use crate::{
27
28
  html::{element::SelmaHTMLElement, end_tag::SelmaHTMLEndTag, text_chunk::SelmaHTMLTextChunk},
28
29
  native_ref_wrap::NativeRefWrap,
29
30
  sanitizer::SelmaSanitizer,
31
+ scan,
30
32
  selector::SelmaSelector,
31
33
  tags::Tag,
32
34
  };
33
35
 
36
+ /// `*`, parsed once per process. lol_html's `element!` macro re-parses its CSS on every
37
+ /// call, which is measurable for rewriters that run on many small inputs.
38
+ fn all_elements_selector() -> &'static Selector {
39
+ static SELECTOR: OnceLock<Selector> = OnceLock::new();
40
+ SELECTOR.get_or_init(|| "*".parse().expect("`*` is a valid selector"))
41
+ }
42
+
43
+ fn escapeworthy_selector() -> &'static Selector {
44
+ static SELECTOR: OnceLock<Selector> = OnceLock::new();
45
+ SELECTOR.get_or_init(|| {
46
+ Tag::ESCAPEWORTHY_TAGS_CSS
47
+ .parse()
48
+ .expect("the escapeworthy tag list is valid CSS")
49
+ })
50
+ }
51
+
34
52
  #[derive(Clone)]
35
53
  pub struct Handler {
36
54
  rb_handler: Opaque<Value>,
@@ -275,23 +293,37 @@ impl SelmaRewriter {
275
293
  Ok(())
276
294
  }));
277
295
  }
278
- if !sanitizer.get_allow_comments() {
296
+ if sanitizer.get_allow_comments() {
297
+ // a no-op handler still makes lol_html tokenize comments, so an
298
+ // unterminated `<!--`, `<!x` or `<?x` at EOF is dropped just like it is
299
+ // when comments are stripped, instead of being flushed raw by the
300
+ // tag-scanner fast path (which would comment out the host page)
301
+ sanitizer_document_content_handlers.push(doc_comments!(|_c| Ok(())));
302
+ } else {
279
303
  sanitizer_document_content_handlers.push(doc_comments!(|c| {
280
304
  sanitizer.remove_comment(c);
281
305
  Ok(())
282
306
  }));
283
307
  }
284
- sanitizer_element_content_handlers.push(element!("*", |el| {
285
- sanitizer.try_remove_element(el);
286
- if el.removed() {
287
- return Ok(());
288
- }
289
- // if it was removed, there are no attributes to sanitize
290
- match sanitizer.sanitize_attributes(el) {
291
- Ok(_) => Ok(()),
292
- Err(err) => Err(err.to_string().into()),
293
- }
294
- }));
308
+ // this must run in the same pass as element removal: by the time the
309
+ // output is re-parsed, a literal `<` next to a removed node has already
310
+ // fused with the text after it (see `SelmaSanitizer::escape_text_chunk`).
311
+ // Registering a text handler makes lol_html materialize every text chunk,
312
+ // so only pay for it when the input has a `<` that can end up in text.
313
+ if scan::has_escapable_lt(html.as_bytes()) {
314
+ sanitizer_document_content_handlers.push(doc_text!(|t| {
315
+ SelmaSanitizer::escape_text_chunk(t);
316
+ Ok(())
317
+ }));
318
+ }
319
+ sanitizer_element_content_handlers.push(Self::element_handler(
320
+ all_elements_selector(),
321
+ |el| {
322
+ sanitizer
323
+ .sanitize_element(el)
324
+ .map_err(|err| err.to_string().into())
325
+ },
326
+ ));
295
327
  }
296
328
  };
297
329
 
@@ -305,13 +337,7 @@ impl SelmaRewriter {
305
337
  html,
306
338
  ) {
307
339
  Ok(rewritten_html) => match &binding.sanitizer {
308
- None => match String::from_utf8(rewritten_html) {
309
- Ok(output) => Ok(output),
310
- Err(err) => Err(magnus::Error::new(
311
- Ruby::get().unwrap().exception_runtime_error(),
312
- format!("{err:?}"),
313
- )),
314
- },
340
+ None => Self::utf8(rewritten_html),
315
341
  Some(sanitizer) => {
316
342
  Self::perform_final_sanitization(self, sanitizer, rewritten_html)
317
343
  }
@@ -327,30 +353,52 @@ impl SelmaRewriter {
327
353
  sanitizer: &SelmaSanitizer,
328
354
  html: Vec<u8>,
329
355
  ) -> Result<String, magnus::Error> {
330
- // TODO: this should ideally be done ahead of time on `initialize`, not on every `#rewrite` call
331
- let mut element_content_handlers: Vec<(Cow<Selector>, ElementContentHandlers)> = vec![];
356
+ // the only handler in this pass matches a fixed list of tag names, so a full
357
+ // re-parse is pointless unless the output can actually contain one of them
358
+ if !sanitizer.get_escape_tagfilter()
359
+ || !scan::has_start_tag(&html, Tag::ESCAPEWORTHY_TAG_NAMES)
360
+ {
361
+ return Self::utf8(html);
362
+ }
332
363
 
333
- if sanitizer.get_escape_tagfilter() {
334
- element_content_handlers.push(element!(Tag::ESCAPEWORTHY_TAGS_CSS, |el| {
335
- let should_remove = sanitizer.allow_element(el);
336
- if should_remove {
337
- sanitizer.force_remove_element(el);
338
- }
364
+ let element_content_handlers = vec![Self::element_handler(escapeworthy_selector(), |el| {
365
+ sanitizer.remove_if_disallowed(el);
339
366
 
340
- Ok(())
341
- }));
342
- }
367
+ Ok(())
368
+ })];
343
369
 
344
- match Self::run_rewrite(self, vec![], element_content_handlers, html.as_slice()) {
345
- Ok(rewritten_html) => match String::from_utf8(rewritten_html) {
346
- Ok(output) => Ok(output),
347
- Err(err) => Err(magnus::Error::new(
348
- Ruby::get().unwrap().exception_runtime_error(),
349
- format!("{err:?}"),
350
- )),
351
- },
352
- Err(err) => Err(err),
353
- }
370
+ Self::run_rewrite(self, vec![], element_content_handlers, html.as_slice())
371
+ .and_then(Self::utf8)
372
+ }
373
+
374
+ /// Pairs a pre-parsed selector with an element handler; see `all_elements_selector`.
375
+ fn element_handler<'h>(
376
+ selector: &'h Selector,
377
+ handler: impl FnMut(&mut Element<'_, '_>) -> HandlerResult + 'h,
378
+ ) -> (Cow<'h, Selector>, ElementContentHandlers<'h>) {
379
+ (
380
+ Cow::Borrowed(selector),
381
+ ElementContentHandlers::default().element(handler),
382
+ )
383
+ }
384
+
385
+ fn text_handler<'h>(
386
+ selector: &'h Selector,
387
+ handler: impl FnMut(&mut TextChunk<'_>) -> HandlerResult + 'h,
388
+ ) -> (Cow<'h, Selector>, ElementContentHandlers<'h>) {
389
+ (
390
+ Cow::Borrowed(selector),
391
+ ElementContentHandlers::default().text(handler),
392
+ )
393
+ }
394
+
395
+ fn utf8(html: Vec<u8>) -> Result<String, magnus::Error> {
396
+ String::from_utf8(html).map_err(|err| {
397
+ magnus::Error::new(
398
+ Ruby::get().unwrap().exception_runtime_error(),
399
+ format!("{err:?}"),
400
+ )
401
+ })
354
402
  }
355
403
 
356
404
  pub fn perform_handler_rewrite<'a>(
@@ -372,10 +420,10 @@ impl SelmaRewriter {
372
420
  let selector = &handler.selector;
373
421
 
374
422
  // TODO: test final raise by simulating errors
375
- if let Some(match_element) = selector.match_element() {
423
+ if let Some(match_element) = selector.element_selector() {
376
424
  let closure_element_stack = element_stack.clone();
377
425
 
378
- element_content_handlers.push(element!(match_element, move |el| {
426
+ element_content_handlers.push(Self::element_handler(match_element, move |el| {
379
427
  match Self::process_element_handlers(
380
428
  handler,
381
429
  el,
@@ -387,10 +435,10 @@ impl SelmaRewriter {
387
435
  }));
388
436
  }
389
437
 
390
- if let Some(match_text_within) = selector.match_text_within() {
438
+ if let Some(match_text_within) = selector.text_selector() {
391
439
  let closure_element_stack = element_stack.clone();
392
440
 
393
- element_content_handlers.push(text!(match_text_within, move |text| {
441
+ element_content_handlers.push(Self::text_handler(match_text_within, move |text| {
394
442
  let element_stack = closure_element_stack.as_ref().borrow();
395
443
  // check if current tag is a tag we should be ignoring text within;
396
444
  // also checks if tag is within an ancestery of ignored tags
@@ -408,27 +456,31 @@ impl SelmaRewriter {
408
456
  }
409
457
 
410
458
  // we need to check *every* element we iterate over, to create a stack of elements
411
- element_content_handlers.push(element!("*", move |el| {
412
- let tag_name = el.tag_name().to_lowercase();
459
+ element_content_handlers.push(Self::element_handler(
460
+ all_elements_selector(),
461
+ move |el| {
462
+ // lol_html already lowercases the name
463
+ let tag_name = el.tag_name();
464
+
465
+ // no need to track self-closing tags
466
+ if Tag::tag_from_tag_name(&tag_name).self_closing {
467
+ return Ok(());
468
+ };
413
469
 
414
- // no need to track self-closing tags
415
- if Tag::tag_from_tag_name(&tag_name).self_closing {
416
- return Ok(());
417
- };
470
+ element_stack.as_ref().borrow_mut().push(tag_name);
418
471
 
419
- element_stack.as_ref().borrow_mut().push(tag_name);
472
+ let closure_element_stack = element_stack.clone();
420
473
 
421
- let closure_element_stack = element_stack.clone();
474
+ let handler: lol_html::EndTagHandler<'static> = Box::new(move |_end_tag| {
475
+ closure_element_stack.as_ref().borrow_mut().pop();
476
+ Ok(())
477
+ });
478
+ // ignore void elements (lol_html's void list may differ from selma's `self_closing`)
479
+ let _ = el.on_end_tag(handler);
422
480
 
423
- let handler: lol_html::EndTagHandler<'static> = Box::new(move |_end_tag| {
424
- closure_element_stack.as_ref().borrow_mut().pop();
425
481
  Ok(())
426
- });
427
- // ignore void elements (lol_html's void list may differ from selma's `self_closing`)
428
- let _ = el.on_end_tag(handler);
429
-
430
- Ok(())
431
- }));
482
+ },
483
+ ));
432
484
  });
433
485
 
434
486
  Self::run_rewrite(
@@ -467,6 +519,10 @@ impl SelmaRewriter {
467
519
  ));
468
520
  }
469
521
  }
522
+ // deliberately no `rewriter.end()`: lol_html would flush whatever token it is
523
+ // still holding at EOF *verbatim*, which turns a half-open `<img src=x onerror=..`
524
+ // into live markup once the fragment is embedded in a page; dropping an
525
+ // unterminated trailing token is the safe choice for a sanitizer
470
526
  }
471
527
  Ok(output)
472
528
  }
@@ -1,8 +1,8 @@
1
- use std::{borrow::BorrowMut, collections::HashMap};
1
+ use std::{borrow::Cow, collections::HashMap, sync::OnceLock};
2
2
 
3
3
  use lol_html::{
4
4
  errors::AttributeNameError,
5
- html_content::{Comment, ContentType, Doctype, Element, EndTag},
5
+ html_content::{Comment, ContentType, Doctype, Element, EndTag, TextChunk},
6
6
  };
7
7
  use magnus::{
8
8
  eval, function, method,
@@ -15,11 +15,19 @@ use magnus::{
15
15
  #[derive(Clone, Debug, Default)]
16
16
  struct ElementSanitizer {
17
17
  allowed_attrs: Vec<String>,
18
- required_attrs: Vec<String>,
19
18
  allowed_classes: Vec<String>,
20
19
  protocol_sanitizers: HashMap<String, Vec<String>>,
21
20
  }
22
21
 
22
+ impl ElementSanitizer {
23
+ /// Shared stand-in for elements the config never mentions, so lookups at rewrite
24
+ /// time never insert anything and the config stays immutable once built.
25
+ fn empty() -> &'static ElementSanitizer {
26
+ static EMPTY: OnceLock<ElementSanitizer> = OnceLock::new();
27
+ EMPTY.get_or_init(ElementSanitizer::default)
28
+ }
29
+ }
30
+
23
31
  #[derive(Clone)]
24
32
  pub struct Sanitizer {
25
33
  flags: [u8; crate::tags::Tag::TAG_COUNT],
@@ -33,9 +41,11 @@ pub struct Sanitizer {
33
41
  config: Opaque<RHash>,
34
42
  }
35
43
 
44
+ /// The config is fully built in `new` and never changes afterwards, so there is no
45
+ /// interior mutability here: every rewrite-time method only reads.
36
46
  #[derive(Clone)]
37
47
  #[magnus::wrap(class = "Selma::Sanitizer")]
38
- pub struct SelmaSanitizer(std::cell::RefCell<Sanitizer>);
48
+ pub struct SelmaSanitizer(Sanitizer);
39
49
 
40
50
  impl SelmaSanitizer {
41
51
  const SELMA_SANITIZER_ALLOW: u8 = (1 << 0);
@@ -65,16 +75,10 @@ impl SelmaSanitizer {
65
75
  }
66
76
  };
67
77
 
78
+ // only elements the config actually mentions get an entry; everything else
79
+ // resolves to `ElementSanitizer::empty()` at rewrite time
68
80
  let mut element_sanitizers = HashMap::new();
69
81
 
70
- // TODO: set up default tags; do we need this?
71
- crate::tags::Tag::html_tags().iter().for_each(|html_tag| {
72
- let element_name = crate::tags::Tag::element_name_from_enum(html_tag).to_string();
73
-
74
- let element_sanitizer = ElementSanitizer::default();
75
- element_sanitizers.insert(element_name, element_sanitizer);
76
- });
77
-
78
82
  // def allow_attribute(element, attrs)
79
83
  // attrs.flatten.each { |attr| set_allowed_attribute(element, attr, true) }
80
84
  // end
@@ -172,7 +176,7 @@ impl SelmaSanitizer {
172
176
  None => true,
173
177
  };
174
178
 
175
- Ok(Self(std::cell::RefCell::new(Sanitizer {
179
+ Ok(Self(Sanitizer {
176
180
  flags,
177
181
  allowed_attrs: sanitizer_allowed_attrs,
178
182
  allowed_classes: sanitizer_allowed_classes,
@@ -182,7 +186,7 @@ impl SelmaSanitizer {
182
186
  allow_comments,
183
187
  allow_doctype,
184
188
  config: config.into(),
185
- })))
189
+ }))
186
190
  }
187
191
 
188
192
  fn setup_config(
@@ -276,10 +280,9 @@ impl SelmaSanitizer {
276
280
  }
277
281
 
278
282
  fn get_config(&self) -> Result<RHash, magnus::Error> {
279
- let binding = self.0.borrow();
280
283
  let ruby = Ruby::get().unwrap();
281
284
 
282
- Ok(ruby.get_inner(binding.config))
285
+ Ok(ruby.get_inner(self.0.config))
283
286
  }
284
287
 
285
288
  /// Toggle a sanitizer option on or off.
@@ -317,7 +320,7 @@ impl SelmaSanitizer {
317
320
  }
318
321
 
319
322
  pub fn escape_tagfilter(&self, e: &mut Element) -> bool {
320
- if self.0.borrow().escape_tagfilter {
323
+ if self.0.escape_tagfilter {
321
324
  let tag = crate::tags::Tag::tag_from_element(e);
322
325
  if crate::tags::Tag::is_tag_escapeworthy(tag) {
323
326
  e.remove();
@@ -329,11 +332,30 @@ impl SelmaSanitizer {
329
332
  }
330
333
 
331
334
  pub fn get_escape_tagfilter(&self) -> bool {
332
- self.0.borrow().escape_tagfilter
335
+ self.0.escape_tagfilter
333
336
  }
334
337
 
335
338
  pub fn get_allow_comments(&self) -> bool {
336
- self.0.borrow().allow_comments
339
+ self.0.allow_comments
340
+ }
341
+
342
+ /// A `<` that the tokenizer classified as text (because the character after it
343
+ /// cannot start a tag, e.g. `<<b>` or `< b`) is otherwise passed through verbatim.
344
+ /// If the sanitizer then removes the node that follows it, the text on either side
345
+ /// joins up and re-tokenizes as markup.
346
+ /// Escaping `<` in every context that decodes entities keeps
347
+ /// text as text regardless of what gets removed around it. Raw-text contexts
348
+ /// (`<script>`, `<style>`, ...) are skipped: entities are not decoded there, so
349
+ /// escaping would corrupt the content rather than protect it.
350
+ pub fn escape_text_chunk(text_chunk: &mut TextChunk) {
351
+ if !text_chunk.text_type().allows_html_entities() {
352
+ return;
353
+ }
354
+
355
+ if text_chunk.as_str().contains('<') {
356
+ let escaped = text_chunk.as_str().replace('<', "&lt;");
357
+ text_chunk.set_str(escaped);
358
+ }
337
359
  }
338
360
 
339
361
  pub fn remove_comment(&self, c: &mut Comment) {
@@ -342,7 +364,7 @@ impl SelmaSanitizer {
342
364
 
343
365
  /// Whether or not to keep HTML doctype.
344
366
  pub fn get_allow_doctype(&self) -> bool {
345
- self.0.borrow().allow_doctype
367
+ self.0.allow_doctype
346
368
  }
347
369
 
348
370
  pub fn remove_doctype(&self, d: &mut Doctype) {
@@ -355,7 +377,7 @@ impl SelmaSanitizer {
355
377
  allow_list: RArray,
356
378
  ) {
357
379
  let ruby = Ruby::get().unwrap();
358
- let protocol_sanitizers = &mut element_sanitizer.protocol_sanitizers.borrow_mut();
380
+ let protocol_sanitizers = &mut element_sanitizer.protocol_sanitizers;
359
381
 
360
382
  for allowed_protocol in allow_list.into_iter() {
361
383
  let protocol_list = protocol_sanitizers.get_mut(&attr_name);
@@ -397,92 +419,95 @@ impl SelmaSanitizer {
397
419
  }
398
420
  }
399
421
 
400
- pub fn sanitize_attributes(&self, element: &mut Element) -> Result<(), AttributeNameError> {
401
- let tag = crate::tags::Tag::tag_from_element(element);
402
- let tag_name = &element.tag_name();
403
- let element_sanitizer = {
404
- let mut binding = self.0.borrow_mut();
405
- let element_sanitizers = &mut binding.element_sanitizers;
406
- Self::get_element_sanitizer(element_sanitizers, tag_name).clone()
407
- };
422
+ /// Everything the sanitizer does to one element, with a single tag lookup: remove it
423
+ /// (and, depending on the config, its contents) when it is not allowed, otherwise
424
+ /// filter and re-escape its attributes.
425
+ pub fn sanitize_element(&self, element: &mut Element) -> Result<(), AttributeNameError> {
426
+ // `tag_name()` allocates, so take it once and derive everything else from it
427
+ let name = element.tag_name();
428
+ let tag = crate::tags::Tag::tag_from_tag_name(&name);
429
+
430
+ self.try_remove_element(element, tag);
431
+ if element.removed() {
432
+ // nothing left to sanitize
433
+ return Ok(());
434
+ }
408
435
 
409
- let binding = self.0.borrow();
436
+ self.sanitize_attributes(element, tag, &name)
437
+ }
410
438
 
411
- // FIXME: This is a hack to get around the fact that we can't borrow
412
- let attribute_map: HashMap<String, String> = element
439
+ fn sanitize_attributes(
440
+ &self,
441
+ element: &mut Element,
442
+ tag: crate::tags::Tag,
443
+ tag_name: &str,
444
+ ) -> Result<(), AttributeNameError> {
445
+ let sanitizer = &self.0;
446
+ let element_sanitizer = sanitizer
447
+ .element_sanitizers
448
+ .get(tag_name)
449
+ .unwrap_or_else(|| ElementSanitizer::empty());
450
+
451
+ // the attribute list cannot be iterated while the element is being mutated, so
452
+ // take a snapshot. Every occurrence of a duplicated name is evaluated in order;
453
+ // a rejected occurrence removes the attribute outright.
454
+ let attributes: Vec<(String, String)> = element
413
455
  .attributes()
414
456
  .iter()
415
457
  .map(|a| (a.name(), a.value()))
416
458
  .collect();
417
459
 
418
- for (attr_name, attr_val) in attribute_map.iter() {
460
+ for (attr_name, attr_val) in &attributes {
419
461
  // you can actually embed <!-- ... --> inside
420
462
  // an HTML tag to pass malicious data. If this is
421
463
  // encountered, remove the entire element to be safe.
422
464
  if attr_name.starts_with("<!--") {
423
- Self::force_remove_element(self, element);
465
+ Self::force_remove_element(element, tag);
424
466
  return Ok(());
425
467
  }
426
468
 
427
- // first, trim leading spaces and unescape any encodings
469
+ // first, trim leading spaces and unescape any encodings (an entity always
470
+ // starts with `&`, so a value without one is already unescaped)
428
471
  let trimmed = attr_val.trim_start();
429
- let x = escapist::unescape_html(trimmed.as_bytes());
430
- let unescaped_attr_val = String::from_utf8_lossy(&x).to_string();
472
+ let unescaped_attr_val: Cow<str> = if trimmed.contains('&') {
473
+ let bytes = escapist::unescape_html(trimmed.as_bytes());
474
+ Cow::Owned(
475
+ String::from_utf8(bytes)
476
+ .unwrap_or_else(|err| String::from_utf8_lossy(err.as_bytes()).into_owned()),
477
+ )
478
+ } else {
479
+ Cow::Borrowed(trimmed)
480
+ };
431
481
 
432
- let should_keep_attrubute = match Self::should_keep_attribute(
433
- &binding,
482
+ let keep = Self::should_keep_attribute(
483
+ sanitizer,
434
484
  element,
435
- &element_sanitizer,
485
+ element_sanitizer,
436
486
  attr_name,
437
487
  &unescaped_attr_val,
438
- ) {
439
- Ok(should_keep) => should_keep,
440
- Err(e) => {
441
- return Err(e);
442
- }
443
- };
488
+ )?;
444
489
 
445
- if !should_keep_attrubute {
490
+ if !keep {
446
491
  element.remove_attribute(attr_name);
447
- } else {
448
- // Prevent the use of `<meta>` elements that set a charset other than UTF-8,
449
- // since output is always UTF-8.
450
- if crate::tags::Tag::is_meta(tag) {
451
- if attr_name == "charset" && unescaped_attr_val != "utf-8" {
452
- match element.set_attribute(attr_name, "utf-8") {
453
- Ok(_) => {}
454
- Err(err) => {
455
- return Err(err);
456
- }
457
- }
458
- }
459
- } else if !unescaped_attr_val.is_empty() {
460
- let mut buf = String::new();
461
- // ...then, escape any special characters, for security
462
- if attr_name == "href" {
463
- escapist::escape_href(&mut buf, unescaped_attr_val.as_str()).unwrap();
464
- } else {
465
- escapist::escape_html(&mut buf, unescaped_attr_val.as_str()).unwrap();
466
- };
467
-
468
- match element.set_attribute(attr_name, &buf) {
469
- Ok(_) => {}
470
- Err(err) => {
471
- return Err(err);
472
- }
473
- }
474
- }
492
+ continue;
475
493
  }
476
- }
477
494
 
478
- let required = &element_sanitizer.required_attrs;
479
- if required.contains(&"*".to_string()) {
480
- return Ok(());
481
- }
482
- for attr in element.attributes().iter() {
483
- let attr_name = &attr.name();
484
- if required.contains(attr_name) {
485
- return Ok(());
495
+ // Prevent the use of `<meta>` elements that set a charset other than UTF-8,
496
+ // since output is always UTF-8.
497
+ if crate::tags::Tag::is_meta(tag) {
498
+ if attr_name == "charset" && unescaped_attr_val != "utf-8" {
499
+ element.set_attribute(attr_name, "utf-8")?;
500
+ }
501
+ } else if !unescaped_attr_val.is_empty() {
502
+ // ...then, escape any special characters, for security
503
+ let mut buf = String::with_capacity(unescaped_attr_val.len());
504
+ if attr_name == "href" {
505
+ escapist::escape_href(&mut buf, &unescaped_attr_val).unwrap();
506
+ } else {
507
+ escapist::escape_html(&mut buf, &unescaped_attr_val).unwrap();
508
+ };
509
+
510
+ element.set_attribute(attr_name, &buf)?;
486
511
  }
487
512
  }
488
513
 
@@ -493,12 +518,15 @@ impl SelmaSanitizer {
493
518
  binding: &Sanitizer,
494
519
  element: &mut Element,
495
520
  element_sanitizer: &ElementSanitizer,
496
- attr_name: &String,
521
+ attr_name: &str,
497
522
  attr_val: &str,
498
523
  ) -> Result<bool, AttributeNameError> {
499
524
  let mut allowed: bool = false;
500
- let element_allowed_attrs = element_sanitizer.allowed_attrs.contains(attr_name);
501
- let sanitizer_allowed_attrs = binding.allowed_attrs.contains(attr_name);
525
+ let element_allowed_attrs = element_sanitizer
526
+ .allowed_attrs
527
+ .iter()
528
+ .any(|a| a == attr_name);
529
+ let sanitizer_allowed_attrs = binding.allowed_attrs.iter().any(|a| a == attr_name);
502
530
 
503
531
  if element_allowed_attrs {
504
532
  allowed = true;
@@ -547,7 +575,7 @@ impl SelmaSanitizer {
547
575
  }
548
576
 
549
577
  fn has_allowed_protocol(protocols_allowed: &[String], attr_val: &str) -> bool {
550
- if protocols_allowed.contains(&"all".to_string()) {
578
+ if protocols_allowed.iter().any(|p| p == "all") {
551
579
  return true;
552
580
  }
553
581
 
@@ -558,10 +586,18 @@ impl SelmaSanitizer {
558
586
  };
559
587
 
560
588
  match attr_val.as_bytes()[idx] {
561
- b'/' => protocols_allowed.contains(&"/".to_string()),
562
- b'#' => protocols_allowed.contains(&"#".to_string()),
563
- // Allow protocol name to be case-insensitive
564
- _ => protocols_allowed.contains(&attr_val[..idx].to_lowercase()),
589
+ b'/' => protocols_allowed.iter().any(|p| p == "/"),
590
+ b'#' => protocols_allowed.iter().any(|p| p == "#"),
591
+ // Allow protocol name to be case-insensitive (the config side is taken as-is)
592
+ _ => {
593
+ let protocol = &attr_val.as_bytes()[..idx];
594
+ protocols_allowed.iter().any(|p| {
595
+ p.len() == protocol.len()
596
+ && p.bytes()
597
+ .zip(protocol)
598
+ .all(|(allowed, given)| allowed == given.to_ascii_lowercase())
599
+ })
600
+ }
565
601
  }
566
602
  }
567
603
 
@@ -574,8 +610,6 @@ impl SelmaSanitizer {
574
610
  ) -> Result<bool, lol_html::errors::AttributeNameError> {
575
611
  let allowed_global = &binding.allowed_classes;
576
612
 
577
- let mut valid_classes: Vec<String> = vec![];
578
-
579
613
  let allowed_local = &element_sanitizer.allowed_classes;
580
614
 
581
615
  // No class filters, so everything goes through
@@ -583,15 +617,13 @@ impl SelmaSanitizer {
583
617
  return Ok(true);
584
618
  }
585
619
 
586
- let attr_value = attr_val.trim_start();
587
- attr_value
620
+ let valid_classes: Vec<&str> = attr_val
588
621
  .split_whitespace()
589
- .map(|s| s.to_string())
590
- .for_each(|class| {
591
- if allowed_global.contains(&class) || allowed_local.contains(&class) {
592
- valid_classes.push(class);
593
- }
594
- });
622
+ .filter(|class| {
623
+ allowed_global.iter().any(|a| a == class)
624
+ || allowed_local.iter().any(|a| a == class)
625
+ })
626
+ .collect();
595
627
 
596
628
  if valid_classes.is_empty() {
597
629
  return Ok(false);
@@ -603,18 +635,23 @@ impl SelmaSanitizer {
603
635
  }
604
636
  }
605
637
 
606
- pub fn allow_element(&self, element: &mut Element) -> bool {
607
- let tag = crate::tags::Tag::tag_from_element(element);
608
- let flags: u8 = self.0.borrow().flags[tag.index];
609
-
610
- (flags & Self::SELMA_SANITIZER_ALLOW) == 0
638
+ fn is_disallowed(&self, tag: crate::tags::Tag) -> bool {
639
+ (self.0.flags[tag.index] & Self::SELMA_SANITIZER_ALLOW) == 0
611
640
  }
612
641
 
613
- pub fn try_remove_element(&self, element: &mut Element) -> bool {
642
+ /// The final pass only needs to know whether a (possibly handler-inserted or fused)
643
+ /// element from the tagfilter list is allowed, and remove it outright if not.
644
+ pub fn remove_if_disallowed(&self, element: &mut Element) {
614
645
  let tag = crate::tags::Tag::tag_from_element(element);
615
- let flags: u8 = self.0.borrow().flags[tag.index];
646
+ if self.is_disallowed(tag) {
647
+ Self::force_remove_element(element, tag);
648
+ }
649
+ }
616
650
 
617
- let should_remove = !element.removed() && self.allow_element(element);
651
+ fn try_remove_element(&self, element: &mut Element, tag: crate::tags::Tag) -> bool {
652
+ let flags: u8 = self.0.flags[tag.index];
653
+
654
+ let should_remove = !element.removed() && self.is_disallowed(tag);
618
655
 
619
656
  if should_remove {
620
657
  if crate::tags::Tag::has_text_content(tag) {
@@ -627,11 +664,11 @@ impl SelmaSanitizer {
627
664
  Self::remove_element(element, tag.self_closing, flags);
628
665
  }
629
666
 
630
- Self::check_if_end_tag_needs_removal(element);
667
+ Self::check_if_end_tag_needs_removal(element, tag);
631
668
  } else {
632
669
  // anything in <iframe> must be removed, if it's kept
633
670
  if crate::tags::Tag::is_iframe(tag) {
634
- if self.0.borrow().flags[tag.index] != 0 {
671
+ if flags != 0 {
635
672
  element.set_inner_content(" ", ContentType::Text);
636
673
  } else {
637
674
  element.set_inner_content("", ContentType::Text);
@@ -662,15 +699,17 @@ impl SelmaSanitizer {
662
699
  }
663
700
  }
664
701
 
665
- pub fn force_remove_element(&self, element: &mut Element) {
666
- let tag = crate::tags::Tag::tag_from_element(element);
667
- let self_closing = tag.self_closing;
668
- Self::remove_element(element, self_closing, Self::SELMA_SANITIZER_REMOVE_CONTENTS);
669
- Self::check_if_end_tag_needs_removal(element);
702
+ fn force_remove_element(element: &mut Element, tag: crate::tags::Tag) {
703
+ Self::remove_element(
704
+ element,
705
+ tag.self_closing,
706
+ Self::SELMA_SANITIZER_REMOVE_CONTENTS,
707
+ );
708
+ Self::check_if_end_tag_needs_removal(element, tag);
670
709
  }
671
710
 
672
- fn check_if_end_tag_needs_removal(element: &mut Element) {
673
- if element.removed() && !crate::tags::Tag::tag_from_element(element).self_closing {
711
+ fn check_if_end_tag_needs_removal(element: &mut Element, tag: crate::tags::Tag) {
712
+ if element.removed() && !tag.self_closing {
674
713
  // ignore void elements (lol_html's void list may differ from selma's `self_closing`)
675
714
  let _ = element.on_end_tag(Box::new(move |end| {
676
715
  Self::remove_end_tag(end);
@@ -0,0 +1,57 @@
1
+ //! Cheap byte scans that decide whether a rewriting pass has any work to do, so the
2
+ //! expensive part (a full lol_html parse, or materializing every text chunk) is only
3
+ //! paid for when it can matter.
4
+
5
+ use memchr::memchr;
6
+
7
+ fn starts_with_ignore_ascii_case(haystack: &[u8], needle: &[u8]) -> bool {
8
+ haystack.len() >= needle.len() && haystack[..needle.len()].eq_ignore_ascii_case(needle)
9
+ }
10
+
11
+ /// Whether any `<` in `html` can end up in an entity-decoding text chunk, which is the
12
+ /// only place `SelmaSanitizer::escape_text_chunk` has work to do.
13
+ ///
14
+ /// In the data state the tokenizer emits a `<` as text exactly when the byte after it
15
+ /// can start neither a tag (ASCII letter), a comment or doctype (`!`), an end tag (`/`),
16
+ /// nor a bogus comment (`?`). Inside `<textarea>` and `<title>` (RCDATA) every `<` is
17
+ /// text, so the presence of either start tag counts as well. Raw-text contexts such as
18
+ /// `<script>` and `<style>` are never escaped and need no consideration here.
19
+ ///
20
+ /// A false positive only costs registering the handler; there are no false negatives.
21
+ pub fn has_escapable_lt(html: &[u8]) -> bool {
22
+ let mut pos = 0;
23
+ while let Some(i) = memchr(b'<', &html[pos..]) {
24
+ let at = pos + i;
25
+ match html.get(at + 1) {
26
+ Some(b'!' | b'/' | b'?') => {}
27
+ Some(c) if c.is_ascii_alphabetic() => {
28
+ let name = &html[at + 1..];
29
+ if starts_with_ignore_ascii_case(name, b"textarea")
30
+ || starts_with_ignore_ascii_case(name, b"title")
31
+ {
32
+ return true;
33
+ }
34
+ }
35
+ // anything else (or nothing at all) after a `<` makes it text
36
+ _ => return true,
37
+ }
38
+ pos = at + 1;
39
+ }
40
+ false
41
+ }
42
+
43
+ /// Whether `html` contains a start tag for any of `names`, compared ASCII
44
+ /// case-insensitively. Matching by prefix over-approximates (`<titlex>` counts for
45
+ /// `title`), which is the safe direction for a gate.
46
+ pub fn has_start_tag(html: &[u8], names: &[&[u8]]) -> bool {
47
+ let mut pos = 0;
48
+ while let Some(i) = memchr(b'<', &html[pos..]) {
49
+ let at = pos + i;
50
+ let name = &html[at + 1..];
51
+ if names.iter().any(|n| starts_with_ignore_ascii_case(name, n)) {
52
+ return true;
53
+ }
54
+ pos = at + 1;
55
+ }
56
+ false
57
+ }
@@ -3,8 +3,9 @@ use magnus::{function, scan_args, Error, Module, Object, RModule, Ruby, Value};
3
3
  #[derive(Clone, Debug)]
4
4
  #[magnus::wrap(class = "Selma::Selector")]
5
5
  pub struct SelmaSelector {
6
- match_element: Option<String>,
7
- match_text_within: Option<String>,
6
+ // parsed once here, so `Rewriter#rewrite` does not re-parse the CSS on every call
7
+ element_selector: Option<lol_html::Selector>,
8
+ text_selector: Option<lol_html::Selector>,
8
9
  ignore_text_within: Option<Vec<String>>,
9
10
  }
10
11
 
@@ -23,25 +24,31 @@ impl SelmaSelector {
23
24
  ));
24
25
  }
25
26
 
26
- // FIXME: not excited about this double parse work (`element!` does it too),
27
- // but at least we can bail ASAP if the CSS is invalid
28
- if let Some(css) = &match_element {
29
- if css.parse::<lol_html::Selector>().is_err() {
30
- return Err(Error::new(
31
- ruby.exception_arg_error(),
32
- format!("Could not parse `match_element` (`{css:?}`) as valid CSS"),
33
- ));
34
- }
35
- }
27
+ let element_selector = match &match_element {
28
+ None => None,
29
+ Some(css) => match css.parse::<lol_html::Selector>() {
30
+ Ok(selector) => Some(selector),
31
+ Err(_) => {
32
+ return Err(Error::new(
33
+ ruby.exception_arg_error(),
34
+ format!("Could not parse `match_element` (`{css:?}`) as valid CSS"),
35
+ ));
36
+ }
37
+ },
38
+ };
36
39
 
37
- if let Some(css) = &match_text_within {
38
- if css.parse::<lol_html::Selector>().is_err() {
39
- return Err(Error::new(
40
- ruby.exception_arg_error(),
41
- format!("Could not parse `match_text_within` (`{css:?}`) as valid CSS",),
42
- ));
43
- }
44
- }
40
+ let text_selector = match &match_text_within {
41
+ None => None,
42
+ Some(css) => match css.parse::<lol_html::Selector>() {
43
+ Ok(selector) => Some(selector),
44
+ Err(_) => {
45
+ return Err(Error::new(
46
+ ruby.exception_arg_error(),
47
+ format!("Could not parse `match_text_within` (`{css:?}`) as valid CSS"),
48
+ ));
49
+ }
50
+ },
51
+ };
45
52
 
46
53
  let ignore_text_within = match rb_ignore_text_within {
47
54
  None => None,
@@ -57,8 +64,8 @@ impl SelmaSelector {
57
64
  };
58
65
 
59
66
  Ok(Self {
60
- match_element,
61
- match_text_within,
67
+ element_selector,
68
+ text_selector,
62
69
  ignore_text_within,
63
70
  })
64
71
  }
@@ -87,12 +94,12 @@ impl SelmaSelector {
87
94
  Ok((match_element, match_text_within, rb_ignore_text_within))
88
95
  }
89
96
 
90
- pub fn match_element(&self) -> Option<&str> {
91
- self.match_element.as_deref()
97
+ pub fn element_selector(&self) -> Option<&lol_html::Selector> {
98
+ self.element_selector.as_ref()
92
99
  }
93
100
 
94
- pub fn match_text_within(&self) -> Option<&str> {
95
- self.match_text_within.as_deref()
101
+ pub fn text_selector(&self) -> Option<&lol_html::Selector> {
102
+ self.text_selector.as_ref()
96
103
  }
97
104
 
98
105
  pub fn ignore_text_within(&self) -> Option<&[String]> {
@@ -208,12 +208,26 @@ impl Tag {
208
208
  pub const ESCAPEWORTHY_TAGS_CSS: &'static str =
209
209
  "title, textarea, style, xmp, iframe, noembed, noframes, script, plaintext";
210
210
 
211
+ /// Byte-level twin of [`Self::ESCAPEWORTHY_TAGS_CSS`], used to decide whether the
212
+ /// final sanitization pass has anything to do before paying for a full re-parse.
213
+ pub const ESCAPEWORTHY_TAG_NAMES: &'static [&'static [u8]] = &[
214
+ b"title",
215
+ b"textarea",
216
+ b"style",
217
+ b"xmp",
218
+ b"iframe",
219
+ b"noembed",
220
+ b"noframes",
221
+ b"script",
222
+ b"plaintext",
223
+ ];
224
+
211
225
  pub fn html_tags() -> Vec<HTMLTag> {
212
226
  all::<HTMLTag>().collect::<Vec<_>>()
213
227
  }
214
228
 
215
229
  pub fn tag_from_element(element: &mut Element) -> Tag {
216
- Self::tag_from_tag_name(element.tag_name().to_lowercase().as_str())
230
+ Self::tag_from_tag_name(&element.tag_name())
217
231
  }
218
232
 
219
233
  pub fn tag_from_tag_name(tag_name: &str) -> Tag {
data/lib/selma/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Selma
4
- VERSION = "0.5.2"
4
+ VERSION = "0.5.3"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: selma
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.2
4
+ version: 0.5.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Garen J. Torikian
@@ -44,6 +44,7 @@ files:
44
44
  - ext/selma/src/native_ref_wrap.rs
45
45
  - ext/selma/src/rewriter.rs
46
46
  - ext/selma/src/sanitizer.rs
47
+ - ext/selma/src/scan.rs
47
48
  - ext/selma/src/selector.rs
48
49
  - ext/selma/src/tags.rs
49
50
  - lib/selma.rb
@@ -84,7 +85,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
84
85
  - !ruby/object:Gem::Version
85
86
  version: '3.4'
86
87
  requirements: []
87
- rubygems_version: 4.0.10
88
+ rubygems_version: 4.0.20
88
89
  specification_version: 4
89
90
  summary: Selma selects and matches HTML nodes using CSS rules. Backed by Rust's lol_html
90
91
  parser.