Mark page-world extension writes with EXTENSION source
The previous cleanup deleted every isExtensionCaller() site, including the one in cookie/storage detectors that did fire reliably for the residual case: page-world extensions (MV3 main world, userscripts with @grant none) whose stack contains a chrome-/moz-/safari-web-extension frame at the synchronous write. Recover that signal for free by returning fromExtension from getInitiatorURL (it already walks the stack and discards extension frames via continue), and have the cookie and storage detectors report source: "extension" instead of "script" when the flag is set. End-to-end plumbing reuses the existing source column: extend the cookie_source Postgres enum with EXTENSION, add the CookieSourceExtension constant with a doc block describing each bucket's actual semantics, add the handler.go switch cases, expose EXTENSION on the GraphQL and MCP CookieSource enums, and add the Extension option to the console source filter. Update bestSource in the pattern analysis worker so a glob merging only extension-attributed exact patterns is no longer silently rolled up to PRE_EXISTING. New precedence is SCRIPT > EXTENSION > PRE_EXISTING, matching the upsert SQL's "page-script wins" rule and the asymmetric signal strength of each bucket. Out of scope: any behavioural use of EXTENSION (auto-exclusion, denylist classification, dashboard surfacing) -- that belongs in the follow-up backend denylist plan. Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
@@ -83,12 +83,12 @@ export class CookieDetector implements Detector {
|
||||
if (!name || this.knownNames.has(name)) return;
|
||||
|
||||
const maxAgeSeconds = parseMaxAgeSeconds(raw);
|
||||
const initiatorUrl = getInitiatorURL(this.apiOrigin);
|
||||
const { url: initiatorUrl, fromExtension } = getInitiatorURL(this.apiOrigin);
|
||||
|
||||
const entry: DetectedCookieEntry = {
|
||||
name,
|
||||
max_age_seconds: maxAgeSeconds,
|
||||
source: "script",
|
||||
source: fromExtension ? "extension" : "script",
|
||||
};
|
||||
if (initiatorUrl) entry.initiator_url = initiatorUrl;
|
||||
this.queue.reportCookie(entry);
|
||||
|
||||
@@ -17,23 +17,41 @@ const STACK_URL_RE = /https?:\/\/[^\s)'"`]+/g;
|
||||
const LINE_COL_SUFFIX_RE = /:\d+(?::\d+)?$/;
|
||||
const MAX_INITIATOR_URL_LENGTH = 1024;
|
||||
|
||||
// getInitiatorURL walks the current call stack and returns the first
|
||||
// third-party script URL (as origin+pathname). It deliberately skips:
|
||||
// - browser extension URLs (chrome/moz/safari-web-extension://)
|
||||
// - the SDK's API origin (instrumentation frames)
|
||||
// - the page's own origin (we want the third-party loader, not first-party code)
|
||||
export interface InitiatorContext {
|
||||
url: string | null;
|
||||
fromExtension: boolean;
|
||||
}
|
||||
|
||||
// getInitiatorURL walks the current call stack once and returns:
|
||||
// - `url`: the first third-party HTTP(S) script URL it finds
|
||||
// (as origin+pathname), skipping the SDK's API origin and the
|
||||
// page's own origin. Returns null when no such frame exists.
|
||||
// - `fromExtension`: true if any frame on the stack was a
|
||||
// browser-extension URL (chrome/moz/safari-web-extension://).
|
||||
// Page-world extensions (MV3 main world, userscripts with
|
||||
// @grant none) reliably leave such a frame; isolated-world
|
||||
// content scripts use a different realm and never reach this
|
||||
// function in the first place.
|
||||
//
|
||||
// Returns null when no third-party frame is found (anonymous/eval/inline
|
||||
// scripts, or writes originating from the page itself).
|
||||
export function getInitiatorURL(apiOrigin: string): string | null {
|
||||
// Both signals come from the same single stack walk, so callers
|
||||
// that need either or both pay only one `new Error().stack` cost.
|
||||
export function getInitiatorURL(apiOrigin: string): InitiatorContext {
|
||||
const stack = new Error().stack;
|
||||
if (!stack) return null;
|
||||
if (!stack) return { url: null, fromExtension: false };
|
||||
|
||||
let fromExtension = false;
|
||||
let url: string | null = null;
|
||||
|
||||
STACK_URL_RE.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = STACK_URL_RE.exec(stack)) !== null) {
|
||||
const raw = m[0];
|
||||
if (EXTENSION_URL_RE.test(raw)) continue;
|
||||
if (EXTENSION_URL_RE.test(raw)) {
|
||||
fromExtension = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (url !== null) continue;
|
||||
|
||||
const cleaned = raw.replace(LINE_COL_SUFFIX_RE, "");
|
||||
|
||||
@@ -48,10 +66,10 @@ export function getInitiatorURL(apiOrigin: string): string | null {
|
||||
if (parsed.origin === location.origin) continue;
|
||||
|
||||
const result = parsed.origin + parsed.pathname;
|
||||
if (result.length > MAX_INITIATOR_URL_LENGTH) {
|
||||
return result.slice(0, MAX_INITIATOR_URL_LENGTH);
|
||||
}
|
||||
return result;
|
||||
url = result.length > MAX_INITIATOR_URL_LENGTH
|
||||
? result.slice(0, MAX_INITIATOR_URL_LENGTH)
|
||||
: result;
|
||||
}
|
||||
return null;
|
||||
|
||||
return { url, fromExtension };
|
||||
}
|
||||
|
||||
@@ -100,7 +100,8 @@ export class StorageDetector implements Detector {
|
||||
const self = this;
|
||||
|
||||
caches.open = function (name: string): Promise<Cache> {
|
||||
self.onCacheStorageOpen(name, "script");
|
||||
const { fromExtension } = getInitiatorURL(self.apiOrigin);
|
||||
self.onCacheStorageOpen(name, fromExtension ? "extension" : "script");
|
||||
return originalOpen(name);
|
||||
};
|
||||
}
|
||||
@@ -112,24 +113,26 @@ export class StorageDetector implements Detector {
|
||||
): void {
|
||||
if (key.startsWith(OWN_KEY_PREFIX)) return;
|
||||
|
||||
const initiatorUrl = getInitiatorURL(this.apiOrigin);
|
||||
const { url: initiatorUrl, fromExtension } = getInitiatorURL(this.apiOrigin);
|
||||
|
||||
const entry: DetectedStorageEntry = {
|
||||
key,
|
||||
storage_type: storageType,
|
||||
value_size: value.length * 2,
|
||||
source: "script",
|
||||
source: fromExtension ? "extension" : "script",
|
||||
};
|
||||
if (initiatorUrl) entry.initiator_url = initiatorUrl;
|
||||
this.queue.reportStorage(entry);
|
||||
}
|
||||
|
||||
private onIndexedDBOpen(name: string): void {
|
||||
const { fromExtension } = getInitiatorURL(this.apiOrigin);
|
||||
|
||||
this.queue.reportStorage({
|
||||
key: name,
|
||||
storage_type: "indexed_db",
|
||||
value_size: null,
|
||||
source: "script",
|
||||
source: fromExtension ? "extension" : "script",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
// pkg/server/api/cookiebanner/v1/handler.go. Any change here must be
|
||||
// matched server-side or the request will be rejected.
|
||||
|
||||
export type CookieSource = "script" | "pre-existing" | "http";
|
||||
export type CookieSource = "script" | "pre-existing" | "http" | "extension";
|
||||
|
||||
export type StorageSource = "script" | "pre-existing";
|
||||
export type StorageSource = "script" | "pre-existing" | "extension";
|
||||
|
||||
export type StorageType =
|
||||
| "local_storage"
|
||||
|
||||
Reference in New Issue
Block a user