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:
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
70
pkg/coredata/cookie_source.go
Normal file
70
pkg/coredata/cookie_source.go
Normal 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)
|
||||
}
|
||||
}
|
||||
18
pkg/coredata/migrations/20260429T064800Z.sql
Normal file
18
pkg/coredata/migrations/20260429T064800Z.sql
Normal 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;
|
||||
@@ -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!
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user