Detect HTTP-header cookies via CookieStore change event

Progressive enhancement for Chromium browsers: listen on the
CookieStore change event to catch cookies set by Set-Cookie HTTP
response headers, which the document.cookie setter hook cannot see.
Adds a new "http" cookie source through the full stack.

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-05-06 18:54:50 +04:00
parent a4cb61366f
commit c0d0221be1
5 changed files with 81 additions and 2 deletions

View File

@@ -20,7 +20,7 @@ import { fetchJSON } from "../http";
interface DetectedCookieEntry {
name: string;
max_age_seconds: number | null;
source: "script" | "pre-existing";
source: "script" | "pre-existing" | "http";
}
const DEBOUNCE_MS = 2_000;
@@ -39,6 +39,7 @@ export class CookieDetector implements Detector {
private readonly pending: Map<string, DetectedCookieEntry> = new Map();
private timer: ReturnType<typeof setTimeout> | null = null;
private originalDescriptor: PropertyDescriptor | null = null;
private cookieStoreHandler: ((event: CookieChangeEvent) => void) | null = null;
constructor(baseUrl: URL, bannerId: string, knownNames: Set<string>) {
this.reportUrl = new URL(`${bannerId}/report`, baseUrl);
@@ -70,6 +71,7 @@ export class CookieDetector implements Detector {
});
this.scanExisting();
this.observeCookieStore();
}
stop(): void {
@@ -82,6 +84,11 @@ export class CookieDetector implements Detector {
this.flush();
}
if (this.cookieStoreHandler && typeof cookieStore !== "undefined") {
cookieStore.removeEventListener("change", this.cookieStoreHandler);
this.cookieStoreHandler = null;
}
if (this.originalDescriptor) {
Object.defineProperty(document, "cookie", this.originalDescriptor);
this.originalDescriptor = null;
@@ -120,6 +127,32 @@ export class CookieDetector implements Detector {
}
}
private observeCookieStore(): void {
if (typeof cookieStore === "undefined" || typeof cookieStore.addEventListener !== "function") {
return;
}
this.cookieStoreHandler = (event: CookieChangeEvent) => {
for (const cookie of event.changed) {
if (this.knownNames.has(cookie.name) || this.reported.has(cookie.name)) continue;
const maxAge = cookie.expires
? Math.round((cookie.expires - Date.now()) / 1000)
: null;
this.reported.add(cookie.name);
this.pending.set(cookie.name, {
name: cookie.name,
max_age_seconds: maxAge && maxAge > 0 ? maxAge : null,
source: "http",
});
}
if (this.pending.size > 0) this.scheduleFlush();
};
cookieStore.addEventListener("change", this.cookieStoreHandler);
}
private scheduleFlush(): void {
if (this.timer) return;
this.timer = setTimeout(() => {

View File

@@ -13,3 +13,25 @@
// PERFORMANCE OF THIS SOFTWARE.
declare const __SDK_VERSION__: string;
interface CookieListItem {
name: string;
value: string;
domain: string | null;
path: string;
expires: number | null;
secure: boolean;
sameSite: "strict" | "lax" | "none";
}
interface CookieChangeEvent extends Event {
changed: readonly CookieListItem[];
deleted: readonly CookieListItem[];
}
interface CookieStoreEventTarget extends EventTarget {
addEventListener(type: "change", listener: (event: CookieChangeEvent) => void): void;
removeEventListener(type: "change", listener: (event: CookieChangeEvent) => void): void;
}
declare const cookieStore: CookieStoreEventTarget | undefined;

View File

@@ -24,12 +24,14 @@ type CookieSource string
const (
CookieSourceScript CookieSource = "SCRIPT"
CookieSourcePreExisting CookieSource = "PRE_EXISTING"
CookieSourceHTTP CookieSource = "HTTP"
)
func CookieSources() []CookieSource {
return []CookieSource{
CookieSourceScript,
CookieSourcePreExisting,
CookieSourceHTTP,
}
}
@@ -53,6 +55,8 @@ func (s *CookieSource) Scan(value any) error {
*s = CookieSourceScript
case CookieSourcePreExisting:
*s = CookieSourcePreExisting
case CookieSourceHTTP:
*s = CookieSourceHTTP
default:
return fmt.Errorf("invalid CookieSource value: %q", v)
}
@@ -62,7 +66,8 @@ func (s *CookieSource) Scan(value any) error {
func (s CookieSource) Value() (driver.Value, error) {
switch s {
case CookieSourceScript,
CookieSourcePreExisting:
CookieSourcePreExisting,
CookieSourceHTTP:
return string(s), nil
default:
return nil, fmt.Errorf("invalid CookieSource: %s", s)

View File

@@ -0,0 +1,15 @@
-- Copyright (c) 2026 Probo Inc <hello@getprobo.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.
ALTER TYPE cookie_source ADD VALUE IF NOT EXISTS 'HTTP';

View File

@@ -266,6 +266,8 @@ func (h *Handler) handleReportDetectedCookies(w http.ResponseWriter, r *http.Req
switch strings.TrimSpace(c.Source) {
case "pre-existing":
source = coredata.CookieSourcePreExisting
case "http":
source = coredata.CookieSourceHTTP
default:
source = coredata.CookieSourceScript
}
@@ -358,6 +360,8 @@ func (h *Handler) handleReportDetectedTrackers(w http.ResponseWriter, r *http.Re
switch strings.TrimSpace(c.Source) {
case "pre-existing":
source = coredata.CookieSourcePreExisting
case "http":
source = coredata.CookieSourceHTTP
default:
source = coredata.CookieSourceScript
}