Skip to content

Commit 28108ab

Browse files
committed
Limit excessive selectors
Reported by @mauriceng98
1 parent ef18872 commit 28108ab

5 files changed

Lines changed: 96 additions & 5 deletions

File tree

docs/src/markdown/about/changelog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ icon: lucide/scroll-text
66
## 2.8.4
77

88
- **FIX**: Fix another inefficient attribute pattern (@mauriceng98).
9+
- **FIX**: Limit total number of selectors processed in a pattern to prevent massive selector requests (@mauriceng98).
910

1011
## 2.8.3
1112

docs/src/markdown/api.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ change.
2626
> [!tip] Getting Proper Namespaces
2727
> The `html5lib` parser provides proper namespaces for HTML5, but `lxml`'s HTML parser will not. If you need
2828
> namespace support for HTML5, consider using `html5lib`.
29-
>
29+
>
3030
> For XML, the `lxml-xml` parser (`xml` for short) will provide proper namespaces. It is generally suggested that
3131
> `lxml-xml` is used to parse XHTML documents to take advantage of namespaces.
3232
@@ -37,6 +37,12 @@ change.
3737
While Soup Sieve access is exposed through Beautiful Soup's API, Soup Sieve's API can always be imported and accessed
3838
directly for more controlled tag selection if needed.
3939

40+
> [!note] Selector Limits
41+
> Starting in 2.8.4, number of selectors in a given pattern are arbitrarily capped at ~8192. This limitation was added
42+
> to prevent cases where impractical, massive selectors.
43+
>
44+
> Some selectors are defined as a series of other pre-defined selectors which also contribute towards the count.
45+
4046
## Flags
4147

4248
### `soupseive.DEBUG`

soupsieve/css_parser.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111

1212
UNICODE_REPLACEMENT_CHAR = 0xFFFD
1313

14+
SELECTOR_LIMIT = 8192
15+
1416
# Simple pseudo classes that take no parameters
1517
PSEUDO_SIMPLE = {
1618
":any-link",
@@ -468,6 +470,13 @@ def __init__(
468470
self.flags = flags
469471
self.debug = self.flags & util.DEBUG
470472
self.custom = {} if custom is None else custom
473+
self.count = 0
474+
475+
def check_count(self) -> None:
476+
"""Check the current selector count."""
477+
478+
if self.count > SELECTOR_LIMIT:
479+
raise ValueError(f'Selector exceeds pseudo-class nesting limit of {SELECTOR_LIMIT}')
471480

472481
def parse_attribute_selector(self, sel: _Selector, m: Match[str], has_selector: bool) -> bool:
473482
"""Create attribute selector from the returned regex match."""
@@ -572,6 +581,9 @@ def parse_pseudo_class_custom(self, sel: _Selector, m: Match[str], has_selector:
572581
).process_selectors(flags=FLG_PSEUDO)
573582
self.custom[pseudo] = selector
574583

584+
self.count += selector.count
585+
self.check_count()
586+
575587
sel.selectors.append(selector)
576588
has_selector = True
577589
return has_selector
@@ -603,34 +615,64 @@ def parse_pseudo_class(
603615
elif pseudo == ':empty':
604616
sel.flags |= ct.SEL_EMPTY
605617
elif pseudo in (':link', ':any-link'):
618+
self.count += CSS_LINK.count
619+
self.check_count()
606620
sel.selectors.append(CSS_LINK)
607621
elif pseudo == ':checked':
622+
self.count += CSS_CHECKED.count
623+
self.check_count()
608624
sel.selectors.append(CSS_CHECKED)
609625
elif pseudo == ':default':
626+
self.count += CSS_DEFAULT.count
627+
self.check_count()
610628
sel.selectors.append(CSS_DEFAULT)
611629
elif pseudo == ':indeterminate':
630+
self.count += CSS_INDETERMINATE.count
631+
self.check_count()
612632
sel.selectors.append(CSS_INDETERMINATE)
613633
elif pseudo == ":disabled":
634+
self.count += CSS_DISABLED.count
635+
self.check_count()
614636
sel.selectors.append(CSS_DISABLED)
615637
elif pseudo == ":enabled":
638+
self.count += CSS_ENABLED.count
639+
self.check_count()
616640
sel.selectors.append(CSS_ENABLED)
617641
elif pseudo == ":required":
642+
self.count += CSS_REQUIRED.count
643+
self.check_count()
618644
sel.selectors.append(CSS_REQUIRED)
619645
elif pseudo == ":muted":
646+
self.count += CSS_MUTED.count
647+
self.check_count()
620648
sel.selectors.append(CSS_MUTED)
621649
elif pseudo == ":open":
650+
self.count += CSS_OPEN.count
651+
self.check_count()
622652
sel.selectors.append(CSS_OPEN)
623653
elif pseudo == ":optional":
654+
self.count += CSS_OPTIONAL.count
655+
self.check_count()
624656
sel.selectors.append(CSS_OPTIONAL)
625657
elif pseudo == ":read-only":
658+
self.count += CSS_READ_ONLY.count
659+
self.check_count()
626660
sel.selectors.append(CSS_READ_ONLY)
627661
elif pseudo == ":read-write":
662+
self.count += CSS_READ_WRITE.count
663+
self.check_count()
628664
sel.selectors.append(CSS_READ_WRITE)
629665
elif pseudo == ":in-range":
666+
self.count += CSS_IN_RANGE.count
667+
self.check_count()
630668
sel.selectors.append(CSS_IN_RANGE)
631669
elif pseudo == ":out-of-range":
670+
self.count += CSS_OUT_OF_RANGE.count
671+
self.check_count()
632672
sel.selectors.append(CSS_OUT_OF_RANGE)
633673
elif pseudo == ":placeholder-shown":
674+
self.count += CSS_PLACEHOLDER_SHOWN.count
675+
self.check_count()
634676
sel.selectors.append(CSS_PLACEHOLDER_SHOWN)
635677
elif pseudo == ':first-child':
636678
sel.nth.append(ct.SelectorNth(1, False, 0, False, False, ct.SelectorList()))
@@ -731,6 +773,8 @@ def parse_pseudo_nth(
731773
else:
732774
# Use default `*|*` for `of S`.
733775
nth_sel = CSS_NTH_OF_S_DEFAULT
776+
self.count += nth_sel.count
777+
self.check_count()
734778
if pseudo_sel == ':nth-child':
735779
sel.nth.append(ct.SelectorNth(s1, var, s2, False, False, nth_sel))
736780
elif pseudo_sel == ':nth-last-child':
@@ -937,6 +981,7 @@ def parse_selectors(
937981
closed = False
938982
relations = [] # type: list[_Selector]
939983
rel_type = ":" + WS_COMBINATOR
984+
count = self.count
940985

941986
# Setup various flags
942987
is_open = bool(flags & FLG_OPEN)
@@ -984,6 +1029,10 @@ def parse_selectors(
9841029
while True:
9851030
key, m = next(iselector)
9861031

1032+
if key not in ('combine', 'pseudo_close'):
1033+
self.count += 1
1034+
self.check_count()
1035+
9871036
# Handle parts
9881037
if key == "at_rule":
9891038
raise NotImplementedError(f"At-rules found at position {m.start(0)}")
@@ -1103,7 +1152,7 @@ def parse_selectors(
11031152
selectors[-1].flags = ct.SEL_PLACEHOLDER_SHOWN
11041153

11051154
# Return selector list
1106-
return ct.SelectorList([s.freeze() for s in selectors], is_not, is_html)
1155+
return ct.SelectorList([s.freeze() for s in selectors], is_not, is_html, self.count - count)
11071156

11081157
def selector_iter(self, pattern: str) -> Iterator[tuple[str, Match[str]]]:
11091158
"""Iterate selector tokens."""

soupsieve/css_types.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -351,24 +351,27 @@ def __getitem__(self, index: int) -> str: # pragma: no cover
351351
class SelectorList(Immutable):
352352
"""Selector list."""
353353

354-
__slots__ = ("selectors", "is_not", "is_html", "_hash")
354+
__slots__ = ("selectors", "is_not", "is_html", "count", "_hash")
355355

356356
selectors: tuple[Selector | SelectorNull, ...]
357357
is_not: bool
358358
is_html: bool
359+
count: int
359360

360361
def __init__(
361362
self,
362363
selectors: Iterable[Selector | SelectorNull] | None = None,
363364
is_not: bool = False,
364-
is_html: bool = False
365+
is_html: bool = False,
366+
count: int = 0,
365367
) -> None:
366368
"""Initialize."""
367369

368370
super().__init__(
369371
selectors=tuple(selectors) if selectors is not None else (),
370372
is_not=is_not,
371-
is_html=is_html
373+
is_html=is_html,
374+
count=count
372375
)
373376

374377
def __iter__(self) -> Iterator[Selector | SelectorNull]:

tests/test_api.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -592,6 +592,38 @@ def test_invalid_type_input_filter(self):
592592
with self.assertRaises(TypeError):
593593
sv.filter('div', "not a tag", flags=flags)
594594

595+
def test_excessive_selectors(self):
596+
"""Test excessive selectors."""
597+
598+
# Build a 500 KB selector string: "a,a,a,...,a" (250,000 items)
599+
count = 10000
600+
selector = ",".join("a" for _ in range(count))
601+
602+
# Compile the selector
603+
with self.assertRaises(ValueError):
604+
sv.compile(selector)
605+
606+
def test_excessive_custom_selectors(self):
607+
"""Test excessive custom selectors."""
608+
609+
# Build a 500 KB selector string: "a,a,a,...,a" (250,000 items)
610+
count = 10000
611+
selector = ",".join("a" for _ in range(count))
612+
613+
# Compile the selector
614+
with self.assertRaises(ValueError):
615+
sv.compile('div:--custom', custom={':--custom': selector})
616+
617+
def test_excessive_custom_and_normal_selectors(self):
618+
"""Test excessive custom and normal selectors."""
619+
620+
count = 5000
621+
selector = ",".join("a" for _ in range(count))
622+
623+
# Compile the selector
624+
with self.assertRaises(ValueError):
625+
sv.compile(f':is({selector}):--custom', custom={':--custom': selector})
626+
595627

596628
class TestSyntaxErrorReporting(util.TestCase):
597629
"""Test reporting of syntax errors."""

0 commit comments

Comments
 (0)