Exclude SDK bundle URL from tracker attribution

The cookie/storage/resource detectors only skipped the API base-url
origin and the page origin when computing a tracker's initiator. When
the SDK is served from a CDN (e.g. jsDelivr) distinct from the API
host, its own wrapper frames sit atop every setItem/document.cookie
call stack and were never skipped, so getInitiatorURL returned the
bundle URL. Third-party and browser-extension writes were therefore
misattributed to cookie-banner.iife.js.

Capture the SDK's own served script URL once at load (currentScript
src, with an Error().stack fallback) and exclude it, at URL level, from
both the initiator stack-walk and the resource detector. URL-level
exclusion keeps other trackers served from the same shared CDN
detectable.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-06-10 15:44:50 +02:00
parent 7c09e19d0c
commit eb7878c618
3 changed files with 79 additions and 0 deletions

View File

@@ -12,6 +12,8 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { getSelfResourceUrls } from "./self-origin";
const EXTENSION_URL_RE = /(?:chrome|moz|safari-web)-extension:\/\//;
const STACK_URL_RE =
/(?:https?|(?:chrome|moz|safari-web)-extension):\/\/[^\s)'"`]+/g;
@@ -43,6 +45,8 @@ export function getInitiatorURL(apiOrigin: string): InitiatorContext {
let fromExtension = false;
let url: string | null = null;
const selfUrls = getSelfResourceUrls();
STACK_URL_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = STACK_URL_RE.exec(stack)) !== null) {
@@ -67,6 +71,8 @@ export function getInitiatorURL(apiOrigin: string): InitiatorContext {
if (parsed.origin === location.origin) continue;
const result = parsed.origin + parsed.pathname;
if (selfUrls.has(result)) continue;
url = result.length > MAX_INITIATOR_URL_LENGTH
? result.slice(0, MAX_INITIATOR_URL_LENGTH)
: result;

View File

@@ -15,6 +15,7 @@
import type { Detector } from "./detector";
import { isExtensionContext } from "./extension-context";
import type { ReportQueue } from "./report-queue";
import { getSelfResourceUrls } from "./self-origin";
import type { ResourceType } from "./types";
// Map browser-reported PerformanceResourceTiming.initiatorType to the
@@ -223,6 +224,8 @@ export class ResourceDetector implements Detector {
if (resourceType !== "service_worker" && parsed.origin === this.pageOrigin) return;
const identifier = parsed.origin + parsed.pathname;
if (getSelfResourceUrls().has(identifier)) return;
this.queue.reportResource({ url: identifier, resource_type: resourceType });
}
}

View File

@@ -0,0 +1,70 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
const HTTP_URL_RE = /https?:\/\/[^\s)'"`]+/;
const LINE_COL_SUFFIX_RE = /:\d+(?::\d+)?$/;
function normalize(raw: string): string | null {
const cleaned = raw.replace(LINE_COL_SUFFIX_RE, "");
try {
const parsed = new URL(cleaned);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
return parsed.origin + parsed.pathname;
} catch {
return null;
}
}
// selfResourceUrls is computed once at module load. It holds the
// normalized `origin + pathname` URL(s) of the SDK bundle itself so the
// detectors never attribute a tracker -- or report the bundle as a
// resource -- to our own served script.
//
// We capture `document.currentScript?.src` here because that reference
// only resolves while the loading script is still executing; by the time
// a detector runs it is typically null. We also derive the executing URL
// from the load-time stack as a fallback for `.mjs`/bundler builds where
// `currentScript` is null. Exclusion is URL-level (origin + pathname),
// not origin-level, because the bundle is commonly served from a shared
// CDN (e.g. cdn.jsdelivr.net) that also hosts unrelated trackers.
const selfResourceUrls: ReadonlySet<string> = (() => {
const urls = new Set<string>();
if (
typeof document !== "undefined"
&& document.currentScript instanceof HTMLScriptElement
&& document.currentScript.src
) {
const fromSrc = normalize(document.currentScript.src);
if (fromSrc) urls.add(fromSrc);
}
const stack = new Error().stack;
if (stack) {
const match = stack.match(HTTP_URL_RE);
if (match) {
const fromStack = normalize(match[0]);
if (fromStack) urls.add(fromStack);
}
}
return urls;
})();
// getSelfResourceUrls returns the normalized `origin + pathname` URL(s)
// of the SDK bundle itself. The set is empty when nothing is resolvable,
// in which case callers behave exactly as before.
export function getSelfResourceUrls(): ReadonlySet<string> {
return selfResourceUrls;
}