From 48606f34c13181a8602982ecf07ab4c6e3390eaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Wed, 29 Apr 2026 11:09:05 +0400 Subject: [PATCH] Filter browser-extension cookies from detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cookies set by browser extensions are not the website operator's compliance responsibility. This adds stack-trace inspection to filter out extension-originated document.cookie writes, and annotates pre-existing cookies with a source field so operators can triage them separately. Introduces a CookieSource enum (SCRIPT / PRE_EXISTING) across the full stack: PostgreSQL, coredata, service, HTTP handler, and GraphQL schema. On conflict, source is upgraded from PRE_EXISTING to SCRIPT when a page script is later observed setting the cookie. Signed-off-by: Émile Ré --- packages/cookie-banner/src/detector.ts | 12 +++- pkg/cookiebanner/service.go | 4 ++ pkg/coredata/cookie.go | 33 ++++++--- pkg/coredata/cookie_source.go | 70 +++++++++++++++++++ pkg/coredata/migrations/20260429T064800Z.sql | 18 +++++ .../console/v1/graphql/cookie_banner.graphql | 13 ++++ pkg/server/api/console/v1/types/cookie.go | 1 + pkg/server/api/cookiebanner/v1/handler.go | 10 +++ 8 files changed, 149 insertions(+), 12 deletions(-) create mode 100644 pkg/coredata/cookie_source.go create mode 100644 pkg/coredata/migrations/20260429T064800Z.sql diff --git a/packages/cookie-banner/src/detector.ts b/packages/cookie-banner/src/detector.ts index b8e727a07..3b1bcf2fc 100644 --- a/packages/cookie-banner/src/detector.ts +++ b/packages/cookie-banner/src/detector.ts @@ -18,10 +18,17 @@ import { fetchJSON } from "./http"; interface DetectedCookieEntry { name: string; duration: string; + source: "script" | "pre-existing"; } const DEBOUNCE_MS = 2_000; const MAX_COOKIES_PER_REQUEST = 100; +const EXTENSION_URL_RE = /(?:chrome|moz|safari-web)-extension:\/\//; + +function isExtensionCaller(): boolean { + const stack = new Error().stack ?? ""; + return EXTENSION_URL_RE.test(stack); +} export class CookieDetector { private readonly reportUrl: URL; @@ -81,6 +88,7 @@ export class CookieDetector { private onCookieSet(raw: string): void { if (isDeletion(raw)) return; + if (isExtensionCaller()) return; const name = parseCookieName(raw); if (!name || this.knownNames.has(name) || this.reported.has(name)) return; @@ -88,7 +96,7 @@ export class CookieDetector { const duration = parseDuration(raw); this.reported.add(name); - this.pending.set(name, { name, duration }); + this.pending.set(name, { name, duration, source: "script" }); this.scheduleFlush(); } @@ -102,7 +110,7 @@ export class CookieDetector { continue; } this.reported.add(name); - this.pending.set(name, { name, duration: "session" }); + this.pending.set(name, { name, duration: "session", source: "pre-existing" }); } if (this.pending.size > 0) { diff --git a/pkg/cookiebanner/service.go b/pkg/cookiebanner/service.go index db07278dc..1b79a08ad 100644 --- a/pkg/cookiebanner/service.go +++ b/pkg/cookiebanner/service.go @@ -126,6 +126,7 @@ type ( DetectedCookie struct { Name string Duration string + Source coredata.CookieSource } ReportDetectedCookiesRequest struct { @@ -601,6 +602,7 @@ func (s *Service) CreateCookieBanner( Name: "probo_consent", Duration: fmt.Sprintf("%d days", req.ConsentExpiryDays), Description: "Stores your cookie consent preferences for this website.", + Source: coredata.CookieSourceScript, CreatedAt: now, UpdatedAt: now, } @@ -1205,6 +1207,7 @@ func (s *Service) CreateCookie( Name: req.Name, Duration: req.Duration, Description: req.Description, + Source: coredata.CookieSourceScript, CreatedAt: now, UpdatedAt: now, } @@ -2209,6 +2212,7 @@ func (s *Service) ReportDetectedCookies( Name: dc.Name, Duration: dc.Duration, Description: "", + Source: dc.Source, CreatedAt: now, UpdatedAt: now, } diff --git a/pkg/coredata/cookie.go b/pkg/coredata/cookie.go index 020ec27b0..82e954f9c 100644 --- a/pkg/coredata/cookie.go +++ b/pkg/coredata/cookie.go @@ -30,15 +30,16 @@ import ( type ( Cookie struct { - ID gid.GID `db:"id"` - OrganizationID gid.GID `db:"organization_id"` - CookieBannerID gid.GID `db:"cookie_banner_id"` - CookieCategoryID gid.GID `db:"cookie_category_id"` - Name string `db:"name"` - Duration string `db:"duration"` - Description string `db:"description"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` + ID gid.GID `db:"id"` + OrganizationID gid.GID `db:"organization_id"` + CookieBannerID gid.GID `db:"cookie_banner_id"` + CookieCategoryID gid.GID `db:"cookie_category_id"` + Name string `db:"name"` + Duration string `db:"duration"` + Description string `db:"description"` + Source CookieSource `db:"source"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` } Cookies []*Cookie @@ -83,6 +84,7 @@ SELECT name, duration, description, + source, created_at, updated_at FROM @@ -132,6 +134,7 @@ SELECT name, duration, description, + source, created_at, updated_at FROM @@ -209,6 +212,7 @@ SELECT name, duration, description, + source, created_at, updated_at FROM @@ -255,6 +259,7 @@ INSERT INTO cookies ( name, duration, description, + source, created_at, updated_at ) VALUES ( @@ -266,6 +271,7 @@ INSERT INTO cookies ( @name, @duration, @description, + @source, @created_at, @updated_at ) @@ -280,6 +286,7 @@ INSERT INTO cookies ( "name": c.Name, "duration": c.Duration, "description": c.Description, + "source": c.Source, "created_at": c.CreatedAt, "updated_at": c.UpdatedAt, } @@ -312,6 +319,7 @@ INSERT INTO cookies ( name, duration, description, + source, created_at, updated_at ) VALUES ( @@ -323,10 +331,13 @@ INSERT INTO cookies ( @name, @duration, @description, + @source, @created_at, @updated_at ) -ON CONFLICT (cookie_banner_id, name) DO NOTHING +ON CONFLICT (cookie_banner_id, name) DO UPDATE + SET source = EXCLUDED.source, updated_at = EXCLUDED.updated_at + WHERE cookies.source != @source_script AND EXCLUDED.source = @source_script ` args := pgx.StrictNamedArgs{ @@ -338,6 +349,8 @@ ON CONFLICT (cookie_banner_id, name) DO NOTHING "name": c.Name, "duration": c.Duration, "description": c.Description, + "source": c.Source, + "source_script": CookieSourceScript, "created_at": c.CreatedAt, "updated_at": c.UpdatedAt, } diff --git a/pkg/coredata/cookie_source.go b/pkg/coredata/cookie_source.go new file mode 100644 index 000000000..8d8582d3b --- /dev/null +++ b/pkg/coredata/cookie_source.go @@ -0,0 +1,70 @@ +// 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. + +package coredata + +import ( + "database/sql/driver" + "fmt" +) + +type CookieSource string + +const ( + CookieSourceScript CookieSource = "SCRIPT" + CookieSourcePreExisting CookieSource = "PRE_EXISTING" +) + +func CookieSources() []CookieSource { + return []CookieSource{ + CookieSourceScript, + CookieSourcePreExisting, + } +} + +func (s CookieSource) String() string { + return string(s) +} + +func (s *CookieSource) Scan(value any) error { + var v string + switch val := value.(type) { + case string: + v = val + case []byte: + v = string(val) + default: + return fmt.Errorf("unsupported type for CookieSource: %T", value) + } + + switch CookieSource(v) { + case CookieSourceScript: + *s = CookieSourceScript + case CookieSourcePreExisting: + *s = CookieSourcePreExisting + default: + return fmt.Errorf("invalid CookieSource value: %q", v) + } + return nil +} + +func (s CookieSource) Value() (driver.Value, error) { + switch s { + case CookieSourceScript, + CookieSourcePreExisting: + return string(s), nil + default: + return nil, fmt.Errorf("invalid CookieSource: %s", s) + } +} diff --git a/pkg/coredata/migrations/20260429T064800Z.sql b/pkg/coredata/migrations/20260429T064800Z.sql new file mode 100644 index 000000000..8a8f669df --- /dev/null +++ b/pkg/coredata/migrations/20260429T064800Z.sql @@ -0,0 +1,18 @@ +-- 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. + +CREATE TYPE cookie_source AS ENUM ('SCRIPT', 'PRE_EXISTING'); + +ALTER TABLE cookies ADD COLUMN source cookie_source NOT NULL DEFAULT 'PRE_EXISTING'; +ALTER TABLE cookies ALTER COLUMN source DROP DEFAULT; diff --git a/pkg/server/api/console/v1/graphql/cookie_banner.graphql b/pkg/server/api/console/v1/graphql/cookie_banner.graphql index 6e0630dbf..681341680 100644 --- a/pkg/server/api/console/v1/graphql/cookie_banner.graphql +++ b/pkg/server/api/console/v1/graphql/cookie_banner.graphql @@ -22,6 +22,18 @@ enum CookieConsentMode ) } +enum CookieSource + @goModel(model: "go.probo.inc/probo/pkg/coredata.CookieSource") { + SCRIPT + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.CookieSourceScript" + ) + PRE_EXISTING + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.CookieSourcePreExisting" + ) +} + enum CookieBannerOrderField @goModel( model: "go.probo.inc/probo/pkg/coredata.CookieBannerOrderField" @@ -174,6 +186,7 @@ type Cookie implements Node { name: String! duration: String! description: String! + source: CookieSource! createdAt: Datetime! updatedAt: Datetime! diff --git a/pkg/server/api/console/v1/types/cookie.go b/pkg/server/api/console/v1/types/cookie.go index ab5f172d9..5fa895696 100644 --- a/pkg/server/api/console/v1/types/cookie.go +++ b/pkg/server/api/console/v1/types/cookie.go @@ -72,6 +72,7 @@ func NewCookie(c *coredata.Cookie) *Cookie { Name: c.Name, Duration: c.Duration, Description: c.Description, + Source: c.Source, CreatedAt: c.CreatedAt, UpdatedAt: c.UpdatedAt, } diff --git a/pkg/server/api/cookiebanner/v1/handler.go b/pkg/server/api/cookiebanner/v1/handler.go index 5ae9bfad6..4cd3bb8b9 100644 --- a/pkg/server/api/cookiebanner/v1/handler.go +++ b/pkg/server/api/cookiebanner/v1/handler.go @@ -189,6 +189,7 @@ func (h *Handler) handlePostConsent(w http.ResponseWriter, r *http.Request) { type detectedCookieEntry struct { Name string `json:"name"` Duration string `json:"duration"` + Source string `json:"source"` } type reportDetectedCookiesBody struct { @@ -227,11 +228,20 @@ func (h *Handler) handleReportDetectedCookies(w http.ResponseWriter, r *http.Req continue } + var source coredata.CookieSource + switch strings.TrimSpace(c.Source) { + case "pre-existing": + source = coredata.CookieSourcePreExisting + default: + source = coredata.CookieSourceScript + } + detected = append( detected, cookiebanner.DetectedCookie{ Name: name, Duration: strings.TrimSpace(c.Duration), + Source: source, }, ) }