Skip to content

Commit e5b578e

Browse files
authored
feat: reserved WebDecoy-Test User-Agent always reports a test detection (#20)
curl -A "WebDecoy-Test/1.0" http://localhost:3000/ now produces a guaranteed detection through the normal /api/v1/sdk/detect path — before rules, before local-analysis thresholds — so a fresh install can prove itself from localhost. The verdict is blocking (enforce-mode adapters return 403; the default monitor mode serves the request and records it). Ingest recognizes the same prefix, labels the row as a test, and keeps it out of stats, billing, and enforcement. Exports: isTestTriggerUserAgent, TEST_TRIGGER_UA_PREFIX, TEST_TRIGGER_USER_AGENT.
1 parent 83f592b commit e5b578e

4 files changed

Lines changed: 230 additions & 0 deletions

File tree

‎packages/webdecoy/src/index.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,14 @@ export type {
3737
ProtectOptions,
3838
} from './types';
3939

40+
// The reserved test trigger: `curl -A "WebDecoy-Test/1.0" <site>`
41+
// always produces a labeled test detection through the real pipeline.
42+
export {
43+
isTestTriggerUserAgent,
44+
TEST_TRIGGER_UA_PREFIX,
45+
TEST_TRIGGER_USER_AGENT,
46+
} from './test-trigger';
47+
4048
// The edge validator's verdict, as the origin sees it. Exported as a
4149
// value, not only a type: readEdgeVerdict() is how an application that is not
4250
// using protect() — a route handler, a server component — reads the tag without

‎packages/webdecoy/src/sdk.ts‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,15 @@ import { AgentVerifier } from './agent/verifier';
1313
import type { AgentRequestInput, AgentVerdict } from './agent/types';
1414
import { readEdgeVerdict } from './edge';
1515
import { classifyUserAgent } from './bots';
16+
import { isTestTriggerUserAgent } from './test-trigger';
1617
import type { RuleContext, RuleEngineResult, ViolationEvent } from './rules/types';
1718
import {
1819
WebDecoyConfig,
1920
RequestMetadata,
2021
ProtectResult,
2122
ProtectOptions,
2223
SDKDetectionRequest,
24+
SDKDetectionResponse,
2325
} from './types';
2426

2527
export class WebDecoy {
@@ -320,6 +322,15 @@ export class WebDecoy {
320322
metadata.timestamp = Date.now();
321323
}
322324

325+
// The reserved test trigger: `curl -A "WebDecoy-Test/1.0"` from the
326+
// quickstart. Handled BEFORE rules and before any local-analysis
327+
// threshold: the documented one-liner must always produce a detection,
328+
// and it must never fire the customer's rules — ingest marks the row
329+
// is_test and keeps it out of stats, billing, and enforcement.
330+
if (isTestTriggerUserAgent(metadata.user_agent)) {
331+
return this.reportTestTrigger(metadata);
332+
}
333+
323334
// Evaluate rules first (if configured). Use async evaluation when a rule
324335
// needs a pre-fetched signal — IP enrichment (filter rules) or Web Bot
325336
// Auth verification (webBotAuth rules). Capture the agent verdict so it
@@ -472,6 +483,60 @@ export class WebDecoy {
472483
}
473484
}
474485

486+
/**
487+
* Report a test-trigger request and return a blocking verdict.
488+
*
489+
* The verdict is `allowed: false` so an enforce-mode adapter answers the
490+
* curl with a 403 — a visibly different response that tells the developer
491+
* their middleware acted, Arcjet-quickstart style. (The default monitor
492+
* mode still serves the request; the dashboard row is the real receipt.)
493+
*
494+
* Without an API key nothing can reach the dashboard, so the verdict says
495+
* so via `error` instead of pretending the test ran.
496+
*/
497+
private async reportTestTrigger(metadata: RequestMetadata): Promise<ProtectResult> {
498+
const blocked: SDKDetectionResponse = {
499+
decision: 'block',
500+
confidence: 100,
501+
threat_level: 'HIGH',
502+
bot_detected: true,
503+
bot_type: 'test_trigger',
504+
detection_id: 'test_' + Date.now(),
505+
rule_enforced: false,
506+
};
507+
508+
if (!this.client) {
509+
return {
510+
allowed: false,
511+
detection: blocked,
512+
error: 'Test trigger recognized, but no apiKey is configured — nothing was reported to the dashboard.',
513+
};
514+
}
515+
516+
try {
517+
const detection = await this.client.detect({
518+
request_metadata: metadata,
519+
local_analysis: {
520+
suspicious_headers: false,
521+
missing_sec_ch_ua: false,
522+
datacenter_ip: false,
523+
local_score: 100,
524+
needs_verification: true,
525+
flags: ['test_trigger'],
526+
},
527+
});
528+
return { allowed: false, detection };
529+
} catch (error) {
530+
// Still block — the developer asked for a visible reaction — but say
531+
// why the dashboard may show nothing.
532+
return {
533+
allowed: false,
534+
detection: blocked,
535+
error: error instanceof Error ? error.message : 'Failed to report test detection',
536+
};
537+
}
538+
}
539+
475540
/**
476541
* Validate the API key configuration
477542
* Useful for testing integration during setup
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/**
2+
* The reserved test trigger: `curl -A "WebDecoy-Test/1.0" <site>` must always
3+
* report a detection through the normal ingest path — before rules, before
4+
* local-analysis thresholds — so a fresh install can prove itself from
5+
* localhost. See docs quickstarts.
6+
*/
7+
8+
import { WebDecoy } from './sdk';
9+
import { isTestTriggerUserAgent, TEST_TRIGGER_USER_AGENT } from './test-trigger';
10+
import { rateLimit } from './rules';
11+
import type { RequestMetadata } from './types';
12+
13+
function metadata(userAgent?: string): RequestMetadata {
14+
return {
15+
method: 'GET',
16+
path: '/',
17+
ip: '203.0.113.7',
18+
user_agent: userAgent,
19+
headers: userAgent ? { 'user-agent': userAgent } : {},
20+
timestamp: Date.now(),
21+
};
22+
}
23+
24+
describe('isTestTriggerUserAgent', () => {
25+
it('matches the documented UA, case-insensitively, prefix-anchored', () => {
26+
expect(isTestTriggerUserAgent(TEST_TRIGGER_USER_AGENT)).toBe(true);
27+
expect(isTestTriggerUserAgent('webdecoy-test/2.1')).toBe(true);
28+
expect(isTestTriggerUserAgent(' WebDecoy-Test/1.0')).toBe(true);
29+
30+
expect(isTestTriggerUserAgent(undefined)).toBe(false);
31+
expect(isTestTriggerUserAgent('')).toBe(false);
32+
expect(isTestTriggerUserAgent('WebDecoy-Test')).toBe(false);
33+
expect(isTestTriggerUserAgent('Mozilla/5.0 (compatible; WebDecoy-Test/1.0)')).toBe(false);
34+
});
35+
});
36+
37+
describe('protect() on the test trigger', () => {
38+
const realFetch = global.fetch;
39+
afterEach(() => {
40+
global.fetch = realFetch;
41+
});
42+
43+
it('reports to ingest and returns the server verdict as blocked', async () => {
44+
const calls: Array<{ url: string; body: any }> = [];
45+
global.fetch = jest.fn(async (url: any, init: any) => {
46+
calls.push({ url: String(url), body: JSON.parse(init.body) });
47+
return new Response(
48+
JSON.stringify({
49+
decision: 'block',
50+
confidence: 100,
51+
threat_level: 'CRITICAL',
52+
bot_detected: true,
53+
bot_type: 'test_trigger',
54+
detection_id: 'd-123',
55+
rule_enforced: false,
56+
}),
57+
{ status: 200 },
58+
);
59+
}) as any;
60+
61+
const sdk = new WebDecoy({ apiKey: 'sk_test_key', apiUrl: 'https://ingest.example' });
62+
const result = await sdk.protect(metadata(TEST_TRIGGER_USER_AGENT));
63+
64+
expect(result.allowed).toBe(false);
65+
expect(result.detection.detection_id).toBe('d-123');
66+
expect(calls).toHaveLength(1);
67+
expect(calls[0].url).toBe('https://ingest.example/api/v1/sdk/detect');
68+
expect(calls[0].body.local_analysis.flags).toEqual(['test_trigger']);
69+
expect(calls[0].body.local_analysis.local_score).toBe(100);
70+
expect(calls[0].body.request_metadata.user_agent).toBe(TEST_TRIGGER_USER_AGENT);
71+
});
72+
73+
it('bypasses rules: a 1-request rate limit never throttles the trigger', async () => {
74+
global.fetch = jest.fn(async () =>
75+
new Response(
76+
JSON.stringify({
77+
decision: 'block',
78+
confidence: 100,
79+
threat_level: 'CRITICAL',
80+
bot_detected: true,
81+
detection_id: 'd-1',
82+
rule_enforced: false,
83+
}),
84+
{ status: 200 },
85+
),
86+
) as any;
87+
88+
const sdk = new WebDecoy({
89+
apiKey: 'sk_test_key',
90+
apiUrl: 'https://ingest.example',
91+
rules: [rateLimit({ max: 1, window: 60 })],
92+
});
93+
94+
// Both calls must reach ingest — neither may come back as a rule verdict.
95+
const first = await sdk.protect(metadata(TEST_TRIGGER_USER_AGENT));
96+
const second = await sdk.protect(metadata(TEST_TRIGGER_USER_AGENT));
97+
expect(first.ruleResult).toBeUndefined();
98+
expect(second.ruleResult).toBeUndefined();
99+
expect((global.fetch as jest.Mock).mock.calls).toHaveLength(2);
100+
});
101+
102+
it('still blocks without an apiKey, but says nothing was reported', async () => {
103+
const sdk = new WebDecoy({});
104+
const result = await sdk.protect(metadata(TEST_TRIGGER_USER_AGENT));
105+
expect(result.allowed).toBe(false);
106+
expect(result.error).toMatch(/apiKey/);
107+
});
108+
109+
it('still blocks when ingest is unreachable, and surfaces the error', async () => {
110+
global.fetch = jest.fn(async () => {
111+
throw new TypeError('fetch failed');
112+
}) as any;
113+
114+
const sdk = new WebDecoy({ apiKey: 'sk_test_key', apiUrl: 'https://ingest.example' });
115+
const result = await sdk.protect(metadata(TEST_TRIGGER_USER_AGENT));
116+
expect(result.allowed).toBe(false);
117+
expect(result.error).toBeTruthy();
118+
});
119+
120+
it('leaves ordinary traffic alone', async () => {
121+
const sdk = new WebDecoy({});
122+
const result = await sdk.protect(
123+
metadata('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'),
124+
);
125+
expect(result.allowed).toBe(true);
126+
});
127+
});
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/**
2+
* The reserved test trigger.
3+
*
4+
* A request whose User-Agent starts with `WebDecoy-Test/` is the documented
5+
* install-verification probe:
6+
*
7+
* curl -A "WebDecoy-Test/1.0" http://localhost:3000/
8+
*
9+
* The SDK always reports it to ingest — before rules, before local-analysis
10+
* thresholds, before sampling of any kind — so the developer who just
11+
* installed the middleware gets a guaranteed, labeled detection in the
12+
* dashboard within seconds, including from localhost. Ingest recognizes the
13+
* same prefix server-side, marks the row `is_test`, and keeps it out of
14+
* stats, billing, actor scoring, and enforcement.
15+
*
16+
* Prefix-anchored on purpose: a UA that merely mentions the string mid-way
17+
* is NOT the trigger, so real traffic can't hide behind the test label.
18+
*/
19+
20+
/** Any User-Agent starting with this (case-insensitive) is a test trigger. */
21+
export const TEST_TRIGGER_UA_PREFIX = 'WebDecoy-Test/';
22+
23+
/** The exact value the quickstart docs tell developers to send. */
24+
export const TEST_TRIGGER_USER_AGENT = 'WebDecoy-Test/1.0';
25+
26+
/** Whether this User-Agent is the reserved test trigger. */
27+
export function isTestTriggerUserAgent(userAgent: string | undefined): boolean {
28+
if (!userAgent) return false;
29+
return userAgent.trimStart().toLowerCase().startsWith(TEST_TRIGGER_UA_PREFIX.toLowerCase());
30+
}

0 commit comments

Comments
 (0)