mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-11-02 13:12:50 +00:00
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:
3 files changed
+59
-3
No files matched your search
@@ -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
|
||||
|
||||
@@ -453,6 +453,28 @@ class Bypasses(GuardFixture):
|
||||
with self.subTest(style=style):
|
||||
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:none", "display:/**/none", "visibility:/* */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):
|
||||
code, out, err = self.run_main("--html", stdin_text="<!DOCTYPE html><?xml version='1.0'?><p>Real.</p>")
|
||||
text = self.text_of(out)
|
||||
|
||||
+26
-3
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user