From 56ad9af460872f60a9cc4b5dfab1ac463eeb5539 Mon Sep 17 00:00:00 2001 From: Todd Schiller Date: Sat, 6 Jun 2026 10:19:28 -0400 Subject: [PATCH 1/3] Fix: extend hidden-text-strip with six additional CSS hide paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../hidden-text-strip.property.test.ts | 164 +++++++++++++ .../rules/__tests__/hidden-text-strip.test.ts | 216 +++++++++++++++++ extension/src/rules/hidden-text-strip.ts | 224 +++++++++++++++++- 3 files changed, 601 insertions(+), 3 deletions(-) diff --git a/extension/src/rules/__tests__/hidden-text-strip.property.test.ts b/extension/src/rules/__tests__/hidden-text-strip.property.test.ts index 4931acc..270d0c0 100644 --- a/extension/src/rules/__tests__/hidden-text-strip.property.test.ts +++ b/extension/src/rules/__tests__/hidden-text-strip.property.test.ts @@ -22,6 +22,12 @@ const HIDDEN_STYLE = fc.constantFrom( "font-size: 0", "position: absolute; left: -10000px", "clip-path: inset(100%)", + "-webkit-text-fill-color: transparent", + "filter: opacity(0)", + "mask-image: linear-gradient(transparent, transparent)", + "transform: scale(0)", + "content-visibility: hidden", + "max-height: 0; overflow: hidden; width: 600px", ); const PAYLOAD = fc.constantFrom( @@ -94,6 +100,164 @@ describe("hidden-text-strip (property)", () => { ); }); + // Every shape the matrix → scale check is supposed to recognize as + // "x or y axis collapsed to zero". Unit tests pin one example each; + // the property test pins the whole class so a future tweak to the + // matrix parser doesn't quietly miss `scale3d(_,0,_)` or + // `matrix(_,_,0,0,_,_)` (y-axis zero) and re-open the bypass. + const COLLAPSING_TRANSFORM = fc.oneof( + fc.constant("scale(0)"), + fc.constant("scale(0, 0)"), + fc.constant("scaleX(0)"), + fc.constant("scaleY(0)"), + fc.constant("scale3d(0, 1, 1)"), + fc.constant("scale3d(1, 0, 1)"), + fc.constant("matrix(0, 0, 0, 0, 0, 0)"), + // x-axis zero, y-axis non-zero (non-uniform collapse) + fc.constant("matrix(0, 0, 0, 1, 10, 20)"), + // y-axis zero, x-axis non-zero + fc.constant("matrix(1, 0, 0, 0, 10, 20)"), + // matrix3d with x-axis zero (m11 == 0) + fc.constant("matrix3d(0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)"), + // matrix3d with y-axis zero (m22 == 0) + fc.constant("matrix3d(1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)"), + ); + + it("strips text under any transform whose x or y scale collapses to zero", () => { + fc.assert( + fc.property(COLLAPSING_TRANSFORM, PAYLOAD, (transform, payload) => { + document.body.innerHTML = ""; + const target = document.createElement("div"); + target.id = "target"; + target.setAttribute("style", `transform: ${transform}`); + target.append(document.createTextNode(payload)); + document.body.append(target); + + hiddenTextStripRule.apply(document.body); + + expect(target.isConnected).toBe(true); + expect(target.textContent).toBe(""); + }), + ); + }); + + // The negative half of the same invariant: any transform that leaves + // both x and y scale non-zero must preserve the text. Without this, + // a too-eager matrix parser could regress `transform: scale(0.5)` + // into a strip and corrupt every transformed element on a page. + const NON_COLLAPSING_TRANSFORM = fc.oneof( + fc.constant("none"), + fc.constant("scale(1)"), + fc.constant("scale(0.5)"), + fc.constant("scale(2, 0.5)"), + fc.constant("scaleX(1.5)"), + fc.constant("scaleY(0.25)"), + fc.constant("scale3d(1, 0.5, 1)"), + fc.constant("matrix(1, 0, 0, 1, 0, 0)"), + fc.constant("matrix(0.5, 0, 0, 0.5, 100, 200)"), + fc.constant("rotate(45deg)"), + fc.constant("translate(10px, 20px)"), + ); + + it("preserves text whose transform leaves both axes non-zero", () => { + fc.assert( + fc.property(NON_COLLAPSING_TRANSFORM, (transform) => { + document.body.innerHTML = ""; + const target = document.createElement("div"); + target.id = "target"; + target.setAttribute("style", `transform: ${transform}`); + target.append(document.createTextNode("visible body text")); + document.body.append(target); + + hiddenTextStripRule.apply(document.body); + + expect(target.textContent).toBe("visible body text"); + }), + ); + }); + + // Mask transparency parser must accept any gradient whose color stops + // are all transparent, and reject any gradient with at least one + // opaque stop. The unit tests fix one example each; the property + // tests pin the class so a tightened regex doesn't quietly miss + // `linear-gradient(rgba(0,0,0,0), transparent)` (mixed transparent + // forms) or accept `linear-gradient(transparent, black)` (mixed + // alpha) and either re-open a bypass or strip every fade overlay + // on the page. + const TRANSPARENT_COLOR = fc.constantFrom( + "transparent", + "rgba(0, 0, 0, 0)", + "rgba(255, 255, 255, 0)", + "rgba(128, 64, 200, 0)", + ); + const OPAQUE_COLOR = fc.constantFrom( + "black", + "white", + "red", + "rgba(0, 0, 0, 1)", + "rgb(50, 100, 150)", + "#abcdef", + ); + const GRADIENT_KIND = fc.constantFrom("linear-gradient", "radial-gradient"); + + it("strips text under any gradient mask composed entirely of transparent stops", () => { + fc.assert( + fc.property( + GRADIENT_KIND, + fc.array(TRANSPARENT_COLOR, { minLength: 2, maxLength: 5 }), + PAYLOAD, + (kind, stops, payload) => { + document.body.innerHTML = ""; + const target = document.createElement("div"); + target.id = "target"; + target.setAttribute( + "style", + `mask-image: ${kind}(${stops.join(", ")})`, + ); + target.append(document.createTextNode(payload)); + document.body.append(target); + + hiddenTextStripRule.apply(document.body); + + expect(target.textContent).toBe(""); + }, + ), + ); + }); + + it("preserves text under a gradient mask containing any opaque stop", () => { + fc.assert( + fc.property( + GRADIENT_KIND, + fc.array(TRANSPARENT_COLOR, { minLength: 0, maxLength: 3 }), + fc.array(OPAQUE_COLOR, { minLength: 1, maxLength: 3 }), + fc.boolean(), + (kind, transparentStops, opaqueStops, opaqueFirst) => { + const stops = opaqueFirst + ? [...opaqueStops, ...transparentStops] + : [...transparentStops, ...opaqueStops]; + // A gradient must have at least 2 stops to be valid; pad if needed. + if (stops.length < 2) { + stops.push(opaqueStops[0] ?? "black"); + } + document.body.innerHTML = ""; + const target = document.createElement("div"); + target.id = "target"; + target.setAttribute( + "style", + `mask-image: ${kind}(${stops.join(", ")})`, + ); + target.append(document.createTextNode("visible fade-overlay text")); + document.body.append(target); + + hiddenTextStripRule.apply(document.body); + + expect(target.textContent).toBe("visible fade-overlay text"); + }, + ), + ); + }); + // Cross-product invariant for #203 item #10: landmark + aria-hidden // allowlists kick in only for positional hide reasons. A future tweak // that moves a check back into the unconditional skip block (or adds diff --git a/extension/src/rules/__tests__/hidden-text-strip.test.ts b/extension/src/rules/__tests__/hidden-text-strip.test.ts index 500b146..87ce09d 100644 --- a/extension/src/rules/__tests__/hidden-text-strip.test.ts +++ b/extension/src/rules/__tests__/hidden-text-strip.test.ts @@ -171,6 +171,222 @@ describe("hiddenTextStripRule", () => { expect(document.querySelector("#child")).not.toBeNull(); }); + it("scrubs text with -webkit-text-fill-color: transparent", () => { + document.body.innerHTML = ` + ${FIXTURES.HIDDEN_IGNORE_PRIOR} + `; + hiddenTextStripRule.apply(document.body); + + expectScrubbed("#x"); + }); + + // The standard "gradient text" idiom paints a background gradient and + // clips it to the glyph outlines via `background-clip: text`. The text + // fill must be transparent so the gradient shows through. Stripping + // this would wipe out every gradient-styled headline. + it("preserves gradient-text using background-clip:text + transparent fill", () => { + document.body.innerHTML = ` +

Gradient Headline

+ `; + hiddenTextStripRule.apply(document.body); + + expect(document.querySelector("#x")?.textContent).toBe("Gradient Headline"); + }); + + // text-fill inherits like `color` — a wrapper that sets transparent + // text-fill but whose only text lives in descendants with their own + // override would wipe out the descendants. Mirrors the color-match / + // font-size:0 gate. + it("preserves a -webkit-text-fill-color wrapper whose descendants override", () => { + document.body.innerHTML = ` +
+ visible +
+ `; + hiddenTextStripRule.apply(document.body); + + expect(document.querySelector("#wrapper")).not.toBeNull(); + expect(document.querySelector("#child")?.textContent).toBe("visible"); + }); + + it("scrubs text hidden via filter: opacity(0)", () => { + document.body.innerHTML = ` +
${FIXTURES.HIDDEN_IGNORE_PRIOR}
+ `; + hiddenTextStripRule.apply(document.body); + + expectScrubbed("#x"); + }); + + it("scrubs text in a filter chain containing opacity(0%)", () => { + document.body.innerHTML = ` +
${FIXTURES.HIDDEN_IGNORE_PRIOR}
+ `; + hiddenTextStripRule.apply(document.body); + + expectScrubbed("#x"); + }); + + it("preserves filter:opacity(0) when a filter transition is in flight", () => { + document.body.innerHTML = ` +
${FIXTURES.HIDDEN_IGNORE_PRIOR}
+ `; + hiddenTextStripRule.apply(document.body); + + expect(document.querySelector("#x")).not.toBeNull(); + }); + + it("preserves filter:opacity(0.5) (non-zero)", () => { + document.body.innerHTML = ` +
half-faded but visible
+ `; + hiddenTextStripRule.apply(document.body); + + expect(document.querySelector("#x")?.textContent).toContain("half-faded"); + }); + + it("scrubs text under a fully-transparent mask-image", () => { + document.body.innerHTML = ` +
${FIXTURES.HIDDEN_IGNORE_PRIOR}
+ `; + hiddenTextStripRule.apply(document.body); + + expectScrubbed("#x"); + }); + + // jsdom under Jest doesn't reflect `-webkit-mask-image` style writes + // (`webkitMaskImage` reads back as `""`). The production check still + // covers the `-webkit-` longhand because Chromium aliases it to the + // standard `mask-image` getter, but we can't exercise that surface + // here. The `mask:` shorthand below covers the same code path. + it("scrubs text under a fully-transparent mask shorthand", () => { + document.body.innerHTML = ` +
${FIXTURES.HIDDEN_IGNORE_PRIOR}
+ `; + hiddenTextStripRule.apply(document.body); + + expectScrubbed("#x"); + }); + + it("preserves a mask-image with an opaque color stop", () => { + document.body.innerHTML = ` +
fade-out heading
+ `; + hiddenTextStripRule.apply(document.body); + + expect(document.querySelector("#x")?.textContent).toContain("fade-out"); + }); + + it("scrubs text collapsed by transform: scale(0)", () => { + document.body.innerHTML = ` +
${FIXTURES.HIDDEN_IGNORE_PRIOR}
+ `; + hiddenTextStripRule.apply(document.body); + + expectScrubbed("#x"); + }); + + // Real browsers resolve `scale(0)` to a 2D matrix. jsdom keeps the + // function form, so test both surfaces. + it("scrubs text whose transform resolved to matrix(0, 0, 0, 0, ...)", () => { + document.body.innerHTML = ` +
${FIXTURES.HIDDEN_IGNORE_PRIOR}
+ `; + hiddenTextStripRule.apply(document.body); + + expectScrubbed("#x"); + }); + + it("scrubs text collapsed on a single axis via scaleX(0)", () => { + document.body.innerHTML = ` +
${FIXTURES.HIDDEN_IGNORE_PRIOR}
+ `; + hiddenTextStripRule.apply(document.body); + + expectScrubbed("#x"); + }); + + it("preserves transform:scale(0.5) (visible at half-size)", () => { + document.body.innerHTML = ` +
half-scale heading
+ `; + hiddenTextStripRule.apply(document.body); + + expect(document.querySelector("#x")?.textContent).toContain("half-scale"); + }); + + // Modals / popovers commonly animate `transform: scale(0)` → `scale(1)`. + // The subtree watcher catches them mid-animation; the guard prevents + // those transient states from being stripped — same shape as the + // existing opacity-transition guard. + it("preserves transform:scale(0) when a transform transition is in flight", () => { + document.body.innerHTML = ` +
+ modal contents +
+ `; + hiddenTextStripRule.apply(document.body); + + expect(document.querySelector("#x")).not.toBeNull(); + }); + + it("scrubs text with content-visibility: hidden", () => { + document.body.innerHTML = ` +
${FIXTURES.HIDDEN_IGNORE_PRIOR}
+ `; + hiddenTextStripRule.apply(document.body); + + expectScrubbed("#x"); + }); + + // `content-visibility: auto` is a paint-skip perf hint that stays + // visible whenever the element is on-screen. Only the `hidden` + // keyword permanently suspends rendering. + it("preserves content-visibility: auto", () => { + document.body.innerHTML = ` +
visible perf-hint section
+ `; + hiddenTextStripRule.apply(document.body); + + expect(document.querySelector("#x")?.textContent).toContain("visible"); + }); + + it("scrubs text in a max-height:0 + overflow:hidden block", () => { + document.body.innerHTML = ` +
${FIXTURES.HIDDEN_LARGE_OFFSCREEN}
+ `; + hiddenTextStripRule.apply(document.body); + + expectScrubbed("#x"); + }); + + // Accordions / details disclosures commonly animate `max-height: 0` → + // `max-height: `. The guard prevents those transient states from + // being stripped while the user expands them. + it("preserves max-height:0 collapse when a max-height transition is in flight", () => { + document.body.innerHTML = ` +
+ accordion contents +
+ `; + hiddenTextStripRule.apply(document.body); + + expect(document.querySelector("#x")).not.toBeNull(); + }); + + it("preserves max-height:0 without overflow:hidden (text would overflow)", () => { + document.body.innerHTML = ` +
overflowing visible text
+ `; + hiddenTextStripRule.apply(document.body); + + expect(document.querySelector("#x")?.textContent).toContain("overflowing"); + }); + it("preserves .sr-only text", () => { document.body.innerHTML = ` screen reader hint diff --git a/extension/src/rules/hidden-text-strip.ts b/extension/src/rules/hidden-text-strip.ts index fd5588b..c8ee081 100644 --- a/extension/src/rules/hidden-text-strip.ts +++ b/extension/src/rules/hidden-text-strip.ts @@ -152,6 +152,13 @@ const POSITIONAL_HIDE_REASONS: ReadonlySet = new Set([ ]); function parsePixelLength(value: string): number | null { + // Computed values normally come back with the `px` suffix, but the + // bare literal `0` is what real browsers (and jsdom under Jest) return + // for any length set to zero — `0`, `0px`, even `0%` — because zero + // is the one length that's unit-independent. + if (value === "0") { + return 0; + } const match = /^(-?\d+(?:\.\d+)?)px$/.exec(value); if (!match?.[1]) { return null; @@ -159,6 +166,20 @@ function parsePixelLength(value: string): number | null { return Number.parseFloat(match[1]); } +// Pick the first non-empty value from a list of computed-style reads. +// Vendor-prefix variants (`-webkit-mask-image` vs `mask-image`, the +// `WebkitTextFillColor` capitalization variant, the `mask:` shorthand) +// each return `""` when the spec form isn't set on this element; falling +// through empty strings is the desired behavior, so `??` won't do. +function firstNonEmpty(...values: ReadonlyArray): string { + for (const value of values) { + if (value) { + return value; + } + } + return ""; +} + function isClippedToZero(style: CSSStyleDeclaration): boolean { const clipPath = style.clipPath; if (clipPath === "inset(100%)") { @@ -254,7 +275,10 @@ function shorthandHasNonzeroDuration(shorthand: string): boolean { return false; } -function hasOpacityAnimationInFlight(style: CSSStyleDeclaration): boolean { +function hasPropertyAnimationInFlight( + style: CSSStyleDeclaration, + propertyPattern: RegExp, +): boolean { // Production path: real browsers expand the `transition`/`animation` // shorthand into the longhand getters, so `transitionProperty` is always // populated (default `"all"`) and `transitionDuration` is always `"0s"` for @@ -268,14 +292,14 @@ function hasOpacityAnimationInFlight(style: CSSStyleDeclaration): boolean { // duration check and the shorthand block is never reached. if (style.transitionProperty) { if ( - /\b(?:opacity|all)\b/.test(style.transitionProperty) && + propertyPattern.test(style.transitionProperty) && hasNonzeroDuration(style.transitionDuration) ) { return true; } } else if ( style.transition && - /\b(?:opacity|all)\b/.test(style.transition) && + propertyPattern.test(style.transition) && shorthandHasNonzeroDuration(style.transition) ) { return true; @@ -296,6 +320,105 @@ function hasOpacityAnimationInFlight(style: CSSStyleDeclaration): boolean { return false; } +const OPACITY_PROPERTY_PATTERN = /\b(?:opacity|all)\b/; +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/; + +function hasOpacityAnimationInFlight(style: CSSStyleDeclaration): boolean { + return hasPropertyAnimationInFlight(style, OPACITY_PROPERTY_PATTERN); +} + +// `filter: opacity(N)` is animatable via the `filter` property even though +// the visual effect is identical to `opacity: N`. Skip when there's a +// transition or animation on filter for the same reason `opacity: 0` skips +// when opacity is animating — the text will become visible mid-animation. +const FILTER_OPACITY_ZERO_PATTERN = /\bopacity\(\s*\.?0+(?:\.0+)?%?\s*\)/i; + +function hasZeroOpacityFilter(filter: string): boolean { + if (!filter || filter === "none") { + return false; + } + return FILTER_OPACITY_ZERO_PATTERN.test(filter); +} + +// Treat a mask-image as fully transparent when every color literal in +// the gradient string is `transparent` or an rgba/hsla with alpha 0. +// Strict — the spec for mask-image is large and we'd rather false-negative +// (miss a partial-transparent mask the attacker hand-shaped to hide text) +// than false-positive on a decorative mask with one transparent stop. +const TRANSPARENT_RGBA_PATTERN = + /rgba?\(\s*\d+(?:\.\d+)?\s*,\s*\d+(?:\.\d+)?\s*,\s*\d+(?:\.\d+)?\s*[,/]\s*0(?:\.0+)?\s*\)/gi; +const TRANSPARENT_HSLA_PATTERN = /hsla?\(\s*[^)]*[,/]\s*0(?:\.0+)?\s*\)/gi; +const OPAQUE_COLOR_RESIDUAL_PATTERN = + /rgba?\(|hsla?\(|hwb\(|lab\(|lch\(|oklab\(|oklch\(|color\(|color-mix\(|#[0-9a-f]{3,8}\b|\b(?:white|black|red|green|blue|yellow|cyan|magenta|grey|gray|orange|purple|pink|brown|currentcolor)\b/i; + +function isFullyTransparentMask(value: string): boolean { + if (!value || value === "none") { + return false; + } + if (!/-gradient\(/i.test(value)) { + return false; + } + const residual = value + .replaceAll(TRANSPARENT_RGBA_PATTERN, "") + .replaceAll(TRANSPARENT_HSLA_PATTERN, "") + .replaceAll(/\btransparent\b/gi, ""); + return !OPAQUE_COLOR_RESIDUAL_PATTERN.test(residual); +} + +// True when the element's `transform` collapses 2D extent to zero — +// `scale(0)`, `scaleX(0)`, `scaleY(0)`, `scale3d(0, 0, _)`, or any +// matrix/matrix3d whose x or y scale axis is zero. jsdom keeps the +// function form verbatim, real browsers resolve to `matrix(...)` — +// we handle both. Single-axis collapse still hides text (glyphs need +// both axes to paint). +function isCollapsedTransform(transform: string): boolean { + if (!transform || transform === "none") { + return false; + } + if (/^scale\(\s*0(?:\.0+)?\s*(?:,\s*\d+(?:\.\d+)?\s*)?\)$/.test(transform)) { + return true; + } + if (/^scale\(\s*\d+(?:\.\d+)?\s*,\s*0(?:\.0+)?\s*\)$/.test(transform)) { + return true; + } + if (/^scale[XY]\(\s*0(?:\.0+)?\s*\)$/.test(transform)) { + return true; + } + if (/^scale3d\(\s*0(?:\.0+)?\s*,/.test(transform)) { + return true; + } + if (/^scale3d\(\s*\d+(?:\.\d+)?\s*,\s*0(?:\.0+)?\s*,/.test(transform)) { + return true; + } + const matrix = /^matrix\(([^)]+)\)$/.exec(transform); + if (matrix?.[1]) { + const parts = matrix[1].split(",").map((s) => Number.parseFloat(s.trim())); + if ( + parts.length >= 4 && + (Math.abs(parts[0] ?? 1) < 1e-6 || Math.abs(parts[3] ?? 1) < 1e-6) + ) { + return true; + } + } + const matrix3d = /^matrix3d\(([^)]+)\)$/.exec(transform); + if (matrix3d?.[1]) { + const parts = matrix3d[1] + .split(",") + .map((s) => Number.parseFloat(s.trim())); + // matrix3d is column-major 4×4; scale-x lives at m11 (index 0), + // scale-y at m22 (index 5). + if ( + parts.length >= 6 && + (Math.abs(parts[0] ?? 1) < 1e-6 || Math.abs(parts[5] ?? 1) < 1e-6) + ) { + return true; + } + } + return false; +} + function detectHiddenByCss( element: Element, style: CSSStyleDeclaration, @@ -351,6 +474,94 @@ function detectHiddenByCss( }, }; } + // `-webkit-text-fill-color: transparent` paints glyphs with zero alpha. + // Legitimate gradient-text uses this with `background-clip: text` so a + // background gradient shows through the glyph outlines — skip that + // pattern. Gate on direct text, mirroring color-match / font-size:0: + // descendants can override the inherited property and a wrapper without + // own text doesn't render any pixel via this color. + const textFill = firstNonEmpty( + style.webkitTextFillColor, + (style as { WebkitTextFillColor?: string }).WebkitTextFillColor, + ); + if (textFill && hasOwnDirectText(element)) { + const parsed = parseColor(textFill); + const backgroundClipsToText = + style.backgroundClip === "text" || + (style as { webkitBackgroundClip?: string }).webkitBackgroundClip === + "text"; + if (parsed?.[3] === 0 && !backgroundClipsToText) { + return { + reason: "text-fill-transparent", + details: { webkitTextFillColor: textFill }, + }; + } + } + // `filter: opacity(0)` is the SVG-filter twin of `opacity: 0` and slips + // past a plain `opacity` check. Chains like `brightness(1) opacity(0)` + // match. Skip when filter is mid-animation — modals occasionally + // animate via `transition: filter` for the same fade-in pattern that + // motivates the opacity-transition guard above. + if ( + hasZeroOpacityFilter(style.filter) && + !hasPropertyAnimationInFlight(style, FILTER_PROPERTY_PATTERN) + ) { + return { reason: "filter-opacity-0", details: { filter: style.filter } }; + } + // A fully-transparent mask-image hides every pixel the element paints. + // Check the longhand, the `-webkit-` prefix, and the `mask` shorthand + // (jsdom keeps the shorthand verbatim, real browsers expand to longhand + // — covering both keeps detection consistent across environments). + const maskImage = firstNonEmpty( + style.maskImage, + (style as { webkitMaskImage?: string }).webkitMaskImage, + (style as { mask?: string }).mask, + ); + if (maskImage && isFullyTransparentMask(maskImage)) { + return { reason: "mask-transparent", details: { maskImage } }; + } + // `transform: scale(0)` collapses the rendered box to zero area. Guard + // against mid-animation states (modals scaling in/out are common); a + // permanent scale(0) on a populated subtree is the injection shape. + if ( + isCollapsedTransform(style.transform) && + !hasPropertyAnimationInFlight(style, TRANSFORM_PROPERTY_PATTERN) + ) { + return { + reason: "transform-collapsed", + details: { transform: style.transform }, + }; + } + // `content-visibility: hidden` suspends rendering and lays out the + // element as if it were empty, but textContent and the a11y tree still + // expose the contents. `auto` skips rendering only when off-screen and + // stays visible otherwise — only `hidden` qualifies. + if (style.contentVisibility === "hidden") { + return { + reason: "content-visibility-hidden", + details: { contentVisibility: style.contentVisibility }, + }; + } + // `max-height: 0` + `overflow: hidden` clips the element's vertical + // extent to zero. The 1×1 SR-only envelope is already preserved by + // `hasStructuralSrOnlyPattern` upstream, so anything reaching this + // check is full-size. Common false-positive: an animated accordion + // collapsing — skip when a transition or animation on max-height / + // height is in flight. + const maxHeight = parsePixelLength(style.maxHeight); + if ( + maxHeight === 0 && + (style.overflow === "hidden" || style.overflowY === "hidden") && + !hasPropertyAnimationInFlight(style, COLLAPSE_PROPERTY_PATTERN) + ) { + return { + reason: "max-height-collapsed", + details: { + maxHeight: style.maxHeight, + overflow: firstNonEmpty(style.overflow, style.overflowY), + }, + }; + } return null; } @@ -457,6 +668,13 @@ function parseColorViaCanvas(value: string): RGBA | null { } function parseColor(value: string): RGBA | null { + // The `transparent` keyword resolves to `rgba(0, 0, 0, 0)` per CSS + // Color spec. Real browsers normalize the computed value to the rgba + // form, but jsdom under Jest returns the keyword verbatim — handle + // both so callers don't have to. + if (value.trim().toLowerCase() === "transparent") { + return [0, 0, 0, 0]; + } return parseColorViaRegex(value) ?? parseColorViaCanvas(value); } From 61fbeea6d9d777d2b4bc376af2f2765475842231 Mon Sep 17 00:00:00 2001 From: Todd Schiller Date: Sat, 6 Jun 2026 10:52:51 -0400 Subject: [PATCH 2/3] Address review feedback (round 1): hyphen-aware property boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `(? --- .../rules/__tests__/hidden-text-strip.test.ts | 26 +++++++++++++++++++ extension/src/rules/hidden-text-strip.ts | 18 ++++++++++--- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/extension/src/rules/__tests__/hidden-text-strip.test.ts b/extension/src/rules/__tests__/hidden-text-strip.test.ts index 87ce09d..2dfe179 100644 --- a/extension/src/rules/__tests__/hidden-text-strip.test.ts +++ b/extension/src/rules/__tests__/hidden-text-strip.test.ts @@ -240,6 +240,19 @@ describe("hiddenTextStripRule", () => { expect(document.querySelector("#x")).not.toBeNull(); }); + // CSS property names contain `-`, so a naive `\bfilter\b` would also + // match `backdrop-filter` and let an attacker declare a long + // transition on the unrelated property to suppress detection of + // `filter: opacity(0)`. The hyphen-aware boundary blocks that bypass. + it("still scrubs filter:opacity(0) when a transition targets backdrop-filter", () => { + document.body.innerHTML = ` +
${FIXTURES.HIDDEN_IGNORE_PRIOR}
+ `; + hiddenTextStripRule.apply(document.body); + + expectScrubbed("#x"); + }); + it("preserves filter:opacity(0.5) (non-zero)", () => { document.body.innerHTML = `
half-faded but visible
@@ -378,6 +391,19 @@ describe("hiddenTextStripRule", () => { expect(document.querySelector("#x")).not.toBeNull(); }); + // Same hyphen-boundary bypass as the backdrop-filter case: a naive + // `\bheight\b` matches `height` inside `line-height`, letting an + // attacker suppress the max-height:0 strip via `transition: + // line-height 999s`. The hyphen-aware boundary blocks it. + it("still scrubs max-height:0 when a transition targets line-height", () => { + document.body.innerHTML = ` +
${FIXTURES.HIDDEN_LARGE_OFFSCREEN}
+ `; + hiddenTextStripRule.apply(document.body); + + expectScrubbed("#x"); + }); + it("preserves max-height:0 without overflow:hidden (text would overflow)", () => { document.body.innerHTML = `
overflowing visible text
diff --git a/extension/src/rules/hidden-text-strip.ts b/extension/src/rules/hidden-text-strip.ts index c8ee081..b1530a9 100644 --- a/extension/src/rules/hidden-text-strip.ts +++ b/extension/src/rules/hidden-text-strip.ts @@ -320,10 +320,20 @@ function hasPropertyAnimationInFlight( return false; } -const OPACITY_PROPERTY_PATTERN = /\b(?:opacity|all)\b/; -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/; +// CSS property names contain `-`, which is not a `\w` character, so +// `\b` finds a word boundary at the hyphen. That means `\bfilter\b` +// matches `filter` inside `backdrop-filter`, and `\bheight\b` matches +// inside `line-height` — an attacker can declare `transition: +// backdrop-filter 999s` or `transition: line-height 999s` to trip the +// animation-in-flight guard and bypass the strip. Use a hyphen-aware +// boundary instead. `OPACITY` and `TRANSFORM` are safe today because +// no standard CSS property ends in `-opacity` or `-transform`, but use +// the same boundary for consistency. +const OPACITY_PROPERTY_PATTERN = /(? Date: Sat, 6 Jun 2026 10:58:25 -0400 Subject: [PATCH 3/3] Address review feedback (round 2): property test for hyphen boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../hidden-text-strip.property.test.ts | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/extension/src/rules/__tests__/hidden-text-strip.property.test.ts b/extension/src/rules/__tests__/hidden-text-strip.property.test.ts index 270d0c0..5107c6e 100644 --- a/extension/src/rules/__tests__/hidden-text-strip.property.test.ts +++ b/extension/src/rules/__tests__/hidden-text-strip.property.test.ts @@ -258,6 +258,73 @@ describe("hidden-text-strip (property)", () => { ); }); + // Animation-in-flight property-name boundary. The guard pattern is + // `(?(?![a-z-])` precisely to keep `\bfilter\b` from + // matching `backdrop-filter`, `\bheight\b` from matching `line-height`, + // etc. — those would let an attacker declare a long transition on the + // adjacent property and trip the guard, bypassing the strip. The unit + // tests pin the two known concrete cases; the property pins the class + // so any CSS property name that *contains* a guard keyword but isn't + // the keyword itself still strips the hidden text. + // + // Each trigger is paired with a transition on a "trap" property name + // that shares a substring with the guard keyword. `transition: + // 999s` MUST NOT suppress the strip of the trigger. + const TRIGGER_AND_TRAP_TRANSITION = fc.constantFrom( + // filter:opacity(0) bypass via backdrop-filter / -webkit-filter + { + style: "filter: opacity(0); transition: backdrop-filter 999s", + }, + { + style: "filter: opacity(0); transition: -webkit-filter 999s", + }, + // max-height:0 bypass via line-height / min-height + { + style: + "max-height: 0; overflow: hidden; width: 600px; transition: line-height 999s", + }, + { + style: + "max-height: 0; overflow: hidden; width: 600px; transition: min-height 999s", + }, + // transform:scale(0) bypass via transform-origin / transform-style + { + style: "transform: scale(0); transition: transform-origin 999s", + }, + { + style: "transform: scale(0); transition: transform-style 999s", + }, + // opacity:0 bypass via fill-opacity / stroke-opacity (SVG) + { + style: "opacity: 0; transition: fill-opacity 999s", + }, + { + style: "opacity: 0; transition: stroke-opacity 999s", + }, + ); + + it("still strips when a transition targets a property whose name only contains the guard keyword", () => { + fc.assert( + fc.property( + TRIGGER_AND_TRAP_TRANSITION, + PAYLOAD, + ({ style }, payload) => { + document.body.innerHTML = ""; + const target = document.createElement("div"); + target.id = "target"; + target.setAttribute("style", style); + target.append(document.createTextNode(payload)); + document.body.append(target); + + hiddenTextStripRule.apply(document.body); + + expect(target.isConnected).toBe(true); + expect(target.textContent).toBe(""); + }, + ), + ); + }); + // Cross-product invariant for #203 item #10: landmark + aria-hidden // allowlists kick in only for positional hide reasons. A future tweak // that moves a check back into the unconditional skip block (or adds