diff --git a/CHANGELOG.md b/CHANGELOG.md index 72134ba4..f8eae222 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -415,6 +415,17 @@ per-file diff commands. lines, six of which fail on master, and ten prose lines with the same words that must stay clean. +- **`posting_guard.py` reads a style attribute the way a browser does and treats a collapsed, + clipped box as hidden** (#709, `tools/posting_guard.py`, `tests/test_posting_guard.py`) - the hidden-style + regex ran on the raw attribute value, so `display:none` (entity-encoded colon) and + `display:/**/none` (a CSS comment inside the declaration) both hid the content in a browser + while the guard showed it under a clean verdict; and a zero-height or zero-width box with + `overflow:hidden`, the other common way to hide a block, was not a hidden style at all. + The value is now entity-decoded and comment-stripped before matching, and a collapsed box + counts as hidden only when both halves are present, because `overflow:hidden` alone is on + most cards and `height:0` alone is a spacer. Three new `Bypasses` cases, seven subtests + failing on master, plus a calibration case pinning that either half alone stays visible. + ### Security - **CI no longer leaves the job token in `.git/config` or expands a PR-controlled name into a diff --git a/tests/test_posting_guard.py b/tests/test_posting_guard.py index c82b697d..8869d3a4 100644 --- a/tests/test_posting_guard.py +++ b/tests/test_posting_guard.py @@ -453,6 +453,28 @@ class Bypasses(GuardFixture): with self.subTest(style=style): self.assert_hidden(f"
Real.
SECRET", "SECRET") + def test_entity_encoded_and_comment_broken_style_values_still_hide(self): + # A browser decodes the attribute and ignores /**/ inside a declaration + # before it parses the CSS; the regex saw neither and showed the text + # under a clean verdict. + for style in ("display:none", "display:/**/none", "visibility:/* */hidden", "opacity:/*x*/0"): + with self.subTest(style=style): + self.assert_hidden(f"Real.
Real.
Real.
Real.
") text = self.text_of(out) diff --git a/tools/posting_guard.py b/tools/posting_guard.py index dedb8ded..49b47301 100644 --- a/tools/posting_guard.py +++ b/tools/posting_guard.py @@ -8,8 +8,10 @@ makes the obvious payload classes visible deterministically, before Step 1 evaluates anything: * hidden HTML (display:none, visibility:hidden, opacity:0, font-size:0, - color:transparent, clip/clip-path, off-screen positioning, the `hidden` - attribute, aria-hidden) - removed from the text; its content is scanned, + color:transparent, clip/clip-path, off-screen positioning, a zero-height + or zero-width box with overflow:hidden, the `hidden` attribute, + aria-hidden; the style value is read the way a browser reads it, with + entities decoded and CSS comments removed) - removed from the text; its content is scanned, and reported as excerpts under `hidden_blocks`. Comments and script/style/noscript/svg/template blocks are removed and scanned too but not listed (every page has them); @@ -202,11 +204,32 @@ def _attrs(raw: str) -> dict: return out +CSS_COMMENT = re.compile(r"/\*.*?\*/", re.S) +# A box collapsed to nothing with its overflow clipped hides its content as +# surely as display:none. Either half alone is everyday layout CSS (overflow: +# hidden is on most cards; height:0 is a spacer), so both must be present. +COLLAPSED_BOX = re.compile(r"\b(?:max-)?(?:height|width)\s*:\s*0(?:\.0+)?(?:px|em|rem|%|pt)?(?![\d.])", re.I) +CLIPPED = re.compile(r"\boverflow(?:-[xy])?\s*:\s*hidden\b", re.I) + + +def _style(attrs: dict) -> str: + """The style attribute as a browser reads it: entities decoded, CSS comments gone. + + A browser decodes `display:none` before parsing the CSS and ignores + `/**/` inside a declaration, so both hid the content while the regex saw + neither. Normalise before matching. + """ + return CSS_COMMENT.sub("", html.unescape(attrs.get("style", ""))) + + def _is_hidden(raw_attrs: str) -> bool: attrs = _attrs(raw_attrs) if "hidden" in attrs or attrs.get("aria-hidden", "").strip().lower() == "true": return True - return bool(HIDDEN_STYLE.search(attrs.get("style", ""))) + style = _style(attrs) + if HIDDEN_STYLE.search(style): + return True + return bool(COLLAPSED_BOX.search(style) and CLIPPED.search(style)) def clean_html(markup: str):