Skip to content
Merged
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
8 changes: 8 additions & 0 deletions packages/webdecoy/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ export type {
ProtectOptions,
} from './types';

// The reserved test trigger: `curl -A "WebDecoy-Test/1.0" <site>`
// 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
Expand Down
65 changes: 65 additions & 0 deletions packages/webdecoy/src/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@ 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,
RequestMetadata,
ProtectResult,
ProtectOptions,
SDKDetectionRequest,
SDKDetectionResponse,
} from './types';

export class WebDecoy {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<ProtectResult> {
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
Expand Down
127 changes: 127 additions & 0 deletions packages/webdecoy/src/test-trigger.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/**
* The reserved test trigger: `curl -A "WebDecoy-Test/1.0" <site>` 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);
});
});
30 changes: 30 additions & 0 deletions packages/webdecoy/src/test-trigger.ts
Original file line number Diff line number Diff line change
@@ -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());
}