feat(a11y-core): cross-origin iframe ad denylist — Type A frame-enumeration gate (AXE-3735)#259
feat(a11y-core): cross-origin iframe ad denylist — Type A frame-enumeration gate (AXE-3735)#259chikara1608 wants to merge 5 commits into
Conversation
…ration gate (AXE-3735)
Skip cross-origin ad iframes on a bundled denylist during Type A traversal.
a11y-engine-core builds the ad-hostname Set from its bundled list and injects
it via axe.configure({ crossOriginDenylist }); the fork drops denied frames at
the single frame-enumeration chokepoint (pushUniqueFrameSelector) so they never
enter context.frames under allowedOrigins:'*' — covering both the sync and async
frame paths. The postMessage layer one level down cannot gate on origin (it has
only a Window), so the gate must act at enumeration where the iframe element and
its readable src are still in hand.
The fork is a separate submodule and cannot import a11y-engine-core, so it
carries its own suffix matcher and receives the already-built Set by reference,
mirroring the existing allowedOrigins plumbing exactly.
- public/configure.js: handle spec.crossOriginDenylist -> audit.setCrossOriginDenylist
- base/audit.js: setCrossOriginDenylist + _init propagation (child-frame audits inherit)
- base/context/parse-selector-array.js: gate pushUniqueFrameSelector on axe._audit.crossOriginDenylist
- utils/is-denied-frame-origin.js: suffix matcher (new) + utils barrel export
- build/tasks/esbuild.js: .txt text loader so a11y-engine-core can inline the denylist
Flag-off is byte-identical to main (Set never set, helper returns false).
Related: AXE-3697 (cross-origin B1), AXE-3735 (denylist). Client-side only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude Code PR Review — axe-core side (paired with a11y-engine#2628)Reviewed as the submodule half of the AXE-3735 COI-denylist pair. The full combined review lives on a11y-engine#2628; this comment carries the findings that live in this repo. Overall the Type A gate is clean, minimal, and correctly placed at the single 🔴 High — new test file missing the mandatory
|
…735) - Add the mandatory [a11y-core] submodule tag to the is-denied-frame-origin matcher test (commit-conventions: every axe-core change must be tagged). - Document the deliberate Type A vs B1/C asymmetry in parse-selector-array: denied frames are dropped at enumeration with no not-evaluated marker, by design per spec sections 5 and 5.1. - Add reciprocal KEEP IN SYNC pointer to the duplicated suffix matcher so it cannot silently drift from a11y-engine-core/lib/commons/coi-denylist.js. Comment/tag-only; no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
chikara1608
left a comment
There was a problem hiding this comment.
Claude Code Review (automated) — 3 inline finding(s). Full report in the PR comment below. Verdict: Failed - see PR comment.
| // frame is removed at enumeration — before any node/result exists to annotate — | ||
| // so there is nothing to mark; adding a marker would mean re-introducing the | ||
| // very frame the denylist exists to drop. | ||
| if (isDeniedFrameOrigin(frame, crossOriginDenylist)) { |
There was a problem hiding this comment.
[Critical] Gate is on the secondary enumeration path — the primary full-page path is ungated
This gate only runs inside pushUniqueFrameSelector (explicit frame-path selectors). The Context constructor also enumerates frames via select('frame, iframe', this).forEach(... pushUniqueFrame ...) in context.js:53-56 → createFrameContext, and that path has no denylist check (context.js references the denylist nowhere). That path is what an ordinary full-page axe.run(document) uses, so denied ad iframes are still enumerated and traversed in essentially every real scan. The :62 comment calling this "the single frame-enumeration chokepoint" is false.
Suggestion: Move the check into the shared chokepoint createFrameContext(frame, context), or also gate pushUniqueFrame in context.js (it gets frame.actualNode, the element with .src). Add a Context-level integration test proving a denied frame is absent from context.frames via the full-page path. Fix the misleading comment.
Reviewer: stack:code-reviewer
| if (url.protocol !== 'http:' && url.protocol !== 'https:') { | ||
| return false; | ||
| } | ||
| host = url.hostname.toLowerCase(); |
There was a problem hiding this comment.
[High] Trailing-dot FQDN bypasses the denylist
new URL('https://doubleclick.net./x').hostname returns doubleclick.net. (WHATWG preserves the root-label dot). Exact .has() misses it and the suffix walk yields net. → `` → never doubleclick.net, so a denied origin served with a trailing-dot host is scanned.
Suggestion:
| host = url.hostname.toLowerCase(); | |
| host = url.hostname.toLowerCase().replace(/\.$/, ''); |
Mirror this in the a11y-engine-core isDeniedHostname helper per the KEEP-IN-SYNC contract, and add a shared test vector.
Reviewer: stack:code-reviewer
| // ad-iframe denylist committed in a11y-engine-core/config) as raw | ||
| // strings so the client parses them at scan start with no runtime | ||
| // fetch. Only affects `.txt` imports; JS bundling is unchanged. | ||
| loader: { '.txt': 'text' }, |
There was a problem hiding this comment.
[Medium] .txt text-loader appears to be a no-op in this repo
No .txt import exists anywhere under axe-core lib/. The bundled denylist lives in a11y-engine-core's config/coi-denylist.txt, built by a11y-engine-core's own build.
Suggestion: Confirm whether there's an undocumented cross-repo build dependency (a11y-engine-core invoking axe-core's grunt esbuild task). If so, note it in the PR; otherwise this line is dead config and belongs in the a11y-engine-core build.
Reviewer: stack:code-reviewer
Claude Code PR ReviewPR: #259 • Head: 6fa3cc9 • Reviewers: stack:code-reviewer SummaryAdds the Type A (axe-core fork) half of the AXE-3735 cross-origin-iframe ad denylist: a new Review Table
Findings
Notes (non-blocking): Submodule Verdict: FAIL — Finding 1 (Critical) leaves the primary full-page frame-enumeration path ungated, so the feature does not work for ordinary scans. Move the gate to |
Address PR #259 re-review: - Critical: the COI denylist gate only covered the selector path (pushUniqueFrameSelector), leaving the primary select('frame, iframe') -> pushUniqueFrame path in context.js ungated, so denied ad iframes were still enumerated in ordinary full-page axe.run() scans. Gate pushUniqueFrame too and fix the misleading "single chokepoint" comment. - High: strip a trailing root-label dot before matching so a trailing-dot FQDN (doubleclick.net.) cannot bypass the denylist. - Add a Context-level integration test asserting a denied frame is dropped via the tag-based path, plus port/uppercase/trailing-dot matcher cases. - Clarify the esbuild .txt loader comment: a11y-engine-core re-exports this task, so this loader is what bundles config/coi-denylist.txt. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Re-review findings addressed in
|
Strip trailing whitespace lint-staged reintroduced on two pre-existing lines during the prior commit; axe-core's `prettier --check .` (fmt_check CI) flagged it. Whitespace-only, no logic change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
chikara1608
left a comment
There was a problem hiding this comment.
Claude Code Review (automated) — 1 inline finding(s). Full report in the PR comment below. Verdict: Passed.
Claude Code PR ReviewPR: #259 • Head: 1292bca • Reviewers: stack:code-reviewer SummaryAdds the Type A (client-side, axe-core fork) cross-origin iframe ad-denylist gate: both frame-enumeration chokepoints — the primary tag-based path ( Re-review: prior thread verdictsAll three previously-unresolved threads are genuinely addressed in the pushed diff (verified, not merely claimed):
Review Table
Findings
Advisory notes (non-blocking)
Verdict: PASS — all prior blocking items genuinely addressed; the only should-fix is the missing selector-path integration test (Medium). Resolve the three GitHub threads once that test lands. |
What
Adds the Type A gate for the Cross-Origin Iframe Ad Denylist (COI denylist). When Type A cross-origin traversal is armed (
allowCrossOriginIframe), cross-origin ad iframes whose origin is on a curated denylist are skipped at frame enumeration, so they are never traversed underallowedOrigins: '*'.The denylist itself lives in a11y-engine-core (bundled
.txt, parsed into aSetat scan start). This fork cannot import a11y-engine-core, so it:Setby reference viaaxe.configure({ crossOriginDenylist })— mirroring the existingallowedOriginsplumbing exactly, andWhy the gate is at enumeration (not postMessage)
frame-messenger/post-message.jshas only aWindow, not the iframe element — "there is no way to know the origin ofwin, so we'll try them all" — andallowedOriginsthere is an allow-list ('*'when Type A COI is on) that cannot express "all except". So the denylist must act one layer up, atContext.framespopulation (pushUniqueFrameSelector), the single enumeration chokepoint that feeds both the sync (collectResultsFromFrames) and async (getFrameContexts) traversal paths — where the iframe element and its readablesrcare still in hand.Changes
lib/core/public/configure.jsspec.crossOriginDenylist→audit.setCrossOriginDenylist(sibling toallowedOrigins)lib/core/base/audit.jssetCrossOriginDenylist()besidesetAllowedOrigins; propagate in_initso child-frame audits inherit itlib/core/base/context/parse-selector-array.jspushUniqueFrameSelectoronaxe._audit.crossOriginDenylist— denied frames never entercontext.frameslib/core/utils/is-denied-frame-origin.js(new)host === entry||host.endsWith('.' + entry)), fail-open; + utils barrel exportbuild/tasks/esbuild.js.txttext loader so a11y-engine-core can inline its bundled denylist (JS bundling unchanged)All fork edits are tagged
// [a11y-critical]/// [a11y-core]per repo convention.Safety
main. When the Type A flag is off,crossOriginDenylistis never set, the helper returnsfalse, and enumeration is unchanged.axe.configure.src/ non-http(s) / srcless /about:blank→ never denied.Testing
test/core/utils/is-denied-frame-origin.js(exact/subdomain match, no false positive on substring hosts likenotdoubleclick.net, flag-off no-op, fail-open).configure,audit,context,get-frame-contexts, and the new helper (233 passing / 5 skipped).Branched off latest
main.Related: AXE-3697 (cross-origin B1), AXE-3735 (COI denylist). Paired with the a11y-engine-core PR that ships the denylist + Type B1/C gates.
Closes: AXE-3735
🤖 Generated with Claude Code