diff --git a/packages/webdecoy/src/index.ts b/packages/webdecoy/src/index.ts index 38ad4e2..00d5f8c 100644 --- a/packages/webdecoy/src/index.ts +++ b/packages/webdecoy/src/index.ts @@ -37,6 +37,14 @@ export type { ProtectOptions, } from './types'; +// The reserved test trigger: `curl -A "WebDecoy-Test/1.0" ` +// always produces a labeled test detection through the real pipeline. +export { + isTestTriggerUserAgent, + TEST_TRIGGER_UA_PREFIX, + TEST_TRIGGER_USER_AGENT, +} from './test-trigger'; + // The edge validator's verdict, as the origin sees it. Exported as a // value, not only a type: readEdgeVerdict() is how an application that is not // using protect() — a route handler, a server component — reads the tag without diff --git a/packages/webdecoy/src/sdk.ts b/packages/webdecoy/src/sdk.ts index 7baeeed..e7a63ea 100644 --- a/packages/webdecoy/src/sdk.ts +++ b/packages/webdecoy/src/sdk.ts @@ -13,6 +13,7 @@ import { AgentVerifier } from './agent/verifier'; import type { AgentRequestInput, AgentVerdict } from './agent/types'; import { readEdgeVerdict } from './edge'; import { classifyUserAgent } from './bots'; +import { isTestTriggerUserAgent } from './test-trigger'; import type { RuleContext, RuleEngineResult, ViolationEvent } from './rules/types'; import { WebDecoyConfig, @@ -20,6 +21,7 @@ import { ProtectResult, ProtectOptions, SDKDetectionRequest, + SDKDetectionResponse, } from './types'; export class WebDecoy { @@ -320,6 +322,15 @@ export class WebDecoy { metadata.timestamp = Date.now(); } + // The reserved test trigger: `curl -A "WebDecoy-Test/1.0"` from the + // quickstart. Handled BEFORE rules and before any local-analysis + // threshold: the documented one-liner must always produce a detection, + // and it must never fire the customer's rules — ingest marks the row + // is_test and keeps it out of stats, billing, and enforcement. + if (isTestTriggerUserAgent(metadata.user_agent)) { + return this.reportTestTrigger(metadata); + } + // Evaluate rules first (if configured). Use async evaluation when a rule // needs a pre-fetched signal — IP enrichment (filter rules) or Web Bot // Auth verification (webBotAuth rules). Capture the agent verdict so it @@ -472,6 +483,60 @@ export class WebDecoy { } } + /** + * Report a test-trigger request and return a blocking verdict. + * + * The verdict is `allowed: false` so an enforce-mode adapter answers the + * curl with a 403 — a visibly different response that tells the developer + * their middleware acted, Arcjet-quickstart style. (The default monitor + * mode still serves the request; the dashboard row is the real receipt.) + * + * Without an API key nothing can reach the dashboard, so the verdict says + * so via `error` instead of pretending the test ran. + */ + private async reportTestTrigger(metadata: RequestMetadata): Promise { + const blocked: SDKDetectionResponse = { + decision: 'block', + confidence: 100, + threat_level: 'HIGH', + bot_detected: true, + bot_type: 'test_trigger', + detection_id: 'test_' + Date.now(), + rule_enforced: false, + }; + + if (!this.client) { + return { + allowed: false, + detection: blocked, + error: 'Test trigger recognized, but no apiKey is configured — nothing was reported to the dashboard.', + }; + } + + try { + const detection = await this.client.detect({ + request_metadata: metadata, + local_analysis: { + suspicious_headers: false, + missing_sec_ch_ua: false, + datacenter_ip: false, + local_score: 100, + needs_verification: true, + flags: ['test_trigger'], + }, + }); + return { allowed: false, detection }; + } catch (error) { + // Still block — the developer asked for a visible reaction — but say + // why the dashboard may show nothing. + return { + allowed: false, + detection: blocked, + error: error instanceof Error ? error.message : 'Failed to report test detection', + }; + } + } + /** * Validate the API key configuration * Useful for testing integration during setup diff --git a/packages/webdecoy/src/test-trigger.test.ts b/packages/webdecoy/src/test-trigger.test.ts new file mode 100644 index 0000000..81a6081 --- /dev/null +++ b/packages/webdecoy/src/test-trigger.test.ts @@ -0,0 +1,127 @@ +/** + * The reserved test trigger: `curl -A "WebDecoy-Test/1.0" ` must always + * report a detection through the normal ingest path — before rules, before + * local-analysis thresholds — so a fresh install can prove itself from + * localhost. See docs quickstarts. + */ + +import { WebDecoy } from './sdk'; +import { isTestTriggerUserAgent, TEST_TRIGGER_USER_AGENT } from './test-trigger'; +import { rateLimit } from './rules'; +import type { RequestMetadata } from './types'; + +function metadata(userAgent?: string): RequestMetadata { + return { + method: 'GET', + path: '/', + ip: '203.0.113.7', + user_agent: userAgent, + headers: userAgent ? { 'user-agent': userAgent } : {}, + timestamp: Date.now(), + }; +} + +describe('isTestTriggerUserAgent', () => { + it('matches the documented UA, case-insensitively, prefix-anchored', () => { + expect(isTestTriggerUserAgent(TEST_TRIGGER_USER_AGENT)).toBe(true); + expect(isTestTriggerUserAgent('webdecoy-test/2.1')).toBe(true); + expect(isTestTriggerUserAgent(' WebDecoy-Test/1.0')).toBe(true); + + expect(isTestTriggerUserAgent(undefined)).toBe(false); + expect(isTestTriggerUserAgent('')).toBe(false); + expect(isTestTriggerUserAgent('WebDecoy-Test')).toBe(false); + expect(isTestTriggerUserAgent('Mozilla/5.0 (compatible; WebDecoy-Test/1.0)')).toBe(false); + }); +}); + +describe('protect() on the test trigger', () => { + const realFetch = global.fetch; + afterEach(() => { + global.fetch = realFetch; + }); + + it('reports to ingest and returns the server verdict as blocked', async () => { + const calls: Array<{ url: string; body: any }> = []; + global.fetch = jest.fn(async (url: any, init: any) => { + calls.push({ url: String(url), body: JSON.parse(init.body) }); + return new Response( + JSON.stringify({ + decision: 'block', + confidence: 100, + threat_level: 'CRITICAL', + bot_detected: true, + bot_type: 'test_trigger', + detection_id: 'd-123', + rule_enforced: false, + }), + { status: 200 }, + ); + }) as any; + + const sdk = new WebDecoy({ apiKey: 'sk_test_key', apiUrl: 'https://ingest.example' }); + const result = await sdk.protect(metadata(TEST_TRIGGER_USER_AGENT)); + + expect(result.allowed).toBe(false); + expect(result.detection.detection_id).toBe('d-123'); + expect(calls).toHaveLength(1); + expect(calls[0].url).toBe('https://ingest.example/api/v1/sdk/detect'); + expect(calls[0].body.local_analysis.flags).toEqual(['test_trigger']); + expect(calls[0].body.local_analysis.local_score).toBe(100); + expect(calls[0].body.request_metadata.user_agent).toBe(TEST_TRIGGER_USER_AGENT); + }); + + it('bypasses rules: a 1-request rate limit never throttles the trigger', async () => { + global.fetch = jest.fn(async () => + new Response( + JSON.stringify({ + decision: 'block', + confidence: 100, + threat_level: 'CRITICAL', + bot_detected: true, + detection_id: 'd-1', + rule_enforced: false, + }), + { status: 200 }, + ), + ) as any; + + const sdk = new WebDecoy({ + apiKey: 'sk_test_key', + apiUrl: 'https://ingest.example', + rules: [rateLimit({ max: 1, window: 60 })], + }); + + // Both calls must reach ingest — neither may come back as a rule verdict. + const first = await sdk.protect(metadata(TEST_TRIGGER_USER_AGENT)); + const second = await sdk.protect(metadata(TEST_TRIGGER_USER_AGENT)); + expect(first.ruleResult).toBeUndefined(); + expect(second.ruleResult).toBeUndefined(); + expect((global.fetch as jest.Mock).mock.calls).toHaveLength(2); + }); + + it('still blocks without an apiKey, but says nothing was reported', async () => { + const sdk = new WebDecoy({}); + const result = await sdk.protect(metadata(TEST_TRIGGER_USER_AGENT)); + expect(result.allowed).toBe(false); + expect(result.error).toMatch(/apiKey/); + }); + + it('still blocks when ingest is unreachable, and surfaces the error', async () => { + global.fetch = jest.fn(async () => { + throw new TypeError('fetch failed'); + }) as any; + + const sdk = new WebDecoy({ apiKey: 'sk_test_key', apiUrl: 'https://ingest.example' }); + const result = await sdk.protect(metadata(TEST_TRIGGER_USER_AGENT)); + expect(result.allowed).toBe(false); + expect(result.error).toBeTruthy(); + }); + + it('leaves ordinary traffic alone', async () => { + const sdk = new WebDecoy({}); + const result = await sdk.protect( + metadata('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'), + ); + expect(result.allowed).toBe(true); + }); +}); diff --git a/packages/webdecoy/src/test-trigger.ts b/packages/webdecoy/src/test-trigger.ts new file mode 100644 index 0000000..a9c9681 --- /dev/null +++ b/packages/webdecoy/src/test-trigger.ts @@ -0,0 +1,30 @@ +/** + * The reserved test trigger. + * + * A request whose User-Agent starts with `WebDecoy-Test/` is the documented + * install-verification probe: + * + * curl -A "WebDecoy-Test/1.0" http://localhost:3000/ + * + * The SDK always reports it to ingest — before rules, before local-analysis + * thresholds, before sampling of any kind — so the developer who just + * installed the middleware gets a guaranteed, labeled detection in the + * dashboard within seconds, including from localhost. Ingest recognizes the + * same prefix server-side, marks the row `is_test`, and keeps it out of + * stats, billing, actor scoring, and enforcement. + * + * Prefix-anchored on purpose: a UA that merely mentions the string mid-way + * is NOT the trigger, so real traffic can't hide behind the test label. + */ + +/** Any User-Agent starting with this (case-insensitive) is a test trigger. */ +export const TEST_TRIGGER_UA_PREFIX = 'WebDecoy-Test/'; + +/** The exact value the quickstart docs tell developers to send. */ +export const TEST_TRIGGER_USER_AGENT = 'WebDecoy-Test/1.0'; + +/** Whether this User-Agent is the reserved test trigger. */ +export function isTestTriggerUserAgent(userAgent: string | undefined): boolean { + if (!userAgent) return false; + return userAgent.trimStart().toLowerCase().startsWith(TEST_TRIGGER_UA_PREFIX.toLowerCase()); +}