diff --git a/build/tasks/esbuild.js b/build/tasks/esbuild.js index 546c9317e..a358986ff 100644 --- a/build/tasks/esbuild.js +++ b/build/tasks/esbuild.js @@ -56,6 +56,11 @@ module.exports = function (grunt) { minify: false, format: 'esm', bundle: true, + // [a11y-core]: inline bundled plain-text data assets (e.g. the COI + // 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' }, plugins: [fingerprintFallback] }) .then(done) diff --git a/lib/core/base/audit.js b/lib/core/base/audit.js index 3353545af..092963b95 100644 --- a/lib/core/base/audit.js +++ b/lib/core/base/audit.js @@ -180,6 +180,18 @@ export default class Audit { } } } + /** + * [a11y-core]: Store the cross-origin iframe ad denylist Set (COI denylist). + * Built by a11y-engine-core from its bundled list and injected via + * axe.configure({ crossOriginDenylist }); consumed at frame enumeration + * (pushUniqueFrameSelector) to skip denied ad frames. Stored by reference — + * never copied — so every hop points at the same Set. + * + * @param {Set} crossOriginDenylist + */ + setCrossOriginDenylist(crossOriginDenylist) { + this.crossOriginDenylist = crossOriginDenylist; + } /** * Initializes the rules and checks */ @@ -195,6 +207,10 @@ export default class Audit { this.tagExclude = ['experimental', 'deprecated']; this.noHtml = audit.noHtml; this.allowedOrigins = audit.allowedOrigins; + // [a11y-core]: carry the COI denylist Set across re-init (mirrors + // allowedOrigins) so child-frame audits built from the default config + // inherit it. Undefined when no cross-origin flag armed it. + this.crossOriginDenylist = audit.crossOriginDenylist; unpackToObject(audit.rules, this, 'addRule'); unpackToObject(audit.checks, this, 'addCheck'); this.data = {}; diff --git a/lib/core/base/context/parse-selector-array.js b/lib/core/base/context/parse-selector-array.js index 4435664f4..d8af58b69 100644 --- a/lib/core/base/context/parse-selector-array.js +++ b/lib/core/base/context/parse-selector-array.js @@ -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 @@ -48,7 +52,19 @@ 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 by either the sync or async frame path + // (this is the single frame-enumeration chokepoint). The iframe element and + // its readable src are still in hand here — the postMessage layer one level + // down cannot gate on origin. + if (isDeniedFrameOrigin(frame, crossOriginDenylist)) { + return; + } let frameContext = context.frames.find(result => result.node === frame); if (!frameContext) { frameContext = createFrameContext(frame, context); diff --git a/lib/core/public/configure.js b/lib/core/public/configure.js index 1cf73d642..b520bac4d 100644 --- a/lib/core/public/configure.js +++ b/lib/core/public/configure.js @@ -126,6 +126,14 @@ function configure(spec) { audit.setAllowedOrigins(spec.allowedOrigins); } + + // [a11y-critical]: cross-origin iframe ad denylist (COI denylist). Sibling to + // allowedOrigins — a11y-engine-core builds the Set from its bundled list and + // passes it here so frame enumeration can skip denied ad frames. The Set is + // stored by reference (never serialized cross-frame). Absent/off => unchanged. + if (spec.crossOriginDenylist) { + audit.setCrossOriginDenylist(spec.crossOriginDenylist); + } } export default configure; diff --git a/lib/core/utils/index.js b/lib/core/utils/index.js index 3c9b7db27..56b39cc19 100644 --- a/lib/core/utils/index.js +++ b/lib/core/utils/index.js @@ -51,6 +51,7 @@ export { isLabelledShadowDomSelector, isLabelledFramesSelector } from './is-context'; +export { default as isDeniedFrameOrigin } from './is-denied-frame-origin'; // [a11y-core]: COI denylist Type A gate export { default as isHidden } from './is-hidden'; export { default as isHtmlElement } from './is-html-element'; export { default as isNodeInContext } from './is-node-in-context'; diff --git a/lib/core/utils/is-denied-frame-origin.js b/lib/core/utils/is-denied-frame-origin.js new file mode 100644 index 000000000..d84bbe429 --- /dev/null +++ b/lib/core/utils/is-denied-frame-origin.js @@ -0,0 +1,54 @@ +// [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. + +/** + * True when a frame element's src origin hostname is on the ad denylist + * (suffix rule: host === entry OR host.endsWith('.' + entry)). Scheme and port + * 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} 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; + } + host = url.hostname.toLowerCase(); + } 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; +} diff --git a/test/core/utils/is-denied-frame-origin.js b/test/core/utils/is-denied-frame-origin.js new file mode 100644 index 000000000..c3a238092 --- /dev/null +++ b/test/core/utils/is-denied-frame-origin.js @@ -0,0 +1,61 @@ +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('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,x' }, 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)); + }); +});