fix(posting-guard): read style values like a browser and treat a collapsed, clipped box as hidden (#709)

* fix(posting-guard): read style values like a browser and treat a collapsed, clipped box as hidden

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. 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.

Entity-decode and comment-strip the value before matching, and count a
collapsed box as hidden only when both halves are present: overflow:
hidden alone is on most cards and height:0 alone is a spacer. Three new
Bypasses cases (seven subtests fail on master) plus a calibration case
pinning that either half alone stays visible.

* changelog: place the #709 entry under [Unreleased]

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Mads Lorentzen <madslorentzen_17@hotmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-21 07:19:47 +02:00
1 parent e608daf80c
commit b67eb9e05a
3 files changed
+59 -3

No files matched your search

+11
View File
@@ -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 lines, six of which fail on master, and ten prose lines with the same words that must stay
clean. 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&#58;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 ### Security
- **CI no longer leaves the job token in `.git/config` or expands a PR-controlled name into a - **CI no longer leaves the job token in `.git/config` or expands a PR-controlled name into a
+22
View File
@@ -453,6 +453,28 @@ class Bypasses(GuardFixture):
with self.subTest(style=style): with self.subTest(style=style):
self.assert_hidden(f"<p>Real.</p><span style='{style}'>SECRET</span>", "SECRET") self.assert_hidden(f"<p>Real.</p><span style='{style}'>SECRET</span>", "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&#58;none", "display:/**/none", "visibility&#x3a;/* */hidden", "opacity:/*x*/0"):
with self.subTest(style=style):
self.assert_hidden(f"<p>Real.</p><div style=\"{style}\">SECRET</div>", "SECRET")
def test_collapsed_box_with_clipped_overflow_hides(self):
for style in ("height:0;overflow:hidden", "max-height: 0px; overflow: hidden", "width:0;overflow-x:hidden"):
with self.subTest(style=style):
self.assert_hidden(f"<p>Real.</p><div style='{style}'>SECRET</div>", "SECRET")
def test_either_half_of_a_collapsed_box_alone_is_layout_not_hiding(self):
# overflow:hidden is on most cards and height:0 is a spacer; neither
# alone hides text, and treating them as hidden would flag every page.
for style in ("overflow:hidden", "height:0", "height:0.5em;overflow:hidden", "max-height:200px;overflow:hidden"):
with self.subTest(style=style):
code, out, err = self.run_main("--html", "--json", stdin_text=f"<p>Real.</p><div style='{style}'>Visible.</div>")
self.assertIn("Visible.", self.text_of(out), style)
self.assertEqual(self.findings_of(out)["hidden_blocks"], [], style)
def test_doctype_and_xml_prolog_are_not_text(self): def test_doctype_and_xml_prolog_are_not_text(self):
code, out, err = self.run_main("--html", stdin_text="<!DOCTYPE html><?xml version='1.0'?><p>Real.</p>") code, out, err = self.run_main("--html", stdin_text="<!DOCTYPE html><?xml version='1.0'?><p>Real.</p>")
text = self.text_of(out) text = self.text_of(out)
+26 -3
View File
@@ -8,8 +8,10 @@ makes the obvious payload classes visible deterministically, before Step 1
evaluates anything: evaluates anything:
* hidden HTML (display:none, visibility:hidden, opacity:0, font-size:0, * hidden HTML (display:none, visibility:hidden, opacity:0, font-size:0,
color:transparent, clip/clip-path, off-screen positioning, the `hidden` color:transparent, clip/clip-path, off-screen positioning, a zero-height
attribute, aria-hidden) - removed from the text; its content is scanned, 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 and reported as excerpts under `hidden_blocks`. Comments and
script/style/noscript/svg/template blocks are removed and scanned too but script/style/noscript/svg/template blocks are removed and scanned too but
not listed (every page has them); not listed (every page has them);
@@ -202,11 +204,32 @@ def _attrs(raw: str) -> dict:
return out 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&#58;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: def _is_hidden(raw_attrs: str) -> bool:
attrs = _attrs(raw_attrs) attrs = _attrs(raw_attrs)
if "hidden" in attrs or attrs.get("aria-hidden", "").strip().lower() == "true": if "hidden" in attrs or attrs.get("aria-hidden", "").strip().lower() == "true":
return 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): def clean_html(markup: str):