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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **`protect()` returns a typed decision.** It used to return `{ allowed, detection }`, and the adapters typed the value handed to `onBlocked` as `any`.
- `conclusion: 'ALLOW' | 'DENY' | 'CHALLENGE' | 'ERROR'`, with `isAllowed()` / `isDenied()` / `isChallenged()` / `isErrored()` and `deniedBy(rule)`. `ERROR` is a distinct conclusion, so a caller can tell "allowed" from "never decided" — both still serve the request.
- `results` — every configured rule in evaluation order with a `state` of `RUN`, `DRY_RUN`, `NOT_RUN` or `CACHED`. `NOT_RUN` is new information: a `filter()` rule with no IP enrichment, or a `webBotAuth()` rule on a request with no host, used to report ALLOW, which reads as "checked and fine" rather than "never checked". A dry-run rule that matched now reports `conclusion: 'DENY'` with `state: 'DRY_RUN'`, rather than the ALLOW its action said.
- `id` — a random `dec_…` id, also stamped on `detection.detection_id`. The old `'rule_' + Date.now()` was not unique under concurrency and correlated with nothing.
- `onBlocked` receives the full decision as a trailing argument in all three adapters, and `detection` is typed. Existing handlers are unaffected.
- `allowed` is unchanged, including failing open on error, so existing middleware keeps working.

- **`characteristics`** — what the SDK treats as the same caller, for keyed rules and the decision cache. Defaults to `['ip']`; accepts `'path'`, `'method'`, `'userAgent'`, or a function over the rule context. A rule's own `keyBy` still wins. When a characteristic is absent the key falls back to the IP, rather than bucketing every request missing that field into one bucket — which is how a limit meant for one tenant takes out anonymous traffic site-wide.

- **Decision caching.** A server-derived `DENY` or `CHALLENGE` is reused for its TTL instead of re-asking the service about a caller it just answered for. Deliberately narrow: `ALLOW` is never cached (that is how a client that has since started misbehaving keeps sailing through, and it saves the cheap request), and rule outcomes are never cached (a rate limiter has to see every request, and a cached tripwire hit would stop the violation being reported). Configure with `decisionCache: { ttl, max }` or disable with `false`.

## [0.12.0] - 2026-08-22

### Fixed
Expand Down
62 changes: 47 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,26 +255,58 @@ Additional local rules for the `rules` array. `filter()` requires an API key for

Local Web Bot Auth verification (RFC 9421). `webBotAuth()` returns a `Rule` that denies agent impersonation; `detectBot(request)` returns the verdict directly for custom handling. See the [Web Bot Auth guide](docs/verify-ai-agents-web-bot-auth.md). Exported types: `AgentVerdict`, `AgentStatus`, `AgentCategory`, `WebBotAuthConfig`, `AgentVerifierOptions`, `SignedAgentDirectory`.

### `protect(metadata, options?): Promise<ProtectResult>`
### `protect(metadata, options?): Promise<Decision>`

Full analysis of a request (platform feature). Returns a decision:
Full analysis of a request. Returns a typed decision:

```typescript
interface ProtectResult {
allowed: boolean;
detection: {
decision: 'allow' | 'block' | 'challenge';
confidence: number; // 0–100 threat score
threat_level: 'MINIMAL' | 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
bot_detected: boolean;
bot_type?: string; // e.g. "curl", "selenium"
detection_id: string;
rule_enforced: boolean;
};
error?: string;
}
const d = await wd.protect(metadata);

d.conclusion // 'ALLOW' | 'DENY' | 'CHALLENGE' | 'ERROR'
d.allowed // true for ALLOW and ERROR (fail open)
d.id // 'dec_…', correlates with the dashboard
d.isDenied() // narrowing helpers
d.deniedBy('tripwire') // which rule, without string-matching
d.results // every rule, in order, and what it concluded
d.detection // the service's response, as before
d.edge // what the edge validator said
```

`results` is the part worth knowing about. Every configured rule appears, with a
`state`:

| `state` | Meaning |
|---|---|
| `RUN` | Evaluated, and its conclusion counts. |
| `DRY_RUN` | Evaluated; conclusion recorded but not enforced. |
| `NOT_RUN` | Could not evaluate — a signal it needs was absent (a `filter()` with no IP enrichment, a `webBotAuth()` on a request with no host). |
| `CACHED` | Not evaluated; a prior decision for this key was reused. |

`NOT_RUN` is the one that used to be invisible: such a rule reported ALLOW, which
reads as "checked and fine" rather than "never checked". A dry-run rule that
matched reports `conclusion: 'DENY'` with `state: 'DRY_RUN'` — what it *would*
have done is the reason you turned it on.

`ERROR` is not a synonym for `DENY`. It means no verdict was reached, and the
request is allowed through.

### `characteristics` — what counts as the same caller

Rate limits and the decision cache key on the client IP by default. On an
authenticated API that is usually the wrong subject:

```typescript
const wd = new WebDecoy({
characteristics: [(ctx) => ctx.headers['x-api-key']],
rules: [rateLimit({ max: 100, window: 60 })],
});
```

Built-ins are `'ip'`, `'path'`, `'method'`, `'userAgent'`; a function derives
anything else. A rule's own `keyBy` still wins. If a characteristic is absent on
a request the key falls back to the IP, rather than bucketing every request
missing that field together.

All TypeScript types are exported (`WebDecoyConfig`, `RequestMetadata`, `ProtectResult`, `Rule`, `TripwireConfig`, `RateLimitConfig`, `FilterConfig`, `Honeytoken`, …).

## Examples
Expand Down
2 changes: 1 addition & 1 deletion packages/express/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"build": "tsup src/index.ts --format cjs,esm --dts --clean",
"dev": "tsup src/index.ts --format cjs,esm --dts --watch",
"test": "jest --passWithNoTests",
"lint": "eslint src --max-warnings 14",
"lint": "eslint src --max-warnings 12",
"clean": "rm -rf dist"
},
"keywords": [
Expand Down
24 changes: 20 additions & 4 deletions packages/express/src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@

import { Request, Response, NextFunction } from 'express';
import { WebDecoy, WebDecoyConfig, RequestMetadata, ProtectOptions } from '@webdecoy/node';
import type { EdgeVerdict, SiteHoneytoken, TrustedProxies } from '@webdecoy/node';
import type {
EdgeVerdict,
SiteHoneytoken,
TrustedProxies,
ProtectResult,
SDKDetectionResponse,
} from '@webdecoy/node';
import {
siteHoneytoken,
injectHoneytokenLink,
Expand Down Expand Up @@ -85,8 +91,18 @@
* `next` is passed so a handler can record the verdict and continue — the
* omission that made monitoring impossible. Call exactly one of `next()` or a
* response method.
*
* `decision` is the full typed verdict — `conclusion`, every rule's outcome
* including the ones that dry-ran or never ran, and `deniedBy('tripwire')` —
* for handlers that need to know WHY rather than just THAT.
*/
onBlocked?: (req: Request, res: Response, detection: any, next: NextFunction) => void;
onBlocked?: (
req: Request,
res: Response,
detection: SDKDetectionResponse,
next: NextFunction,
decision: ProtectResult,
) => void;

/**
* Custom function to handle errors
Expand Down Expand Up @@ -135,7 +151,7 @@
function defaultOnBlocked(
req: Request,
res: Response,
detection: any,
detection: SDKDetectionResponse,
_next: NextFunction,
): void {
res.status(403).json({
Expand Down Expand Up @@ -291,13 +307,13 @@
return intercepting;
};

(res as any).write = function (chunk: any, ...rest: any[]): boolean {

Check warning on line 310 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (20)

Unexpected any. Specify a different type

Check warning on line 310 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (20)

Unexpected any. Specify a different type

Check warning on line 310 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (20)

Unexpected any. Specify a different type

Check warning on line 310 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (22)

Unexpected any. Specify a different type

Check warning on line 310 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (22)

Unexpected any. Specify a different type

Check warning on line 310 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (22)

Unexpected any. Specify a different type
if (!shouldIntercept()) return originalWrite(chunk, ...rest);
if (chunk) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
return true;
};

(res as any).end = function (chunk: any, ...rest: any[]): any {

Check warning on line 316 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (20)

Unexpected any. Specify a different type

Check warning on line 316 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (20)

Unexpected any. Specify a different type

Check warning on line 316 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (20)

Unexpected any. Specify a different type

Check warning on line 316 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (20)

Unexpected any. Specify a different type

Check warning on line 316 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (22)

Unexpected any. Specify a different type

Check warning on line 316 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (22)

Unexpected any. Specify a different type

Check warning on line 316 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (22)

Unexpected any. Specify a different type

Check warning on line 316 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (22)

Unexpected any. Specify a different type
try {
if (!shouldIntercept()) return originalEnd(chunk, ...rest);
if (chunk && typeof chunk !== 'function') {
Expand Down Expand Up @@ -326,9 +342,9 @@
// rules". An earlier draft of this put the check after them and would
// have shipped exactly the bug it exists to fix.
if (mode === 'monitor') {
(req as any).webdecoy = result.detection;

Check warning on line 345 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (20)

Unexpected any. Specify a different type
(req as any).webdecoyEdge = result.edge;

Check warning on line 346 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (20)

Unexpected any. Specify a different type
(req as any).webdecoyWouldBlock = !result.allowed;

Check warning on line 347 in packages/express/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (20)

Unexpected any. Specify a different type
return next();
}

Expand Down Expand Up @@ -369,7 +385,7 @@
return next();
} else {
// Block the request
return onBlocked(req, res, result.detection, next);
return onBlocked(req, res, result.detection, next, result);
}
} catch (error) {
onError(req, res, error as Error);
Expand Down
2 changes: 1 addition & 1 deletion packages/fastify/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"build": "tsup src/index.ts --format cjs,esm --dts --clean",
"dev": "tsup src/index.ts --format cjs,esm --dts --watch",
"test": "jest --passWithNoTests",
"lint": "eslint src --max-warnings 2",
"lint": "eslint src --max-warnings 0",
"clean": "rm -rf dist"
},
"keywords": [
Expand Down
31 changes: 25 additions & 6 deletions packages/fastify/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,13 @@ import {
resolveClientIp,
normalizeIp,
} from '@webdecoy/node';
import type { EdgeVerdict, SiteHoneytoken, TrustedProxies } from '@webdecoy/node';
import type {
EdgeVerdict,
SiteHoneytoken,
TrustedProxies,
ProtectResult,
SDKDetectionResponse,
} from '@webdecoy/node';

export interface WebDecoyPluginOptions extends ProtectOptions {
/**
Expand Down Expand Up @@ -72,10 +78,19 @@ export interface WebDecoyPluginOptions extends ProtectOptions {
getIP?: (req: FastifyRequest) => string;

/**
* Custom function to handle blocked requests
* By default, returns 403 Forbidden
* Called when a request would be blocked.
*
* `detection` is the detection response, as before. `decision` is the full
* typed verdict — `conclusion`, every rule's outcome including the ones that
* dry-ran or never ran, and `deniedBy('tripwire')` — for handlers that need to
* know WHY rather than just THAT.
*/
onBlocked?: (req: FastifyRequest, reply: FastifyReply, detection: any) => void;
onBlocked?: (
req: FastifyRequest,
reply: FastifyReply,
detection: SDKDetectionResponse,
decision: ProtectResult,
) => void;

/**
* Custom function to handle errors
Expand Down Expand Up @@ -118,7 +133,11 @@ function resolveIP(req: FastifyRequest, trustProxy: TrustedProxies | undefined):
/**
* Default blocked request handler
*/
function defaultOnBlocked(req: FastifyRequest, reply: FastifyReply, detection: any): void {
function defaultOnBlocked(
req: FastifyRequest,
reply: FastifyReply,
detection: SDKDetectionResponse,
): void {
reply.status(403).send({
error: 'Forbidden',
message: 'Access denied by Web Decoy protection',
Expand Down Expand Up @@ -303,7 +322,7 @@ async function webdecoyPluginImpl(
req.webdecoyEdge = result.edge;
} else {
// Block the request
onBlocked(req, reply, result.detection);
onBlocked(req, reply, result.detection, result);
}
} catch (error) {
onError(req, reply, error as Error);
Expand Down
2 changes: 1 addition & 1 deletion packages/nextjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"build": "tsup src/index.ts --format cjs,esm --dts --clean",
"dev": "tsup src/index.ts --format cjs,esm --dts --watch",
"test": "jest --passWithNoTests",
"lint": "eslint src --max-warnings 5",
"lint": "eslint src --max-warnings 3",
"clean": "rm -rf dist",
"check:edge": "node ../../scripts/check-edge.mjs src/index.ts"
},
Expand Down
20 changes: 16 additions & 4 deletions packages/nextjs/src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
resolveClientIp,
normalizeIp,
} from '@webdecoy/node';
import type { TrustedProxies } from '@webdecoy/node';
import type { TrustedProxies, ProtectResult, SDKDetectionResponse } from '@webdecoy/node';

export interface WebDecoyMiddlewareOptions extends ProtectOptions {
/**
Expand Down Expand Up @@ -58,7 +58,19 @@
* Custom function to handle blocked requests
* By default, returns 403 Forbidden JSON response
*/
onBlocked?: (req: NextRequest, detection: any) => NextResponse;
/**
* Called when a request would be blocked.
*
* `detection` is the detection response, as before. `decision` is the full
* typed verdict — `conclusion`, every rule's outcome including the ones that
* dry-ran or never ran, and `deniedBy('tripwire')` — for handlers that need to
* know WHY rather than just THAT.
*/
onBlocked?: (
req: NextRequest,
detection: SDKDetectionResponse,
decision: ProtectResult,
) => NextResponse;

/**
* Custom function to handle errors
Expand Down Expand Up @@ -103,7 +115,7 @@
/**
* Default blocked request handler
*/
function defaultOnBlocked(req: NextRequest, detection: any): NextResponse {
function defaultOnBlocked(req: NextRequest, detection: SDKDetectionResponse): NextResponse {
return NextResponse.json(
{
error: 'Forbidden',
Expand Down Expand Up @@ -274,7 +286,7 @@
}
return NextResponse.next({ request: { headers: requestHeaders } });
} else {
return onBlocked(req, result.detection);
return onBlocked(req, result.detection, result);
}
} catch (error) {
const errorResponse = onError(req, error as Error);
Expand Down Expand Up @@ -312,7 +324,7 @@
* });
* ```
*/
export function withBotProtection<T extends (...args: any[]) => any>(

Check warning on line 327 in packages/nextjs/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (22)

Unexpected any. Specify a different type

Check warning on line 327 in packages/nextjs/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (22)

Unexpected any. Specify a different type
handler: T,
config: WebDecoyConfig & WithBotProtectionOptions
): T {
Expand Down Expand Up @@ -367,7 +379,7 @@
}

// Attach detection info to request
(req as any).webdecoy = result.detection;

Check warning on line 382 in packages/nextjs/src/middleware.ts

View workflow job for this annotation

GitHub Actions / Build (22)

Unexpected any. Specify a different type
} catch (error) {
console.error('[WebDecoy] Protection error:', error);
// Fail open
Expand Down
80 changes: 80 additions & 0 deletions packages/webdecoy/src/characteristics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* What the SDK considers "the same caller".
*
* WHY THIS MODULE EXISTS
*
* `rateLimit({ keyBy })` was the only place a caller could change what a rule
* keyed on, and it was per-rule. Everything else — the decision cache, and any
* future keyed rule — was IP-only.
*
* That is the wrong subject for exactly the traffic worth limiting. On an
* authenticated API the meaningful caller is a user id or an API key, not an
* address shared by a whole office or rotated through a proxy pool. It is also
* the wrong subject for us specifically: the actor model exists because IP is
* not identity.
*/

import type { RuleContext } from './rules/types';

/**
* One component of the key that identifies a caller.
*
* A string names a field of the request; a function derives whatever you like
* from the context (a decoded JWT subject, a tenant id, an API key header).
*/
export type Characteristic =
| 'ip'
| 'path'
| 'method'
| 'userAgent'
| ((context: RuleContext) => string | undefined);

/** The default: one bucket per client address. */
export const DEFAULT_CHARACTERISTICS: readonly Characteristic[] = ['ip'];

function resolveOne(context: RuleContext, c: Characteristic): string | undefined {
if (typeof c === 'function') {
try {
return c(context) || undefined;
} catch {
// A characteristic that throws is a bug in the caller's code, but it must
// not take the request down. Treat it as absent and fall back below.
return undefined;
}
}
switch (c) {
case 'ip':
return context.ip || undefined;
case 'path':
return context.path || undefined;
case 'method':
return context.method || undefined;
case 'userAgent':
return context.userAgent || undefined;
}
}

/**
* Derive the key identifying this caller.
*
* If any characteristic is absent the whole key falls back to the IP. The
* alternative — a key with an empty component — silently merges every request
* missing that field into one bucket, so an unauthenticated request would share
* a rate limit with every other unauthenticated request. That is the failure
* mode where a limit meant for one tenant takes out anonymous traffic site-wide,
* and it is invisible until it happens.
*/
export function deriveKey(
context: RuleContext,
characteristics: readonly Characteristic[] = DEFAULT_CHARACTERISTICS,
): string {
if (characteristics.length === 0) return context.ip;

const parts: string[] = [];
for (const c of characteristics) {
const value = resolveOne(context, c);
if (value === undefined) return context.ip;
parts.push(value);
}
return parts.join('|');
}
Loading