Fix: extend hidden-text-strip with six additional CSS hide paths#206
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
1 issue found.
About Unblocked
Unblocked has been set up to automatically review your team's pull requests to identify genuine bugs and issues.
📖 Documentation — Learn more in our docs.
💬 Ask questions — Mention @unblocked to request a review or summary, or ask follow-up questions.
👍 Give feedback — React to comments with 👍 or 👎 to help us improve.
⚙️ Customize — Adjust settings in your preferences.
| const FILTER_PROPERTY_PATTERN = /\b(?:filter|all)\b/; | ||
| const TRANSFORM_PROPERTY_PATTERN = /\b(?:transform|all)\b/; | ||
| const COLLAPSE_PROPERTY_PATTERN = /\b(?:max-height|height|all)\b/; |
There was a problem hiding this comment.
Because - is not a \w character, \b creates a word boundary at the hyphen in compound CSS property names. This means:
FILTER_PROPERTY_PATTERN(\bfilter\b) matchesfilterinsidebackdrop-filter. An attacker can addtransition: backdrop-filter 999sto suppress detection offilter: opacity(0).COLLAPSE_PROPERTY_PATTERN(\bheight\b) matchesheightinsideline-height. An attacker can addtransition: line-height 999sto suppress detection ofmax-height: 0; overflow: hidden.
Both allow the animation-in-flight guard to fire on an unrelated property, bypassing the hidden-text strip entirely.
Fix by using a CSS-property-aware boundary instead of \b. For example, using a negative lookbehind for [a-z-]:
const FILTER_PROPERTY_PATTERN = /(?<![a-z-])(?:filter|all)(?![a-z-])/;
const COLLAPSE_PROPERTY_PATTERN = /(?<![a-z-])(?:max-height|height|all)(?![a-z-])/;OPACITY_PROPERTY_PATTERN and TRANSFORM_PROPERTY_PATTERN happen to be safe today because no standard CSS property ends with -opacity or -transform, but applying the same fix to all four would be defensive.
There was a problem hiding this comment.
Confirmed and fixed in c301765. Verified the bypass first:
> /\b(?:filter|all)\b/.test("backdrop-filter") // true
> /\b(?:max-height|height|all)\b/.test("line-height") // true
Switched all four property patterns to (?<![a-z-])…(?![a-z-]) per your suggestion — even the two that are safe today (OPACITY, TRANSFORM) since the boundary should be one consistent thing across the file. Added two regression tests pinning the concrete bypasses (transition: backdrop-filter 999s no longer suppresses the filter: opacity(0) strip; transition: line-height 999s no longer suppresses the max-height: 0 strip). Thanks for the careful read.
— Claude Code, on behalf of @twschiller
Closes one item from the red-team audit in #203 (High, S). Adds detection for the CSS hide paths flagged in the audit that previously slipped past `detectHiddenByCss`: - `-webkit-text-fill-color: transparent` (gated on direct text + not `background-clip: text` so gradient-text headlines stay readable) - `filter: opacity(0)` including chains like `brightness(1) opacity(0)` - `mask-image: linear-gradient(transparent, transparent)` and `mask:` shorthand / `-webkit-mask-image` variants - `transform: scale(0)` (and `scaleX`/`scaleY`/`scale3d` / matrix forms) - `content-visibility: hidden` - `max-height: 0` + `overflow: hidden` on non-1×1 elements Each animatable trigger gets the same animation-in-flight guard as the existing `opacity: 0` check so transient modal-open / accordion-collapse states aren't stripped mid-animation. Includes unit cases for each new branch + 4 property tests pinning the classes (every collapsing transform form strips, every non-collapsing one preserves; every all-transparent gradient mask strips, every gradient with an opaque stop preserves). Also includes two cross-cutting fixes that the new branches exposed: - `parseColor` now resolves the `transparent` keyword (jsdom under Jest returns the keyword verbatim instead of normalizing to rgba). - `parsePixelLength` accepts a bare `0` (computed values for zero-length drop the `px` suffix in real browsers and in jsdom-under-Jest). Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
CSS property names contain `-`, which is not a `\w` character, so `\b` finds a word boundary at the hyphen. That made `\bfilter\b` match `filter` inside `backdrop-filter` and `\bheight\b` match inside `line-height` — an attacker could declare `transition: backdrop-filter 999s` or `transition: line-height 999s` to trip the animation-in-flight guard and bypass the strip entirely. Switch all four property patterns to `(?<![a-z-])…(?![a-z-])` per reviewer suggestion. `OPACITY` and `TRANSFORM` were safe today (no standard CSS property ends in `-opacity` or `-transform`) but use the same boundary for consistency. Regression tests pin both concrete bypasses. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Generalize the two concrete bypass regression tests (`transition: backdrop-filter` no longer suppresses `filter: opacity(0)`; `transition: line-height` no longer suppresses `max-height: 0`) into a property test that exercises eight CSS property names containing each guard keyword as a substring: `-webkit-filter`, `min-height`, `transform- origin`, `transform-style`, `fill-opacity`, `stroke-opacity`, plus the two from the unit tests. A future tweak that drops the hyphen-aware boundary back to `\b` (or adds a new guard keyword without the same boundary) can't quietly re-open the bypass class — the property fails on the first regression. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
c301765 to
e2760ce
Compare
Summary
Closes one item from the red-team audit in #203 (High, S).
Six CSS hide paths flagged in the audit slipped past
detectHiddenByCss. This PR adds detection for each, with the same animation-in-flight guard as the existingopacity: 0check so transient modal-open / accordion-collapse states aren't stripped mid-animation.-webkit-text-fill-color: transparentbackground-clip: textso gradient-text headlines (the standard gradient-headline idiom) stay readable.filter: opacity(0)brightness(1) opacity(0%)match. Guards againsttransition: filter.mask-image: linear-gradient(transparent, transparent)mask-imagelonghand, the-webkit-longhand, and themask:shorthand. Strict parser — rejects gradients with any opaque stop.transform: scale(0)(andscaleX/scaleY/scale3d/matrix forms)transition: transform.content-visibility: hiddenautois a perf hint that stays visible; onlyhiddenqualifies.max-height: 0+overflow: hiddentransition: max-heightis in flight (accordion).Notes for reviewers
parseColornow resolves thetransparentkeyword. Real browsers normalize the computed value torgba(0, 0, 0, 0)but jsdom under Jest returns the keyword verbatim, and the regex parser fell through to null.parsePixelLengthaccepts a bare0. Real browsers (and jsdom under Jest) drop thepxsuffix for any zero-valued length, so the strict^…px$regex rejectedmax-height: 0.-webkit-mask-imageunit test was omitted — jsdom doesn't reflect that property at all (webkitMaskImagereads back as""). The production code still covers it because Chromium aliases the-webkit-longhand to the standard getter, and themask:shorthand test exercises the sameisFullyTransparentMaskcode path.background-image:+background-clip: text+-webkit-text-fill-color: transparent. jsdom does reflectbackground-clip: textcorrectly as long as the shorthandbackground:isn't used.Test plan
bun run test -- hidden-text-strip— 65 cases green (41 existing + 20 new unit + 4 new property).bun run preflight— codegen + Biome + ESLint + typecheck + knip + Jest 1651/1651 green.content-visibility: autoandmax-height:0 without overflownegative cases.🤖 Generated with Claude Code