Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 20 additions & 14 deletions packages/webdecoy/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 };
}
19 changes: 19 additions & 0 deletions packages/webdecoy/src/invariants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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\(/);
});
});
119 changes: 119 additions & 0 deletions packages/webdecoy/src/report-headers.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>): 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);
}
});
});
49 changes: 49 additions & 0 deletions packages/webdecoy/src/report-headers.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>): Record<string, string> {
const out: Record<string, string> = {};
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('; ');
}
2 changes: 1 addition & 1 deletion packages/webdecoy/src/tls-info-wire.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
5 changes: 4 additions & 1 deletion packages/webdecoy/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;

/**
Expand Down
Loading