-
Notifications
You must be signed in to change notification settings - Fork 3
feat(a11y-core): cross-origin iframe ad denylist — Type A frame-enumeration gate (AXE-3735) #259
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
4ac2a87
8e3e3ab
6fa3cc9
ca9234e
1292bca
766845f
14a87f5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,9 @@ | ||
| import { createFrameContext } from './create-frame-context'; | ||
| import { getNodeFromTree, shadowSelectAll } from '../../utils'; | ||
| import { | ||
| getNodeFromTree, | ||
| isDeniedFrameOrigin, | ||
| shadowSelectAll | ||
| } from '../../utils'; | ||
|
|
||
| /** | ||
| * Finds frames in context, converts selectors to Element references and pushes unique frames | ||
|
|
@@ -56,7 +60,30 @@ function pushUniqueFrameSelector(context, type, selectorArray) { | |
|
|
||
| const frameSelector = selectorArray.shift(); | ||
| const frames = shadowSelectAll(frameSelector); | ||
| // [a11y-critical]: read the COI ad denylist once for this selector group. | ||
| // Undefined unless a11y-engine-core armed Type A cross-origin and injected it | ||
| // via axe.configure — off => enumeration is byte-identical to main. | ||
| const crossOriginDenylist = axe._audit && axe._audit.crossOriginDenylist; | ||
| frames.forEach(frame => { | ||
| // Skip ad iframes on the denylist BEFORE they enter context.frames, so they | ||
| // are never enumerated or traversed. This is the selector-path chokepoint | ||
| // (multi-element frame selectors); its twin is pushUniqueFrame in context.js, | ||
| // which gates the tag-based select('frame, iframe') path. BOTH must gate — a | ||
| // frame dropped here but not there would be re-added by the other path. The | ||
| // iframe element and its readable src are still in hand here — the postMessage | ||
| // layer one level down cannot gate on origin. | ||
| // | ||
| // BY DESIGN (spec §5 table + §5.1): Type A drops the denied frame here with a | ||
| // bare `return`, so it never enters context.frames and produces NO | ||
| // not-evaluated marker. This is intentionally asymmetric with Type B1/C | ||
| // (a11y-engine-core), which route denied frames through markFrameNotEvaluated | ||
| // to preserve the "checked-clean vs could-not-check" distinction. In Type A the | ||
| // 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)) { | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Critical] Gate is on the secondary enumeration path — the primary full-page path is ungated This gate only runs inside Suggestion: Move the check into the shared chokepoint Reviewer: stack:code-reviewer
chikara1608 marked this conversation as resolved.
|
||
| return; | ||
| } | ||
| let frameContext = context.frames.find(result => result.node === frame); | ||
| if (!frameContext) { | ||
| frameContext = createFrameContext(frame, context); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| // [a11y-core]: Cross-Origin Iframe ad denylist (COI denylist) — Type A gate. | ||
| // | ||
| // a11y-engine-core builds the ad-hostname Set from its bundled config/coi-denylist.txt | ||
| // and hands it to the fork via axe.configure({ crossOriginDenylist }) -> | ||
| // audit.setCrossOriginDenylist (mirroring allowedOrigins). This fork is a separate | ||
| // submodule and CANNOT import a11y-engine-core, so it carries its own copy of the | ||
| // tiny suffix matcher here. When the Type A cross-origin flag is off, the Set is | ||
| // never set and this returns false, keeping frame enumeration byte-identical to main. | ||
| // | ||
| // KEEP IN SYNC: the §1 suffix-match rule below is intentionally duplicated from | ||
| // a11y-engine-core/lib/commons/coi-denylist.js (isDeniedHostname) because this fork | ||
| // cannot import that package. Any change to the match semantics MUST be mirrored | ||
| // there (and its test vectors), and vice versa. The two deliberately differ only in | ||
| // input handling: this fork takes an iframe element and resolves/validates its src | ||
| // (http(s)-only, relative→location.href); the core helper takes a pre-resolved | ||
| // origin/URL string. | ||
|
|
||
| /** | ||
| * True when a frame element's src origin hostname is on the ad denylist | ||
| * (suffix rule: host === entry OR host.endsWith('.' + entry)). Scheme, port and | ||
| * a trailing root-label dot are stripped before compare. srcless / srcdoc / | ||
| * relative / about:blank frames | ||
| * resolve to a same-origin or opaque host that is never on the ad list, so they | ||
| * are never denied. Any parse error fails open (not denied). | ||
| * | ||
| * @param {Element} frame the iframe element (its src is readable even cross-origin) | ||
| * @param {Set<string>} denylistSet ad-hostname Set, or undefined when the flag is off | ||
| * @returns {boolean} | ||
| */ | ||
| export default function isDeniedFrameOrigin(frame, denylistSet) { | ||
| if ( | ||
| !denylistSet || | ||
| typeof denylistSet.has !== 'function' || | ||
| denylistSet.size === 0 | ||
| ) { | ||
| return false; | ||
| } | ||
| let host; | ||
| try { | ||
| const url = new URL(frame.src, window.location.href); | ||
| if (url.protocol !== 'http:' && url.protocol !== 'https:') { | ||
| return false; | ||
| } | ||
| // Strip a trailing root-label dot: new URL('https://doubleclick.net./x') | ||
| // yields hostname 'doubleclick.net.', which would evade both the exact .has() | ||
| // and the suffix walk. Normalize it away so a trailing-dot FQDN still matches. | ||
| host = url.hostname.toLowerCase().replace(/\.$/, ''); | ||
| } catch { | ||
| return false; | ||
| } | ||
| if (!host) { | ||
| return false; | ||
| } | ||
| if (denylistSet.has(host)) { | ||
| return true; | ||
| } | ||
| // Walk parent suffixes: a.b.doubleclick.net -> b.doubleclick.net -> doubleclick.net | ||
| let idx = host.indexOf('.'); | ||
| while (idx !== -1) { | ||
| if (denylistSet.has(host.slice(idx + 1))) { | ||
| return true; | ||
| } | ||
| idx = host.indexOf('.', idx + 1); | ||
| } | ||
| return false; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,83 @@ | ||
| // [a11y-core]: COI denylist Type A gate — matcher tests (AXE-3735). | ||
| describe('axe.utils.isDeniedFrameOrigin', () => { | ||
| const { isDeniedFrameOrigin } = axe.utils; | ||
| const denylist = new Set(['doubleclick.net', 'adnxs.com']); | ||
|
|
||
| it('is false when no denylist Set is provided (flag off => byte-identical)', () => { | ||
| assert.isFalse( | ||
| isDeniedFrameOrigin({ src: 'https://ads.doubleclick.net/x' }) | ||
| ); | ||
| assert.isFalse( | ||
| isDeniedFrameOrigin({ src: 'https://ads.doubleclick.net/x' }, undefined) | ||
| ); | ||
| }); | ||
|
|
||
| it('is false for an empty denylist Set', () => { | ||
| assert.isFalse( | ||
| isDeniedFrameOrigin({ src: 'https://ads.doubleclick.net/x' }, new Set()) | ||
| ); | ||
| }); | ||
|
|
||
| it('denies an exact-origin frame', () => { | ||
| assert.isTrue( | ||
| isDeniedFrameOrigin({ src: 'https://doubleclick.net/tag' }, denylist) | ||
| ); | ||
| }); | ||
|
|
||
| it('denies a subdomain frame (dot-boundary suffix)', () => { | ||
| assert.isTrue( | ||
| isDeniedFrameOrigin({ src: 'https://a.b.adnxs.com/px' }, denylist) | ||
| ); | ||
| }); | ||
|
|
||
| it('does NOT deny a non-dot-boundary substring host (no false positive)', () => { | ||
| assert.isFalse( | ||
| isDeniedFrameOrigin({ src: 'https://notdoubleclick.net/x' }, denylist) | ||
| ); | ||
| }); | ||
|
|
||
| it('does NOT deny an unrelated origin', () => { | ||
| assert.isFalse( | ||
| isDeniedFrameOrigin({ src: 'https://example.com/x' }, denylist) | ||
| ); | ||
| }); | ||
|
|
||
| it('denies despite an explicit port (port is stripped)', () => { | ||
| assert.isTrue( | ||
| isDeniedFrameOrigin({ src: 'https://doubleclick.net:8080/x' }, denylist) | ||
| ); | ||
| }); | ||
|
|
||
| it('denies an uppercase host (case-insensitive)', () => { | ||
| assert.isTrue( | ||
| isDeniedFrameOrigin({ src: 'https://ADS.DOUBLECLICK.NET/x' }, denylist) | ||
| ); | ||
| }); | ||
|
|
||
| it('denies a trailing-dot FQDN (root-label dot stripped)', () => { | ||
| assert.isTrue( | ||
| isDeniedFrameOrigin({ src: 'https://doubleclick.net./x' }, denylist) | ||
| ); | ||
| assert.isTrue( | ||
| isDeniedFrameOrigin({ src: 'https://ads.doubleclick.net./x' }, denylist) | ||
| ); | ||
| }); | ||
|
|
||
| it('is false for non-http(s) / srcless / about:blank frames (never denied)', () => { | ||
| assert.isFalse(isDeniedFrameOrigin({ src: '' }, denylist)); | ||
| assert.isFalse(isDeniedFrameOrigin({ src: 'about:blank' }, denylist)); | ||
| assert.isFalse( | ||
| isDeniedFrameOrigin({ src: 'data:text/html,<b>x</b>' }, denylist) | ||
| ); | ||
| }); | ||
|
|
||
| it('fails open on an unparseable src', () => { | ||
| assert.isFalse(isDeniedFrameOrigin({ src: 'http://' }, denylist)); | ||
| }); | ||
|
|
||
| it('works on a real iframe element', () => { | ||
| const iframe = document.createElement('iframe'); | ||
| iframe.src = 'https://ads.doubleclick.net/pagead'; | ||
| assert.isTrue(isDeniedFrameOrigin(iframe, denylist)); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Medium]
.txttext-loader appears to be a no-op in this repoNo
.txtimport exists anywhere under axe-corelib/. The bundled denylist lives in a11y-engine-core'sconfig/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