Skip to content
Open
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
5 changes: 5 additions & 0 deletions build/tasks/esbuild.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
16 changes: 16 additions & 0 deletions lib/core/base/audit.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>} crossOriginDenylist
*/
setCrossOriginDenylist(crossOriginDenylist) {
this.crossOriginDenylist = crossOriginDenylist;
}
/**
* Initializes the rules and checks
*/
Expand All @@ -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 = {};
Expand Down
18 changes: 17 additions & 1 deletion lib/core/base/context/parse-selector-array.js
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
Expand Down Expand Up @@ -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);
Expand Down
8 changes: 8 additions & 0 deletions lib/core/public/configure.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
1 change: 1 addition & 0 deletions lib/core/utils/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
54 changes: 54 additions & 0 deletions lib/core/utils/is-denied-frame-origin.js
Original file line number Diff line number Diff line change
@@ -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<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;
}
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;
}
61 changes: 61 additions & 0 deletions test/core/utils/is-denied-frame-origin.js
Original file line number Diff line number Diff line change
@@ -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,<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));
});
});
Loading