From 90f1794d09a177365bdd9b9d82a0b7f169ae8d5c Mon Sep 17 00:00:00 2001 From: Chris Portscheller Date: Thu, 8 Oct 2026 22:13:18 -0500 Subject: [PATCH] fix(security): send credential headers by name only in detection reports Detection reports carried the request's full header map, so a visitor's session cookie, bearer token or API key header was sent with every report. toWireDetectionRequest now replaces credential header values with an empty string and keeps only WebDecoy's own wd_clearance cookie. Rules still see the full request. --- CHANGELOG.md | 4 + packages/webdecoy/src/client.ts | 34 +++--- packages/webdecoy/src/invariants.test.ts | 19 +++ packages/webdecoy/src/report-headers.test.ts | 119 +++++++++++++++++++ packages/webdecoy/src/report-headers.ts | 49 ++++++++ packages/webdecoy/src/tls-info-wire.test.ts | 2 +- packages/webdecoy/src/types.ts | 5 +- 7 files changed, 216 insertions(+), 16 deletions(-) create mode 100644 packages/webdecoy/src/report-headers.test.ts create mode 100644 packages/webdecoy/src/report-headers.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f4d7411..cbf2874 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Security + +- **Credential headers never leave your server.** Detection reports carried the request's full header map, so a visitor's session cookie, `Authorization` bearer token or API key header was sent to WebDecoy with every report. Reports now send those headers by name only, with an empty value; the one cookie kept is WebDecoy's own `wd_clearance`. Rules and characteristics still see the full request, so a rate-limit key derived from `x-api-key` behaves as before. Credential headers are matched by name (`cookie`, `auth`, `token`, `secret`, `password`, `session`, `csrf`, `api-key` and similar), and WebDecoy's ingest service applies the same rule to reports from older SDK versions. + ## [0.18.4] - 2026-10-02 ### Fixed diff --git a/packages/webdecoy/src/client.ts b/packages/webdecoy/src/client.ts index e27a666..92f01b8 100644 --- a/packages/webdecoy/src/client.ts +++ b/packages/webdecoy/src/client.ts @@ -9,6 +9,7 @@ import type { AIReferralBatch } from './referrals/referral-counter'; import { SDKDetectionRequest, SDKDetectionResponse } from './types'; +import { reportableHeaders } from './report-headers'; import type { ViolationEvent, IPEnrichmentData } from './rules/types'; export interface ClientConfig { @@ -295,21 +296,26 @@ export class WebDecoyClient { } /** - * Shape a detection request for the wire. The detection service reads the - * client's ALPN list from `tls_info.alpn`; the SDK once documented it as - * `alpn_protocols`, which the service ignores. Callers still passing the old - * name get it sent under the name the service reads, and the old key is never - * sent. + * Shape a detection request for the wire. The one place a report takes its + * final form, so everything it must never carry is removed here. + * + * Credential header values (cookies, bearer tokens, API keys) are dropped; see + * {@link reportableHeaders}. + * + * The detection service reads the client's ALPN list from `tls_info.alpn`; the + * SDK once documented it as `alpn_protocols`, which the service ignores. + * Callers still passing the old name get it sent under the name the service + * reads, and the old key is never sent. */ export function toWireDetectionRequest(request: SDKDetectionRequest): SDKDetectionRequest { - const tls = request.request_metadata.tls_info; - if (!tls || tls.alpn_protocols === undefined) return request; - const { alpn_protocols, ...rest } = tls; - return { - ...request, - request_metadata: { - ...request.request_metadata, - tls_info: { ...rest, alpn: rest.alpn ?? alpn_protocols }, - }, + const metadata = { + ...request.request_metadata, + headers: reportableHeaders(request.request_metadata.headers ?? {}), }; + const tls = metadata.tls_info; + if (tls && tls.alpn_protocols !== undefined) { + const { alpn_protocols, ...rest } = tls; + metadata.tls_info = { ...rest, alpn: rest.alpn ?? alpn_protocols }; + } + return { ...request, request_metadata: metadata }; } diff --git a/packages/webdecoy/src/invariants.test.ts b/packages/webdecoy/src/invariants.test.ts index 3e8768b..57d789a 100644 --- a/packages/webdecoy/src/invariants.test.ts +++ b/packages/webdecoy/src/invariants.test.ts @@ -209,3 +209,22 @@ describe('the edge build stays edge-compatible', () => { } }); }); + +describe('one answer to "what may a report carry"', () => { + it('every detection report is shaped by toWireDetectionRequest', () => { + // That function removes credential header values. A second call site for + // the detect endpoint would send the visitor's cookies and tokens again, + // and read perfectly reasonably while doing it. + const callers = sourceFiles().flatMap((f) => hits(f, /\/api\/v1\/sdk\/detect\b/)); + expect(callers).toHaveLength(1); + expect(callers[0]).toMatch(/webdecoy[\\/]src[\\/]client\.ts:/); + + const client = readFileSync(join(PACKAGES, 'webdecoy', 'src', 'client.ts'), 'utf8'); + const call = client.slice( + client.indexOf("'/api/v1/sdk/detect'"), + client.indexOf("'/api/v1/sdk/detect'") + 120 + ); + expect(call).toContain('toWireDetectionRequest(request)'); + expect(client).toMatch(/headers: reportableHeaders\(/); + }); +}); diff --git a/packages/webdecoy/src/report-headers.test.ts b/packages/webdecoy/src/report-headers.test.ts new file mode 100644 index 0000000..717583b --- /dev/null +++ b/packages/webdecoy/src/report-headers.test.ts @@ -0,0 +1,119 @@ +import { WebDecoyClient, toWireDetectionRequest } from './client'; +import { CREDENTIAL_HEADER, reportableHeaders } from './report-headers'; +import type { SDKDetectionRequest } from './types'; + +function request(headers: Record): SDKDetectionRequest { + return { + request_metadata: { method: 'GET', path: '/', ip: '203.0.113.7', headers, timestamp: 0 }, + local_analysis: { + suspicious_headers: false, + missing_sec_ch_ua: false, + datacenter_ip: false, + local_score: 0, + needs_verification: true, + flags: [], + }, + }; +} + +// What the Express, Fastify, Next.js and Hono adapters hand the SDK: the +// request's full header map, lowercased. +const visitor = { + 'user-agent': 'Mozilla/5.0', + accept: 'text/html', + cookie: 'sid=visitor-session; wd_clearance=v1.token; theme=dark', + authorization: 'Bearer visitor-token', + 'proxy-authorization': 'Basic dXNlcjpwYXNz', + 'x-api-key': 'k_live_123', + 'x-csrf-token': 'c', + 'x-hasura-admin-secret': 's', + 'x-middleware-subrequest': 'middleware', + 'signature-agent': '"https://agent.example"', +}; + +describe('credential headers in detection reports', () => { + const realFetch = global.fetch; + afterEach(() => { + global.fetch = realFetch; + }); + + it('never sends a credential value, and keeps every name', async () => { + let body = ''; + global.fetch = jest.fn(async (_url: any, init: any) => { + body = init.body; + return new Response( + JSON.stringify({ + decision: 'allow', + confidence: 0, + threat_level: 'MINIMAL', + bot_detected: false, + detection_id: 'd-1', + rule_enforced: false, + }), + { status: 200 }, + ); + }) as any; + const client = new WebDecoyClient({ + apiKey: 'sk_test_key', + apiUrl: 'https://ingest.example', + timeout: 1000, + debug: false, + tlsRejectUnauthorized: true, + }); + await client.detect(request({ ...visitor })); + + for (const secret of [ + 'visitor-session', + 'visitor-token', + 'dXNlcjpwYXNz', + 'k_live_123', + 'theme=dark', + ]) { + expect(body).not.toContain(secret); + } + const sent = JSON.parse(body).request_metadata.headers; + expect(Object.keys(sent).sort()).toEqual(Object.keys(visitor).sort()); + expect(sent['user-agent']).toBe('Mozilla/5.0'); + expect(sent['x-middleware-subrequest']).toBe('middleware'); + expect(sent['signature-agent']).toBe('"https://agent.example"'); + }); + + it("keeps WebDecoy's own clearance cookie and nothing else", () => { + expect(reportableHeaders({ cookie: 'a=1; wd_clearance=v1.tok=en; b=2' }).cookie).toBe( + 'wd_clearance=v1.tok=en' + ); + expect(reportableHeaders({ Cookie: 'sid=1' }).Cookie).toBe(''); + expect(reportableHeaders({ cookie: 'not_wd_clearance=x' }).cookie).toBe(''); + }); + + it('leaves the request the rules see untouched', () => { + const req = request({ ...visitor }); + toWireDetectionRequest(req); + expect(req.request_metadata.headers).toEqual(visitor); + }); + + it('matches credential names, not the evidence', () => { + for (const name of [ + 'Cookie', + 'set-cookie', + 'authorization', + 'x-auth-token', + 'x-vault-token', + 'apikey', + 'x-session-id', + ]) { + expect(CREDENTIAL_HEADER.test(name)).toBe(true); + } + for (const name of [ + 'user-agent', + 'accept', + 'referer', + 'x-forwarded-for', + 'sec-ch-ua', + 'signature', + 'next-action', + ]) { + expect(CREDENTIAL_HEADER.test(name)).toBe(false); + } + }); +}); diff --git a/packages/webdecoy/src/report-headers.ts b/packages/webdecoy/src/report-headers.ts new file mode 100644 index 0000000..f51432d --- /dev/null +++ b/packages/webdecoy/src/report-headers.ts @@ -0,0 +1,49 @@ +/** + * The request headers a detection report may carry. + * + * A credential header belongs to your visitor: their session cookie, their + * bearer token, an API key. WebDecoy never needs its value to score a request, + * so the value never leaves your server. The name is still sent, because a + * client that guesses an admin secret is itself evidence. + * + * Only the outgoing report is redacted. Rules and characteristics run on the + * full request before this, so a rate-limit key derived from `x-api-key` works + * exactly as before. + */ + +/** + * Header names whose values are credentials. A substring pattern rather than a + * list, so names nobody listed (`x-hasura-admin-secret`, `x-vault-token`) are + * caught too. WebDecoy's ingest service enforces the same pattern on its side, + * so an SDK that predates this file is covered as well. + */ +export const CREDENTIAL_HEADER = + /cookie|auth|token|secret|passw|session|credential|csrf|xsrf|api[-_]?key|access[-_]?key|private[-_]?key/i; + +/** + * WebDecoy's own clearance cookie. It is issued by WebDecoy, carries no + * credential for your site, and is how a cleared client is recognised across + * requests, so it is the one cookie a report keeps. + */ +const CLEARANCE_COOKIE = 'wd_clearance'; + +/** `headers` with every credential value removed. Names are kept. */ +export function reportableHeaders(headers: Record): Record { + const out: Record = {}; + for (const [name, value] of Object.entries(headers)) { + if (!CREDENTIAL_HEADER.test(name)) { + out[name] = value; + continue; + } + out[name] = name.toLowerCase() === 'cookie' ? clearanceCookieOnly(value) : ''; + } + return out; +} + +function clearanceCookieOnly(cookie: string): string { + return String(cookie ?? '') + .split(';') + .map((pair) => pair.trim()) + .filter((pair) => pair.slice(0, pair.indexOf('=')).trim() === CLEARANCE_COOKIE) + .join('; '); +} diff --git a/packages/webdecoy/src/tls-info-wire.test.ts b/packages/webdecoy/src/tls-info-wire.test.ts index dd7fb52..c29eeda 100644 --- a/packages/webdecoy/src/tls-info-wire.test.ts +++ b/packages/webdecoy/src/tls-info-wire.test.ts @@ -81,6 +81,6 @@ describe('tls_info on the wire', () => { it('leaves a request without the deprecated name untouched', () => { const req = request({ cipher_suites: [4865], alpn: ['h2'] }); - expect(toWireDetectionRequest(req)).toBe(req); + expect(toWireDetectionRequest(req)).toEqual(req); }); }); diff --git a/packages/webdecoy/src/types.ts b/packages/webdecoy/src/types.ts index 14c4713..5d68dbb 100644 --- a/packages/webdecoy/src/types.ts +++ b/packages/webdecoy/src/types.ts @@ -191,7 +191,10 @@ export interface RequestMetadata { /** User-Agent header */ user_agent?: string; - /** All request headers */ + /** + * All request headers. Rules see every value; a detection report sends + * credential headers (cookies, tokens, API keys) by name only. + */ headers: Record; /**