Add synchronous DOM hooks to cookie banner resource detector
Wrap script/iframe/img/link/source IDL setters, setAttribute, the four HTML-parsing entry points, fetch, XHR, and sendBeacon so extension stacks are visible at the synchronous call site. Coordinate via an extensionElements WeakSet and an extensionUrls Set so the existing MutationObserver and PerformanceObserver paths drop the same resources. Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
@@ -53,6 +53,30 @@ function mapInitiatorType(it: string): ResourceType | null {
|
||||
}
|
||||
}
|
||||
|
||||
// Attribute names that can carry a resource URL on at least one of the
|
||||
// element types we track. Used as a fast-path filter at the top of the
|
||||
// `setAttribute`/`setAttributeNS` wrap so non-resource calls (e.g.
|
||||
// `setAttribute("class", ...)`) bail before we ever touch the stack.
|
||||
const RESOURCE_ATTRIBUTES: ReadonlySet<string> = new Set([
|
||||
"src",
|
||||
"href",
|
||||
"data",
|
||||
]);
|
||||
|
||||
// SavedDescriptor lets stop() restore the exact (configurable, enumerable,
|
||||
// get, set) shape we replaced via Object.defineProperty.
|
||||
interface SavedDescriptor {
|
||||
target: object;
|
||||
key: string;
|
||||
descriptor: PropertyDescriptor;
|
||||
}
|
||||
|
||||
// Hard cap on the synchronously-marked extension URL set so a malicious
|
||||
// extension cannot grow the SDK's memory footprint indefinitely. FIFO
|
||||
// eviction is fine because the most recent injections are also the most
|
||||
// likely to still be loading and need filtering.
|
||||
const MAX_EXTENSION_URLS = 256;
|
||||
|
||||
export class ResourceDetector implements Detector {
|
||||
private readonly queue: ReportQueue;
|
||||
private readonly pageOrigin: string;
|
||||
@@ -61,6 +85,35 @@ export class ResourceDetector implements Detector {
|
||||
private perfObserver: PerformanceObserver | null = null;
|
||||
private originalSWRegister: typeof ServiceWorkerContainer.prototype.register | null = null;
|
||||
|
||||
// extensionElements is populated synchronously by the property-setter,
|
||||
// setAttribute, and HTML-parsing wraps when the call originates from a
|
||||
// browser-extension stack. The MutationObserver consults it before
|
||||
// reporting, which is the only way we can reliably attribute a
|
||||
// resource insertion to an extension -- the observer's own callback
|
||||
// fires from a browser-internal stack with no extension frame visible.
|
||||
private extensionElements: WeakSet<Element> = new WeakSet();
|
||||
|
||||
// extensionUrls covers the URL-only paths (PerformanceObserver, fetch,
|
||||
// XHR, sendBeacon) where we have no element to key on. It mirrors the
|
||||
// origin+pathname identifier used in processResource so a hit drops
|
||||
// the report without further work. Capped at MAX_EXTENSION_URLS with
|
||||
// FIFO eviction.
|
||||
private extensionUrls: Set<string> = new Set();
|
||||
|
||||
// Saved property descriptors restored by stop().
|
||||
private savedDescriptors: SavedDescriptor[] = [];
|
||||
|
||||
// Saved originals for direct method/global replacements restored by
|
||||
// stop().
|
||||
private originalSetAttribute: typeof Element.prototype.setAttribute | null = null;
|
||||
private originalSetAttributeNS: typeof Element.prototype.setAttributeNS | null = null;
|
||||
private originalInsertAdjacentHTML: typeof Element.prototype.insertAdjacentHTML | null = null;
|
||||
private originalDocWrite: typeof Document.prototype.write | null = null;
|
||||
private originalDocWriteln: typeof Document.prototype.writeln | null = null;
|
||||
private originalFetch: typeof window.fetch | null = null;
|
||||
private originalXHROpen: typeof XMLHttpRequest.prototype.open | null = null;
|
||||
private originalSendBeacon: typeof navigator.sendBeacon | null = null;
|
||||
|
||||
constructor(queue: ReportQueue, apiOrigin: string) {
|
||||
this.queue = queue;
|
||||
this.pageOrigin = location.origin;
|
||||
@@ -74,6 +127,11 @@ export class ResourceDetector implements Detector {
|
||||
this.observePerformance();
|
||||
this.wrapServiceWorker();
|
||||
|
||||
this.wrapElementSrcSetters();
|
||||
this.wrapSetAttribute();
|
||||
this.wrapHTMLParsing();
|
||||
this.wrapNetworkAPIs();
|
||||
|
||||
if (isExtensionContext()) return;
|
||||
|
||||
this.scanExisting();
|
||||
@@ -99,6 +157,59 @@ export class ResourceDetector implements Detector {
|
||||
navigator.serviceWorker.register = this.originalSWRegister;
|
||||
this.originalSWRegister = null;
|
||||
}
|
||||
|
||||
for (const saved of this.savedDescriptors) {
|
||||
try {
|
||||
Object.defineProperty(saved.target, saved.key, saved.descriptor);
|
||||
} catch {
|
||||
// Restoration is best-effort: another piece of code may have
|
||||
// re-defined the same property after we did. We swallow rather
|
||||
// than throw because failing to restore is not a correctness
|
||||
// problem -- our wrap simply keeps running until the page
|
||||
// unloads.
|
||||
}
|
||||
}
|
||||
this.savedDescriptors = [];
|
||||
|
||||
if (this.originalSetAttribute) {
|
||||
Element.prototype.setAttribute = this.originalSetAttribute;
|
||||
this.originalSetAttribute = null;
|
||||
}
|
||||
|
||||
if (this.originalSetAttributeNS) {
|
||||
Element.prototype.setAttributeNS = this.originalSetAttributeNS;
|
||||
this.originalSetAttributeNS = null;
|
||||
}
|
||||
|
||||
if (this.originalInsertAdjacentHTML) {
|
||||
Element.prototype.insertAdjacentHTML = this.originalInsertAdjacentHTML;
|
||||
this.originalInsertAdjacentHTML = null;
|
||||
}
|
||||
|
||||
if (this.originalDocWrite) {
|
||||
Document.prototype.write = this.originalDocWrite;
|
||||
this.originalDocWrite = null;
|
||||
}
|
||||
|
||||
if (this.originalDocWriteln) {
|
||||
Document.prototype.writeln = this.originalDocWriteln;
|
||||
this.originalDocWriteln = null;
|
||||
}
|
||||
|
||||
if (this.originalFetch && typeof window !== "undefined") {
|
||||
window.fetch = this.originalFetch;
|
||||
this.originalFetch = null;
|
||||
}
|
||||
|
||||
if (this.originalXHROpen && typeof XMLHttpRequest !== "undefined") {
|
||||
XMLHttpRequest.prototype.open = this.originalXHROpen;
|
||||
this.originalXHROpen = null;
|
||||
}
|
||||
|
||||
if (this.originalSendBeacon && typeof navigator !== "undefined") {
|
||||
navigator.sendBeacon = this.originalSendBeacon;
|
||||
this.originalSendBeacon = null;
|
||||
}
|
||||
}
|
||||
|
||||
private scanExisting(): void {
|
||||
@@ -115,6 +226,7 @@ export class ResourceDetector implements Detector {
|
||||
for (const mutation of mutations) {
|
||||
for (const node of mutation.addedNodes) {
|
||||
if (!(node instanceof HTMLElement)) continue;
|
||||
if (this.extensionElements.has(node)) continue;
|
||||
|
||||
if (node instanceof HTMLScriptElement && node.src) {
|
||||
this.processResource(node.src, "script");
|
||||
@@ -123,9 +235,11 @@ export class ResourceDetector implements Detector {
|
||||
}
|
||||
|
||||
for (const script of node.querySelectorAll<HTMLScriptElement>("script[src]")) {
|
||||
if (this.extensionElements.has(script)) continue;
|
||||
this.processResource(script.src, "script");
|
||||
}
|
||||
for (const iframe of node.querySelectorAll<HTMLIFrameElement>("iframe[src]")) {
|
||||
if (this.extensionElements.has(iframe)) continue;
|
||||
this.processResource(iframe.src, "iframe");
|
||||
}
|
||||
}
|
||||
@@ -211,7 +325,7 @@ export class ResourceDetector implements Detector {
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(src);
|
||||
parsed = new URL(src, location.href);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
@@ -225,6 +339,546 @@ export class ResourceDetector implements Detector {
|
||||
if (resourceType !== "service_worker" && parsed.origin === this.pageOrigin) return;
|
||||
|
||||
const identifier = parsed.origin + parsed.pathname;
|
||||
if (this.extensionUrls.has(identifier)) return;
|
||||
|
||||
this.queue.reportResource({ url: identifier, resource_type: resourceType });
|
||||
}
|
||||
|
||||
// identifierOf normalises a raw URL string into the same origin+pathname
|
||||
// shape used by processResource, returning null for anything that is
|
||||
// not an http(s) URL. Used by the synchronous wraps to populate
|
||||
// extensionUrls and stay consistent with the value the async paths
|
||||
// will later compare against.
|
||||
private identifierOf(src: string): string | null {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(src, location.href);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
|
||||
return parsed.origin + parsed.pathname;
|
||||
}
|
||||
|
||||
private markExtensionElement(el: Element): void {
|
||||
this.extensionElements.add(el);
|
||||
}
|
||||
|
||||
private markExtensionUrl(id: string | null): void {
|
||||
if (!id) return;
|
||||
if (this.extensionUrls.size >= MAX_EXTENSION_URLS) {
|
||||
const first = this.extensionUrls.values().next().value;
|
||||
if (first !== undefined) this.extensionUrls.delete(first);
|
||||
}
|
||||
this.extensionUrls.add(id);
|
||||
}
|
||||
|
||||
private markExtension(el: Element | null, url: string | null): void {
|
||||
if (el) this.markExtensionElement(el);
|
||||
if (url) this.markExtensionUrl(url);
|
||||
}
|
||||
|
||||
// wrapElementSrcSetters wraps the `src` (or `href` for <link>) IDL
|
||||
// setter on each prototype that loads a tracker-relevant resource.
|
||||
// Because the wrap runs on the caller's synchronous stack,
|
||||
// isExtensionCaller() actually sees the extension frame here -- which
|
||||
// it cannot from MutationObserver/PerformanceObserver. This is the
|
||||
// primary point at which extension attribution becomes reliable.
|
||||
private wrapElementSrcSetters(): void {
|
||||
this.wrapPropertySetter(
|
||||
HTMLScriptElement.prototype,
|
||||
"src",
|
||||
(_el, absolute) => this.processResource(absolute, "script"),
|
||||
);
|
||||
this.wrapPropertySetter(
|
||||
HTMLIFrameElement.prototype,
|
||||
"src",
|
||||
(_el, absolute) => this.processResource(absolute, "iframe"),
|
||||
);
|
||||
this.wrapPropertySetter(
|
||||
HTMLImageElement.prototype,
|
||||
"src",
|
||||
(_el, absolute) => this.processResource(absolute, "image"),
|
||||
);
|
||||
this.wrapPropertySetter(
|
||||
HTMLLinkElement.prototype,
|
||||
"href",
|
||||
(el, absolute) => {
|
||||
// <link> only initiates a load for some `rel` values; for the
|
||||
// others (icon, manifest, dns-prefetch, ...) we leave reporting
|
||||
// to PerformanceObserver if and when an actual load occurs.
|
||||
// preload/prefetch/modulepreload are deferred too because their
|
||||
// mapped resource type depends on the `as` attribute.
|
||||
if ((el as HTMLLinkElement).rel.toLowerCase() === "stylesheet") {
|
||||
this.processResource(absolute, "stylesheet");
|
||||
}
|
||||
},
|
||||
);
|
||||
if (typeof HTMLSourceElement !== "undefined") {
|
||||
this.wrapPropertySetter(
|
||||
HTMLSourceElement.prototype,
|
||||
"src",
|
||||
(_el, absolute) => this.processResource(absolute, "media"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private wrapPropertySetter(
|
||||
proto: object,
|
||||
key: string,
|
||||
onPageCaller: (el: Element, absolute: string) => void,
|
||||
): void {
|
||||
const desc = Object.getOwnPropertyDescriptor(proto, key);
|
||||
if (!desc?.set || !desc?.get) return;
|
||||
|
||||
this.savedDescriptors.push({ target: proto, key, descriptor: desc });
|
||||
|
||||
const originalSet = desc.set;
|
||||
const originalGet = desc.get;
|
||||
const self = this;
|
||||
|
||||
Object.defineProperty(proto, key, {
|
||||
configurable: true,
|
||||
enumerable: desc.enumerable,
|
||||
get: originalGet,
|
||||
set(value: unknown) {
|
||||
originalSet.call(this, value);
|
||||
if (typeof value !== "string" || value === "") return;
|
||||
|
||||
const fromExtension = isExtensionCaller();
|
||||
// The original getter resolves relative URLs against the
|
||||
// document base. Reading through it gives the same absolute
|
||||
// form processResource would later produce.
|
||||
const absolute = (originalGet.call(this) as string) || value;
|
||||
|
||||
if (fromExtension) {
|
||||
self.markExtension(this as Element, self.identifierOf(absolute));
|
||||
return;
|
||||
}
|
||||
onPageCaller(this as Element, absolute);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// wrapSetAttribute installs a single hook on Element.prototype that
|
||||
// covers every `el.setAttribute("src" | "href" | "data", ...)` call,
|
||||
// regardless of the element type. The first thing it does is bail on
|
||||
// attribute names we never care about, keeping the overhead near zero
|
||||
// on the hot path of generic setAttribute usage (class names, ARIA
|
||||
// attributes, dataset entries, ...).
|
||||
private wrapSetAttribute(): void {
|
||||
const original = Element.prototype.setAttribute;
|
||||
this.originalSetAttribute = original;
|
||||
const self = this;
|
||||
|
||||
Element.prototype.setAttribute = function (
|
||||
this: Element,
|
||||
name: string,
|
||||
value: string,
|
||||
): void {
|
||||
original.call(this, name, value);
|
||||
if (typeof name !== "string") return;
|
||||
// Common case: name is already lowercase. Skip the toLowerCase()
|
||||
// allocation by checking the canonical names directly first.
|
||||
if (
|
||||
name !== "src"
|
||||
&& name !== "href"
|
||||
&& name !== "data"
|
||||
&& !RESOURCE_ATTRIBUTES.has(name.toLowerCase())
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const lower = name === "src" || name === "href" || name === "data"
|
||||
? name
|
||||
: name.toLowerCase();
|
||||
self.handleAttributeMutation(this, lower, value);
|
||||
};
|
||||
|
||||
if (typeof Element.prototype.setAttributeNS === "function") {
|
||||
const originalNS = Element.prototype.setAttributeNS;
|
||||
this.originalSetAttributeNS = originalNS;
|
||||
|
||||
Element.prototype.setAttributeNS = function (
|
||||
this: Element,
|
||||
ns: string | null,
|
||||
name: string,
|
||||
value: string,
|
||||
): void {
|
||||
originalNS.call(this, ns, name, value);
|
||||
if (typeof name !== "string") return;
|
||||
// Strip any namespace prefix so xlink:href etc. resolve to the
|
||||
// local name we filter on.
|
||||
const colon = name.indexOf(":");
|
||||
const local = (colon >= 0 ? name.slice(colon + 1) : name).toLowerCase();
|
||||
if (!RESOURCE_ATTRIBUTES.has(local)) return;
|
||||
self.handleAttributeMutation(this, local, value);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private handleAttributeMutation(
|
||||
el: Element,
|
||||
attrName: string,
|
||||
value: unknown,
|
||||
): void {
|
||||
if (typeof value !== "string" || value === "") return;
|
||||
|
||||
const rt = this.resourceTypeForElement(el, attrName);
|
||||
if (rt === null) return;
|
||||
|
||||
if (isExtensionCaller()) {
|
||||
this.markExtension(el, this.identifierOf(value));
|
||||
return;
|
||||
}
|
||||
|
||||
this.processResource(value, rt);
|
||||
}
|
||||
|
||||
private resourceTypeForElement(el: Element, attrName: string): ResourceType | null {
|
||||
if (attrName === "src") {
|
||||
if (el instanceof HTMLScriptElement) return "script";
|
||||
if (el instanceof HTMLIFrameElement) return "iframe";
|
||||
if (el instanceof HTMLImageElement) return "image";
|
||||
if (typeof HTMLSourceElement !== "undefined" && el instanceof HTMLSourceElement) return "media";
|
||||
if (typeof HTMLEmbedElement !== "undefined" && el instanceof HTMLEmbedElement) return "media";
|
||||
if (typeof HTMLMediaElement !== "undefined" && el instanceof HTMLMediaElement) return "media";
|
||||
return null;
|
||||
}
|
||||
if (attrName === "href") {
|
||||
if (el instanceof HTMLLinkElement) {
|
||||
// Only report stylesheet loads from the sync hook; other rel
|
||||
// values either don't load (dns-prefetch, preconnect, icon,
|
||||
// manifest) or have a resource type that depends on the `as`
|
||||
// attribute, which is best handled by PerformanceObserver.
|
||||
return el.rel.toLowerCase() === "stylesheet" ? "stylesheet" : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (attrName === "data") {
|
||||
if (typeof HTMLObjectElement !== "undefined" && el instanceof HTMLObjectElement) return "media";
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// wrapHTMLParsing covers the four entry points where the browser HTML
|
||||
// parser builds element trees from a string: setting innerHTML or
|
||||
// outerHTML, insertAdjacentHTML, and document.write/writeln. None of
|
||||
// those invoke the per-element setter or setAttribute hooks above
|
||||
// (the parser writes attributes via internal C++), so we capture the
|
||||
// extension verdict synchronously at the entry point and then walk
|
||||
// the parsed result to tag the new resource-bearing descendants.
|
||||
private wrapHTMLParsing(): void {
|
||||
this.wrapInnerHTMLSetter();
|
||||
this.wrapOuterHTMLSetter();
|
||||
this.wrapInsertAdjacentHTML();
|
||||
this.wrapDocumentWrite();
|
||||
}
|
||||
|
||||
private wrapInnerHTMLSetter(): void {
|
||||
const desc = Object.getOwnPropertyDescriptor(Element.prototype, "innerHTML");
|
||||
if (!desc?.set || !desc?.get) return;
|
||||
|
||||
this.savedDescriptors.push({ target: Element.prototype, key: "innerHTML", descriptor: desc });
|
||||
|
||||
const originalSet = desc.set;
|
||||
const originalGet = desc.get;
|
||||
const self = this;
|
||||
|
||||
Object.defineProperty(Element.prototype, "innerHTML", {
|
||||
configurable: true,
|
||||
enumerable: desc.enumerable,
|
||||
get: originalGet,
|
||||
set(value: unknown) {
|
||||
const fromExtension = isExtensionCaller();
|
||||
originalSet.call(this, value);
|
||||
if (!fromExtension) return;
|
||||
// innerHTML replaces every existing child, so after the call
|
||||
// every descendant is new.
|
||||
self.markResourceTree(this as Element, true);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private wrapOuterHTMLSetter(): void {
|
||||
const desc = Object.getOwnPropertyDescriptor(Element.prototype, "outerHTML");
|
||||
if (!desc?.set || !desc?.get) return;
|
||||
|
||||
this.savedDescriptors.push({ target: Element.prototype, key: "outerHTML", descriptor: desc });
|
||||
|
||||
const originalSet = desc.set;
|
||||
const originalGet = desc.get;
|
||||
const self = this;
|
||||
|
||||
Object.defineProperty(Element.prototype, "outerHTML", {
|
||||
configurable: true,
|
||||
enumerable: desc.enumerable,
|
||||
get: originalGet,
|
||||
set(value: unknown) {
|
||||
const fromExtension = isExtensionCaller();
|
||||
const parent = (this as Element).parentNode;
|
||||
if (!fromExtension || !parent) {
|
||||
originalSet.call(this, value);
|
||||
return;
|
||||
}
|
||||
// outerHTML replaces this element with parsed siblings inside
|
||||
// the parent. We can't walk the parent wholesale (pre-existing
|
||||
// children would be wrongly marked), so we use a one-shot
|
||||
// MutationObserver to capture only the actually-added nodes.
|
||||
self.observeAndMark(parent, () => originalSet.call(this, value));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private wrapInsertAdjacentHTML(): void {
|
||||
const original = Element.prototype.insertAdjacentHTML;
|
||||
if (typeof original !== "function") return;
|
||||
|
||||
this.originalInsertAdjacentHTML = original;
|
||||
const self = this;
|
||||
|
||||
Element.prototype.insertAdjacentHTML = function (
|
||||
this: Element,
|
||||
position: InsertPosition,
|
||||
text: string,
|
||||
): void {
|
||||
const fromExtension = isExtensionCaller();
|
||||
if (!fromExtension) {
|
||||
original.call(this, position, text);
|
||||
return;
|
||||
}
|
||||
|
||||
// The affected parent depends on the position: beforebegin and
|
||||
// afterend insert siblings of `this` (so observe parentNode);
|
||||
// afterbegin and beforeend insert children of `this`.
|
||||
const root: Node | null
|
||||
= position === "beforebegin" || position === "afterend"
|
||||
? this.parentNode
|
||||
: this;
|
||||
|
||||
if (!root) {
|
||||
original.call(this, position, text);
|
||||
return;
|
||||
}
|
||||
|
||||
self.observeAndMark(root, () => original.call(this, position, text));
|
||||
};
|
||||
}
|
||||
|
||||
private wrapDocumentWrite(): void {
|
||||
if (typeof Document === "undefined") return;
|
||||
|
||||
const originalWrite = Document.prototype.write;
|
||||
const originalWriteln = Document.prototype.writeln;
|
||||
if (typeof originalWrite !== "function") return;
|
||||
|
||||
this.originalDocWrite = originalWrite;
|
||||
this.originalDocWriteln = typeof originalWriteln === "function" ? originalWriteln : null;
|
||||
|
||||
const self = this;
|
||||
|
||||
Document.prototype.write = function (this: Document, ...args: string[]): void {
|
||||
const fromExtension = isExtensionCaller();
|
||||
if (!fromExtension) {
|
||||
originalWrite.apply(this, args);
|
||||
return;
|
||||
}
|
||||
self.observeAndMark(this, () => originalWrite.apply(this, args));
|
||||
};
|
||||
|
||||
if (typeof originalWriteln === "function") {
|
||||
Document.prototype.writeln = function (this: Document, ...args: string[]): void {
|
||||
const fromExtension = isExtensionCaller();
|
||||
if (!fromExtension) {
|
||||
originalWriteln.apply(this, args);
|
||||
return;
|
||||
}
|
||||
self.observeAndMark(this, () => originalWriteln.apply(this, args));
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// observeAndMark wraps a single synchronous DOM mutation in a
|
||||
// disposable MutationObserver so we get a precise list of the nodes
|
||||
// the operation actually inserted. takeRecords() drains the queue
|
||||
// synchronously, before the main observer's microtask runs, so we
|
||||
// can tag the new elements before observeMutations() sees them.
|
||||
private observeAndMark(root: Node, fn: () => void): void {
|
||||
if (typeof MutationObserver === "undefined") {
|
||||
fn();
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(() => {});
|
||||
try {
|
||||
observer.observe(root, { childList: true, subtree: true });
|
||||
} catch {
|
||||
// observe() throws on detached or unusual roots. Fall back to
|
||||
// running the operation untracked rather than failing the page.
|
||||
fn();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
fn();
|
||||
} finally {
|
||||
const records = observer.takeRecords();
|
||||
observer.disconnect();
|
||||
for (const record of records) {
|
||||
for (const node of record.addedNodes) {
|
||||
if (node instanceof HTMLElement) {
|
||||
this.markResourceTree(node, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// markResourceTree walks `root` and its descendants and tags any
|
||||
// resource-bearing element. `fromParser` distinguishes elements
|
||||
// produced by the HTML parser (innerHTML/outerHTML/insertAdjacentHTML/
|
||||
// document.write): parser-inserted <script> elements have their
|
||||
// already-started flag set and never fetch their src, so we tag them
|
||||
// for MutationObserver suppression but deliberately do not poison
|
||||
// extensionUrls -- that URL might still be a legitimate page tracker
|
||||
// loaded from elsewhere.
|
||||
private markResourceTree(root: Element, fromParser: boolean): void {
|
||||
this.tryMarkElement(root, fromParser);
|
||||
for (const el of root.querySelectorAll("*")) {
|
||||
this.tryMarkElement(el, fromParser);
|
||||
}
|
||||
}
|
||||
|
||||
private tryMarkElement(el: Element, fromParser: boolean): void {
|
||||
let url: string | null = null;
|
||||
let willLoad = true;
|
||||
|
||||
if (el instanceof HTMLScriptElement) {
|
||||
if (!el.src) return;
|
||||
url = el.src;
|
||||
// Parser-inserted scripts are flagged already-started by the
|
||||
// spec and never fetch their src. Tag the element for the
|
||||
// MutationObserver path but skip URL marking.
|
||||
willLoad = !fromParser;
|
||||
} else if (el instanceof HTMLIFrameElement) {
|
||||
if (!el.src) return;
|
||||
url = el.src;
|
||||
} else if (el instanceof HTMLImageElement) {
|
||||
if (!el.src) return;
|
||||
url = el.src;
|
||||
} else if (el instanceof HTMLLinkElement) {
|
||||
if (!el.href) return;
|
||||
url = el.href;
|
||||
} else if (typeof HTMLSourceElement !== "undefined" && el instanceof HTMLSourceElement) {
|
||||
if (!el.src) return;
|
||||
url = el.src;
|
||||
} else if (typeof HTMLEmbedElement !== "undefined" && el instanceof HTMLEmbedElement) {
|
||||
if (!el.src) return;
|
||||
url = el.src;
|
||||
} else if (typeof HTMLObjectElement !== "undefined" && el instanceof HTMLObjectElement) {
|
||||
if (!el.data) return;
|
||||
url = el.data;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
this.markExtensionElement(el);
|
||||
if (willLoad) this.markExtensionUrl(this.identifierOf(url));
|
||||
}
|
||||
|
||||
// wrapNetworkAPIs covers the resource paths that never produce a DOM
|
||||
// element: fetch, XMLHttpRequest, and sendBeacon. Each of these has a
|
||||
// synchronous entry point with the caller's stack on top, so the
|
||||
// extension check here is reliable in a way that PerformanceObserver
|
||||
// never can be.
|
||||
private wrapNetworkAPIs(): void {
|
||||
this.wrapFetch();
|
||||
this.wrapXHR();
|
||||
this.wrapSendBeacon();
|
||||
}
|
||||
|
||||
private wrapFetch(): void {
|
||||
if (typeof window === "undefined" || typeof window.fetch !== "function") return;
|
||||
|
||||
const original = window.fetch;
|
||||
this.originalFetch = original;
|
||||
const self = this;
|
||||
|
||||
window.fetch = function (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> {
|
||||
const url = self.urlFromFetchInput(input);
|
||||
if (url) {
|
||||
if (isExtensionCaller()) {
|
||||
self.markExtensionUrl(self.identifierOf(url));
|
||||
} else {
|
||||
self.processResource(url, "fetch");
|
||||
}
|
||||
}
|
||||
return original.call(this, input, init);
|
||||
};
|
||||
}
|
||||
|
||||
private urlFromFetchInput(input: RequestInfo | URL): string | null {
|
||||
try {
|
||||
if (typeof input === "string") return input;
|
||||
if (input instanceof URL) return input.toString();
|
||||
if (typeof Request !== "undefined" && input instanceof Request) return input.url;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private wrapXHR(): void {
|
||||
if (typeof XMLHttpRequest === "undefined") return;
|
||||
|
||||
const original = XMLHttpRequest.prototype.open;
|
||||
this.originalXHROpen = original;
|
||||
const self = this;
|
||||
|
||||
// The ECMA signature of XHR.open is variadic (2 to 5 args) and
|
||||
// treats `undefined` for the trailing async/username/password as
|
||||
// "not passed" with spec-defined defaults. Forwarding via apply
|
||||
// preserves whatever the caller actually provided.
|
||||
function wrappedOpen(this: XMLHttpRequest): void {
|
||||
const args = arguments as unknown as [string, string | URL, ...unknown[]];
|
||||
const rawUrl = args[1];
|
||||
if (typeof rawUrl === "string" || rawUrl instanceof URL) {
|
||||
const url = typeof rawUrl === "string" ? rawUrl : rawUrl.toString();
|
||||
if (isExtensionCaller()) {
|
||||
self.markExtensionUrl(self.identifierOf(url));
|
||||
} else {
|
||||
self.processResource(url, "fetch");
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line prefer-rest-params
|
||||
(original as (...a: unknown[]) => void).apply(this, args);
|
||||
}
|
||||
|
||||
XMLHttpRequest.prototype.open = wrappedOpen as typeof XMLHttpRequest.prototype.open;
|
||||
}
|
||||
|
||||
private wrapSendBeacon(): void {
|
||||
if (typeof navigator === "undefined" || typeof navigator.sendBeacon !== "function") return;
|
||||
|
||||
const original = navigator.sendBeacon;
|
||||
this.originalSendBeacon = original;
|
||||
const self = this;
|
||||
|
||||
navigator.sendBeacon = function (
|
||||
this: Navigator,
|
||||
url: string | URL,
|
||||
data?: BodyInit | null,
|
||||
): boolean {
|
||||
const urlStr = typeof url === "string" ? url : url.toString();
|
||||
if (isExtensionCaller()) {
|
||||
self.markExtensionUrl(self.identifierOf(urlStr));
|
||||
} else {
|
||||
self.processResource(urlStr, "beacon");
|
||||
}
|
||||
return original.call(this, url, data);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user