Filter browser-extension cookies from detection

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é <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-04-29 11:09:05 +04:00
parent e5b489ce32
commit 48606f34c1
8 changed files with 149 additions and 12 deletions

View File

@@ -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) {

View File

@@ -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,
}

View File

@@ -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,
}

View File

@@ -0,0 +1,70 @@
// 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.
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)
}
}

View File

@@ -0,0 +1,18 @@
-- 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.
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;

View File

@@ -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!

View File

@@ -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,
}

View File

@@ -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,
},
)
}