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 {
|
interface DetectedCookieEntry {
|
||||||
name: string;
|
name: string;
|
||||||
duration: string;
|
duration: string;
|
||||||
|
source: "script" | "pre-existing";
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEBOUNCE_MS = 2_000;
|
const DEBOUNCE_MS = 2_000;
|
||||||
const MAX_COOKIES_PER_REQUEST = 100;
|
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 {
|
export class CookieDetector {
|
||||||
private readonly reportUrl: URL;
|
private readonly reportUrl: URL;
|
||||||
@@ -81,6 +88,7 @@ export class CookieDetector {
|
|||||||
|
|
||||||
private onCookieSet(raw: string): void {
|
private onCookieSet(raw: string): void {
|
||||||
if (isDeletion(raw)) return;
|
if (isDeletion(raw)) return;
|
||||||
|
if (isExtensionCaller()) return;
|
||||||
|
|
||||||
const name = parseCookieName(raw);
|
const name = parseCookieName(raw);
|
||||||
if (!name || this.knownNames.has(name) || this.reported.has(name)) return;
|
if (!name || this.knownNames.has(name) || this.reported.has(name)) return;
|
||||||
@@ -88,7 +96,7 @@ export class CookieDetector {
|
|||||||
const duration = parseDuration(raw);
|
const duration = parseDuration(raw);
|
||||||
|
|
||||||
this.reported.add(name);
|
this.reported.add(name);
|
||||||
this.pending.set(name, { name, duration });
|
this.pending.set(name, { name, duration, source: "script" });
|
||||||
this.scheduleFlush();
|
this.scheduleFlush();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +110,7 @@ export class CookieDetector {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
this.reported.add(name);
|
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) {
|
if (this.pending.size > 0) {
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ type (
|
|||||||
DetectedCookie struct {
|
DetectedCookie struct {
|
||||||
Name string
|
Name string
|
||||||
Duration string
|
Duration string
|
||||||
|
Source coredata.CookieSource
|
||||||
}
|
}
|
||||||
|
|
||||||
ReportDetectedCookiesRequest struct {
|
ReportDetectedCookiesRequest struct {
|
||||||
@@ -601,6 +602,7 @@ func (s *Service) CreateCookieBanner(
|
|||||||
Name: "probo_consent",
|
Name: "probo_consent",
|
||||||
Duration: fmt.Sprintf("%d days", req.ConsentExpiryDays),
|
Duration: fmt.Sprintf("%d days", req.ConsentExpiryDays),
|
||||||
Description: "Stores your cookie consent preferences for this website.",
|
Description: "Stores your cookie consent preferences for this website.",
|
||||||
|
Source: coredata.CookieSourceScript,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
}
|
}
|
||||||
@@ -1205,6 +1207,7 @@ func (s *Service) CreateCookie(
|
|||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
Duration: req.Duration,
|
Duration: req.Duration,
|
||||||
Description: req.Description,
|
Description: req.Description,
|
||||||
|
Source: coredata.CookieSourceScript,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
}
|
}
|
||||||
@@ -2209,6 +2212,7 @@ func (s *Service) ReportDetectedCookies(
|
|||||||
Name: dc.Name,
|
Name: dc.Name,
|
||||||
Duration: dc.Duration,
|
Duration: dc.Duration,
|
||||||
Description: "",
|
Description: "",
|
||||||
|
Source: dc.Source,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,15 +30,16 @@ import (
|
|||||||
|
|
||||||
type (
|
type (
|
||||||
Cookie struct {
|
Cookie struct {
|
||||||
ID gid.GID `db:"id"`
|
ID gid.GID `db:"id"`
|
||||||
OrganizationID gid.GID `db:"organization_id"`
|
OrganizationID gid.GID `db:"organization_id"`
|
||||||
CookieBannerID gid.GID `db:"cookie_banner_id"`
|
CookieBannerID gid.GID `db:"cookie_banner_id"`
|
||||||
CookieCategoryID gid.GID `db:"cookie_category_id"`
|
CookieCategoryID gid.GID `db:"cookie_category_id"`
|
||||||
Name string `db:"name"`
|
Name string `db:"name"`
|
||||||
Duration string `db:"duration"`
|
Duration string `db:"duration"`
|
||||||
Description string `db:"description"`
|
Description string `db:"description"`
|
||||||
CreatedAt time.Time `db:"created_at"`
|
Source CookieSource `db:"source"`
|
||||||
UpdatedAt time.Time `db:"updated_at"`
|
CreatedAt time.Time `db:"created_at"`
|
||||||
|
UpdatedAt time.Time `db:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
Cookies []*Cookie
|
Cookies []*Cookie
|
||||||
@@ -83,6 +84,7 @@ SELECT
|
|||||||
name,
|
name,
|
||||||
duration,
|
duration,
|
||||||
description,
|
description,
|
||||||
|
source,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
FROM
|
FROM
|
||||||
@@ -132,6 +134,7 @@ SELECT
|
|||||||
name,
|
name,
|
||||||
duration,
|
duration,
|
||||||
description,
|
description,
|
||||||
|
source,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
FROM
|
FROM
|
||||||
@@ -209,6 +212,7 @@ SELECT
|
|||||||
name,
|
name,
|
||||||
duration,
|
duration,
|
||||||
description,
|
description,
|
||||||
|
source,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
FROM
|
FROM
|
||||||
@@ -255,6 +259,7 @@ INSERT INTO cookies (
|
|||||||
name,
|
name,
|
||||||
duration,
|
duration,
|
||||||
description,
|
description,
|
||||||
|
source,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
) VALUES (
|
) VALUES (
|
||||||
@@ -266,6 +271,7 @@ INSERT INTO cookies (
|
|||||||
@name,
|
@name,
|
||||||
@duration,
|
@duration,
|
||||||
@description,
|
@description,
|
||||||
|
@source,
|
||||||
@created_at,
|
@created_at,
|
||||||
@updated_at
|
@updated_at
|
||||||
)
|
)
|
||||||
@@ -280,6 +286,7 @@ INSERT INTO cookies (
|
|||||||
"name": c.Name,
|
"name": c.Name,
|
||||||
"duration": c.Duration,
|
"duration": c.Duration,
|
||||||
"description": c.Description,
|
"description": c.Description,
|
||||||
|
"source": c.Source,
|
||||||
"created_at": c.CreatedAt,
|
"created_at": c.CreatedAt,
|
||||||
"updated_at": c.UpdatedAt,
|
"updated_at": c.UpdatedAt,
|
||||||
}
|
}
|
||||||
@@ -312,6 +319,7 @@ INSERT INTO cookies (
|
|||||||
name,
|
name,
|
||||||
duration,
|
duration,
|
||||||
description,
|
description,
|
||||||
|
source,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
) VALUES (
|
) VALUES (
|
||||||
@@ -323,10 +331,13 @@ INSERT INTO cookies (
|
|||||||
@name,
|
@name,
|
||||||
@duration,
|
@duration,
|
||||||
@description,
|
@description,
|
||||||
|
@source,
|
||||||
@created_at,
|
@created_at,
|
||||||
@updated_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{
|
args := pgx.StrictNamedArgs{
|
||||||
@@ -338,6 +349,8 @@ ON CONFLICT (cookie_banner_id, name) DO NOTHING
|
|||||||
"name": c.Name,
|
"name": c.Name,
|
||||||
"duration": c.Duration,
|
"duration": c.Duration,
|
||||||
"description": c.Description,
|
"description": c.Description,
|
||||||
|
"source": c.Source,
|
||||||
|
"source_script": CookieSourceScript,
|
||||||
"created_at": c.CreatedAt,
|
"created_at": c.CreatedAt,
|
||||||
"updated_at": c.UpdatedAt,
|
"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
|
enum CookieBannerOrderField
|
||||||
@goModel(
|
@goModel(
|
||||||
model: "go.probo.inc/probo/pkg/coredata.CookieBannerOrderField"
|
model: "go.probo.inc/probo/pkg/coredata.CookieBannerOrderField"
|
||||||
@@ -174,6 +186,7 @@ type Cookie implements Node {
|
|||||||
name: String!
|
name: String!
|
||||||
duration: String!
|
duration: String!
|
||||||
description: String!
|
description: String!
|
||||||
|
source: CookieSource!
|
||||||
createdAt: Datetime!
|
createdAt: Datetime!
|
||||||
updatedAt: Datetime!
|
updatedAt: Datetime!
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ func NewCookie(c *coredata.Cookie) *Cookie {
|
|||||||
Name: c.Name,
|
Name: c.Name,
|
||||||
Duration: c.Duration,
|
Duration: c.Duration,
|
||||||
Description: c.Description,
|
Description: c.Description,
|
||||||
|
Source: c.Source,
|
||||||
CreatedAt: c.CreatedAt,
|
CreatedAt: c.CreatedAt,
|
||||||
UpdatedAt: c.UpdatedAt,
|
UpdatedAt: c.UpdatedAt,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -189,6 +189,7 @@ func (h *Handler) handlePostConsent(w http.ResponseWriter, r *http.Request) {
|
|||||||
type detectedCookieEntry struct {
|
type detectedCookieEntry struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Duration string `json:"duration"`
|
Duration string `json:"duration"`
|
||||||
|
Source string `json:"source"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type reportDetectedCookiesBody struct {
|
type reportDetectedCookiesBody struct {
|
||||||
@@ -227,11 +228,20 @@ func (h *Handler) handleReportDetectedCookies(w http.ResponseWriter, r *http.Req
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var source coredata.CookieSource
|
||||||
|
switch strings.TrimSpace(c.Source) {
|
||||||
|
case "pre-existing":
|
||||||
|
source = coredata.CookieSourcePreExisting
|
||||||
|
default:
|
||||||
|
source = coredata.CookieSourceScript
|
||||||
|
}
|
||||||
|
|
||||||
detected = append(
|
detected = append(
|
||||||
detected,
|
detected,
|
||||||
cookiebanner.DetectedCookie{
|
cookiebanner.DetectedCookie{
|
||||||
Name: name,
|
Name: name,
|
||||||
Duration: strings.TrimSpace(c.Duration),
|
Duration: strings.TrimSpace(c.Duration),
|
||||||
|
Source: source,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user