Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions extension/src/rules/__tests__/hidden-text-strip.property.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,89 @@ describe("hidden-text-strip (property)", () => {
);
});

// 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
// a new positional-shaped reason without updating the allowlist set)
// would re-open the bypass; pinning the class as a property catches it.
const POSITIONAL_TRIGGER = fc.constantFrom(
"position: absolute; left: -10000px",
"position: fixed; top: -10000px",
"clip-path: inset(100%)",
"text-indent: -10000px",
);
const NON_POSITIONAL_TRIGGER = fc.constantFrom(
"visibility: hidden",
"opacity: 0",
);
const LANDMARK_TAG = fc.constantFrom(
"nav",
"main",
"header",
"footer",
"aside",
);

it("preserves a positional-hide landmark; strips a non-positional one", () => {
fc.assert(
fc.property(
LANDMARK_TAG,
fc.boolean(),
POSITIONAL_TRIGGER,
NON_POSITIONAL_TRIGGER,
PAYLOAD,
(tag, positional, positionalStyle, nonPositionalStyle, payload) => {
document.body.innerHTML = "";
const target = document.createElement(tag);
target.id = "target";
target.setAttribute(
"style",
positional ? positionalStyle : nonPositionalStyle,
);
target.append(document.createTextNode(payload));
document.body.append(target);

hiddenTextStripRule.apply(document.body);

expect(target.isConnected).toBe(true);
// Positional → landmark allowlist preserves text.
// Non-positional → injection-shaped on a landmark, stripped.
expect(target.textContent === "").toBe(!positional);
},
),
);
});

it("preserves positional hides inside aria-hidden; strips non-positional ones", () => {
fc.assert(
fc.property(
fc.boolean(),
POSITIONAL_TRIGGER,
NON_POSITIONAL_TRIGGER,
PAYLOAD,
(positional, positionalStyle, nonPositionalStyle, payload) => {
document.body.innerHTML = "";
const wrapper = document.createElement("div");
wrapper.setAttribute("aria-hidden", "true");
const target = document.createElement("span");
target.id = "target";
target.setAttribute(
"style",
positional ? positionalStyle : nonPositionalStyle,
);
target.append(document.createTextNode(payload));
wrapper.append(target);
document.body.append(wrapper);

hiddenTextStripRule.apply(document.body);

expect(target.isConnected).toBe(true);
expect(target.textContent === "").toBe(!positional);
},
),
);
});

it("blanks color-matched text and keeps wrapper + descendants attached", () => {
// Color-match uses a different code path than the CSS triggers above —
// it walks ancestors to compute the effective background. Cover it
Expand Down
67 changes: 64 additions & 3 deletions extension/src/rules/__tests__/hidden-text-strip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -436,15 +436,76 @@ describe("hiddenTextStripRule", () => {
expect(document.querySelector("#x")).not.toBeNull();
});

it("preserves text inside aria-hidden=true ancestors", () => {
// aria-hidden subtree allowlist is positional-only: a visibility:hidden
// (or opacity:0 / color-match) child inside an aria-hidden wrapper used
// to bypass the rule because the wrapper's aria-hidden attribute was
// treated as a blanket exemption. Now stripped — aria-hidden suppresses
// the a11y tree but doesn't keep text out of `textContent` where an
// agent reads it.
it("scrubs visibility:hidden text inside an aria-hidden subtree", () => {
document.body.innerHTML = `
<div aria-hidden="true">
<span id="x" style="visibility: hidden">decorative</span>
<span id="x" style="visibility: hidden">${FIXTURES.HIDDEN_IGNORE_PRIOR}</span>
</div>
`;
hiddenTextStripRule.apply(document.body);

expect(document.querySelector("#x")).not.toBeNull();
expectScrubbed("#x");
});

// Counterpart: aria-hidden subtree still preserves a positional hide
// (off-screen) — the SR-only / decorative-icon idiom commonly uses
// aria-hidden + off-screen positioning together.
it("preserves off-screen text inside an aria-hidden subtree", () => {
document.body.innerHTML = `
<div aria-hidden="true">
<span id="x" style="position: absolute; left: -10000px">decorative offscreen label</span>
</div>
`;
hiddenTextStripRule.apply(document.body);

expect(document.querySelector("#x")?.textContent).toContain("decorative");
});

// Landmark allowlist is positional-only too: a NAV with
// visibility:hidden text on the landmark element itself used to be a
// bypass.
it("scrubs visibility:hidden text on a landmark element", () => {
document.body.innerHTML = `
<nav id="x" style="visibility: hidden">${FIXTURES.HIDDEN_IGNORE_PRIOR}</nav>
`;
hiddenTextStripRule.apply(document.body);

expectScrubbed("#x");
});

it("scrubs color-matched text on a landmark element", () => {
document.body.innerHTML = `
<section style="background-color: rgb(255, 255, 255)">
<aside id="x" style="color: rgb(255, 255, 255); background-color: rgb(255, 255, 255)">${FIXTURES.HIDDEN_SMUGGLED}</aside>
</section>
`;
hiddenTextStripRule.apply(document.body);

expectScrubbed("#x");
});

it("scrubs opacity:0 text on a landmark element", () => {
document.body.innerHTML = `
<header id="x" style="opacity: 0">${FIXTURES.HIDDEN_IGNORE_PRIOR}</header>
`;
hiddenTextStripRule.apply(document.body);

expectScrubbed("#x");
});

it("scrubs visibility:hidden text on an element with role=navigation", () => {
document.body.innerHTML = `
<div id="x" role="navigation" style="visibility: hidden">${FIXTURES.HIDDEN_IGNORE_PRIOR}</div>
`;
hiddenTextStripRule.apply(document.body);

expectScrubbed("#x");
});

it("leaves display:none text alone (collapsed menu / tab panel pattern)", () => {
Expand Down
59 changes: 45 additions & 14 deletions extension/src/rules/hidden-text-strip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,15 +96,18 @@ function isExcludedAncestor(element: Element): boolean {
return false;
}

// HTML5 landmarks + ARIA landmark roles. These elements are load-bearing for
// page structure and accessibility, and sites routinely position them
// HTML5 landmarks + ARIA landmark roles. Sites routinely position these
// off-screen as "skip to content" affordances or keyboard-shortcut help
// menus (e.g., Amazon's `<nav id="shortcut-menu">` at `left: -10000px`).
// Stripping a landmark wipes out a chunk of the a11y tree, and the prose
// inside is almost never injection — agents reading the a11y tree treat the
// content as navigation anyway. We allowlist the element itself, not the
// subtree: an injection-shaped descendant inside a landmark is still
// strippable.
// menus (e.g., Amazon's `<nav id="shortcut-menu">` at `left: -10000px`),
// so the off-screen-positioning idiom inside a landmark gets preserved
// regardless of size. This is a narrower allowlist than it used to be:
// landmarks no longer protect against `visibility: hidden`, `opacity: 0`,
// or color-matched text inside the landmark element — those shapes
// aren't load-bearing for a11y and an attacker setting them on a `<nav>`
// is still injection-shaped. The allowlist is per-element: an
// injection-shaped descendant inside a landmark is still strippable
// (a 600px-wide off-left DIV inside a NAV is not "the landmark"
// itself).
const LANDMARK_TAGS: ReadonlySet<string> = new Set([
"NAV",
"MAIN",
Expand All @@ -128,6 +131,26 @@ function isLandmark(element: Element): boolean {
return role !== null && LANDMARK_ROLES.has(role);
}

// Match reasons whose only signal is "the element is positioned out of
// the visible viewport / clipped to zero area". These are the shapes a
// landmark or aria-hidden subtree legitimately uses to keep content
// available to assistive tech without painting it; we allowlist
// landmarks and aria-hidden subtrees against these reasons only.
//
// Conversely, `visibility-hidden`, `opacity-0`, `font-size-0`, and
// `color-match` are paint-mode hides — text remains in DOM textContent
// and in the a11y tree (visibility:hidden does suppress the a11y tree
// for assistive tech, but it doesn't suppress text agents reading
// `textContent`, which is the threat model). A landmark or aria-hidden
// subtree using one of those is bypassing the rule by exploiting its
// own allowlist, so we strip.
const POSITIONAL_HIDE_REASONS: ReadonlySet<string> = new Set([
"offscreen-left",
"offscreen-top",
"text-indent",
"clip-to-zero",
]);

function parsePixelLength(value: string): number | null {
const match = /^(-?\d+(?:\.\d+)?)px$/.exec(value);
if (!match?.[1]) {
Expand Down Expand Up @@ -533,15 +556,9 @@ function findCandidates(root: ParentNode): Candidate[] {
if (isInsidePlaceholder(element)) {
continue;
}
if (element.closest('[aria-hidden="true"]')) {
continue;
}
if (hasSrOnlyClass(element)) {
continue;
}
if (isLandmark(element)) {
continue;
}
if (isExcludedAncestor(element)) {
continue;
}
Expand All @@ -557,6 +574,20 @@ function findCandidates(root: ParentNode): Candidate[] {
if (!match) {
continue;
}
// Landmark + aria-hidden subtree allowlists apply only to positional
// hide reasons (off-screen position, text-indent, clip-to-zero) —
// the shapes a11y patterns legitimately use to keep content
// available to assistive tech. visibility:hidden / opacity:0 /
// color-match inside a landmark or aria-hidden subtree is
// injection-shaped and gets stripped.
if (POSITIONAL_HIDE_REASONS.has(match.reason)) {
if (isLandmark(element)) {
continue;
}
if (element.closest('[aria-hidden="true"]')) {
continue;
}
}
matches.push({ element, ...match });
}
return filterToOutermost(matches, (c) => c.element);
Expand Down
Loading