diff --git a/packages/cookie-banner/src/detectors/cookie-detector.ts b/packages/cookie-banner/src/detectors/cookie-detector.ts index f2c6b338e..746ed95bb 100644 --- a/packages/cookie-banner/src/detectors/cookie-detector.ts +++ b/packages/cookie-banner/src/detectors/cookie-detector.ts @@ -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 = new Map(); private timer: ReturnType | null = null; private originalDescriptor: PropertyDescriptor | null = null; + private cookieStoreHandler: ((event: CookieChangeEvent) => void) | null = null; constructor(baseUrl: URL, bannerId: string, knownNames: Set) { 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(() => { diff --git a/packages/cookie-banner/src/globals.d.ts b/packages/cookie-banner/src/globals.d.ts index 27e22eea3..aefed31ce 100644 --- a/packages/cookie-banner/src/globals.d.ts +++ b/packages/cookie-banner/src/globals.d.ts @@ -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; diff --git a/pkg/coredata/cookie_source.go b/pkg/coredata/cookie_source.go index 8d8582d3b..fe800021e 100644 --- a/pkg/coredata/cookie_source.go +++ b/pkg/coredata/cookie_source.go @@ -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) diff --git a/pkg/coredata/migrations/20260506T144600Z.sql b/pkg/coredata/migrations/20260506T144600Z.sql new file mode 100644 index 000000000..945f59888 --- /dev/null +++ b/pkg/coredata/migrations/20260506T144600Z.sql @@ -0,0 +1,15 @@ +-- Copyright (c) 2026 Probo Inc . +-- +-- 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'; diff --git a/pkg/server/api/cookiebanner/v1/handler.go b/pkg/server/api/cookiebanner/v1/handler.go index 108d1dc7e..0506088bc 100644 --- a/pkg/server/api/cookiebanner/v1/handler.go +++ b/pkg/server/api/cookiebanner/v1/handler.go @@ -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 }