Skip to content

Commit 787bf08

Browse files
committed
feat(sdk): join the browser's signals to the requests that follow
@webdecoy/client collected behavioural, environmental and form signals, DetectionEngine scored them, and /score returned a verdict -- to the browser, which then forgot it. The origin never learned anything from the submission. Every part existed and nothing joined them, so the README mentioned the client package in one table row and never explained a path. That gap matters because this is the answer to the one thing a tripwire cannot catch: a Playwright-driven Chrome that browses only the links a human would. It has a genuine fingerprint, follows no hidden links and requests no honeypot paths -- and it still cannot fake having a person behind it. createCaptchaEndpoints({ signalStore }) now records the verdict against the browser's session and echoes the session id so the widget can set the cookie the rule reads. clientSignals({ store }) acts on it. A request with no session is NOT_RUN, never a denial. curl and Googlebot both send nothing, and scoring silence would deny exactly the crawlers we most need to keep -- which is also why the guide says to start in dryRun: this is the one rule in the SDK that is a judgement rather than a fact. The guide points at the npm package rather than a CDN URL. A versionless script tag with no integrity attribute is a supply-chain dependency on whoever is serving it, and I had no verified CDN path for this bundle anyway. Closes WebDecoy/app#733
1 parent a69e4a4 commit 787bf08

7 files changed

Lines changed: 624 additions & 2 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- **The client-signal path is wired end to end.** `@webdecoy/client` collected behavioural, environmental and form signals, `DetectionEngine` scored them, and `/score` returned a verdict — to the browser, which then forgot it. The origin never learned anything from the submission, and joining the two was left to the developer, so in practice nobody did. Now `createCaptchaEndpoints({ signalStore })` records the verdict against the browser's session, and `clientSignals({ store })` lets the requests that follow act on it. This is the SDK's answer to a Playwright-driven Chrome that browses only the links a human would: it has a genuine fingerprint and follows no hidden links, so no tripwire sees it, but it cannot fake having a person behind it. A request with no session is `NOT_RUN`, never a denial — curl and Googlebot both send nothing, and scoring silence would deny exactly the crawlers most worth keeping. Guide: `docs/client-signals.md`.
13+
1214
- **`@webdecoy/node/testing`** — helpers for the *application's* test suite. The SDK had hundreds of tests and a customer had none: there was no supported way to write "assert this request would be denied" against your own rules, so the first time anyone learned what the middleware does to their traffic was in production. `createTestHarness()` is offline by default (an API key in the environment is ignored, so a unit test never becomes a live call or files test traffic as a real detection) and gives each harness its own rule state. `request()`/`get()`/`post()`/`botRequest()` build metadata; `expectDenied`/`expectAllowed`/`expectRuleState` assert on the decision and print every rule and its state on failure; `protectMany()` runs a rate limit to its edge without sleeping.
1315

1416
- **A pluggable logger.** `logger` accepts anything with `debug`/`info`/`warn`/`error`, defaulting to the previous console behaviour. Warnings and errors are no longer gated on `debug` — a violation that failed to report is not diagnostic output. `fromPino()` wraps a pino-style logger, whose argument order is reversed; passing one directly type-checks and then silently drops every structured field.

‎README.md‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,38 @@ names the ones in your deny set that do not:
279279

280280
`policy.unenforceable` is the same list, in code. Requires no API key.
281281

282+
## Catching a real browser that isn't a real user
283+
284+
A Playwright-driven Chrome that browses only the links a human would presents a
285+
genuine fingerprint, follows no hidden links and requests no honeypot paths — the
286+
one thing a tripwire cannot see. What it cannot fake is having a person behind
287+
it.
288+
289+
```typescript
290+
import { MemoryClientSignalStore, clientSignals, tripwire } from '@webdecoy/node';
291+
292+
const signalStore = new MemoryClientSignalStore();
293+
294+
app.use(webdecoyCaptcha({ secret: process.env.WEBDECOY_SECRET, signalStore }));
295+
app.use(webdecoy({
296+
rules: [
297+
tripwire(), // intent — deterministic
298+
clientSignals({ store: signalStore, dryRun: true }), // interaction — probabilistic
299+
],
300+
}));
301+
```
302+
303+
`@webdecoy/client` collects behavioural, environmental and form signals in the
304+
browser; `/score` records the verdict against the session; `clientSignals()` acts
305+
on it for the requests that follow. Before this the score went back to the
306+
browser and the origin never learned anything from it.
307+
308+
**A request with no session is `NOT_RUN`, never a denial** — curl and Googlebot
309+
both send nothing, and scoring silence would deny exactly the crawlers you most
310+
need to keep. This augments the keyless rules; it does not replace them.
311+
312+
Full guide: [**Catching a real browser that isn't a real user**](docs/client-signals.md).
313+
282314
## Attack signatures
283315

284316
Tripwires catch scanners by the path they ask for. `attackSignatures()` looks at

‎docs/client-signals.md‎

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
# Catching a real browser that isn't a real user
2+
3+
Tripwires catch automation by intent: a hidden path a person can never reach, so
4+
any request for it is a bot by construction. That is deterministic and
5+
unspoofable, and it has one blind spot — a Playwright-driven Chrome that browses
6+
only the links a human would. It presents a genuine fingerprint, it follows no
7+
hidden links, and it requests no honeypot paths.
8+
9+
What it cannot fake is having a person behind it. `@webdecoy/client` collects
10+
that evidence in the browser, `DetectionEngine` scores it on your server, and
11+
`clientSignals()` lets the requests that follow act on the result.
12+
13+
**This augments the keyless rules; it does not replace them.** No JavaScript
14+
means no signals, and a request with no session is `NOT_RUN`, never a denial —
15+
curl and Googlebot both send nothing, and scoring silence would deny exactly the
16+
crawlers you most need to keep.
17+
18+
## 1. Serve the endpoints, with a store
19+
20+
The `/score` endpoint already existed. What is new is the store: without it the
21+
verdict goes back to the browser and your origin never learns anything from it.
22+
23+
```typescript
24+
import express from 'express';
25+
import { webdecoyCaptcha } from '@webdecoy/express';
26+
import { MemoryClientSignalStore } from '@webdecoy/node';
27+
28+
const signalStore = new MemoryClientSignalStore();
29+
30+
const app = express();
31+
app.use(express.json());
32+
app.use(webdecoyCaptcha({ secret: process.env.WEBDECOY_SECRET, signalStore }));
33+
```
34+
35+
`MemoryClientSignalStore` is per-process, like the rate limiter. On more than one
36+
replica implement `ClientSignalStore` over something shared, or the request after
37+
the submission may land on a different instance and find nothing.
38+
39+
## 2. Add the rule
40+
41+
```typescript
42+
import { webdecoy } from '@webdecoy/express';
43+
import { tripwire, clientSignals } from '@webdecoy/node';
44+
45+
app.use(webdecoy({
46+
rules: [
47+
tripwire(), // intent — deterministic
48+
clientSignals({ store: signalStore }), // interaction — probabilistic
49+
],
50+
}));
51+
```
52+
53+
By default the rule follows the engine's own recommendation. `minScore` overrides
54+
it with a threshold of your own, 0–1, higher being more bot-like.
55+
56+
Start with `dryRun: true`. This is the one rule in the SDK that is a judgement
57+
rather than a fact, and you want a day of your own traffic before it blocks
58+
anyone.
59+
60+
## 3. Load the widget
61+
62+
```bash
63+
npm install @webdecoy/client
64+
```
65+
66+
```typescript
67+
import { WebDecoyCaptcha } from '@webdecoy/client';
68+
69+
WebDecoyCaptcha.configure({ serverUrl: '' }); // same origin
70+
WebDecoyCaptcha.invisible({ action: 'browse' });
71+
```
72+
73+
The package also ships a prebuilt global bundle at
74+
`@webdecoy/client/global` for pages without a bundler. Serve it from your own
75+
origin rather than a third-party CDN, or pin a version and add
76+
`integrity`/`crossorigin` — a script tag with neither is a supply-chain
77+
dependency on whoever is serving it.
78+
79+
The widget submits to `/score` with a `sessionId` and sets the `wd_cs` cookie.
80+
The rule reads that cookie — or an `X-WD-Session` header, for a client that
81+
cannot use cookies.
82+
83+
## 4. Confirm it works
84+
85+
```typescript
86+
import { createTestHarness, request, expectDenied } from '@webdecoy/node/testing';
87+
88+
const wd = createTestHarness({ rules: [clientSignals({ store, minScore: 0.5 })] });
89+
expectDenied(await wd.protect(request({ headers: { cookie: 'wd_cs=sess-1' } })));
90+
```
91+
92+
Against a real browser: drive the page with Playwright, let the widget submit,
93+
and compare the recorded score with your own session. `webdriver` alone
94+
contributes to the score, and a session with no pointer movement, no scroll and
95+
no keystrokes contributes considerably more.
96+
97+
## What the signals are
98+
99+
The collection contract is the code — `summarizeBehavior()` in
100+
`@webdecoy/client` is the published list of what leaves the browser. In outline:
101+
102+
| Group | Examples |
103+
|---|---|
104+
| Behavioural | pointer trajectory, micro-tremor, velocity variance, scroll and key events |
105+
| Environmental | `navigator.webdriver`, plugin count, automation flags, CDP artifacts |
106+
| Temporal | time to first interaction, session duration, event deltas |
107+
| Form | per-field dwell times, paste versus keystroke, submit timing |
108+
109+
No page content, no form values, no cookies other than the session id.
110+
111+
## Limits worth knowing
112+
113+
- **A client signal is a claim by code running on the client.** A determined
114+
attacker can lie to it. Its value is that most automation does not bother, and
115+
that faking human interaction convincingly is much harder than faking a
116+
fingerprint.
117+
- **The score is probabilistic.** Unlike a tripwire hit, a high score is not
118+
proof. That is why `dryRun` is the recommended starting point and why the
119+
deterministic rules stay in the list.
120+
- **Sessions expire** after 15 minutes by default.

‎packages/webdecoy/src/captcha/http.ts‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
import { Captcha, type CaptchaOptions } from './service';
1515
import type { Signals } from '../detection/types';
16+
import type { ClientSignalStore } from '../client-signals';
1617

1718
/** Normalized inbound request the adapters construct. */
1819
export interface CaptchaRequest {
@@ -36,6 +37,12 @@ export interface CaptchaHttpResponse {
3637
}
3738

3839
export interface CaptchaEndpointsOptions extends CaptchaOptions {
40+
/**
41+
* Where `/score` records its verdict, for `clientSignals()` to read on
42+
* subsequent requests. Omit and the score is returned to the browser and
43+
* forgotten, which is what happened before this existed.
44+
*/
45+
signalStore?: ClientSignalStore;
3946
/** Base path the routes are mounted under (default `/__webdecoy`). */
4047
basePath?: string;
4148
}
@@ -48,6 +55,8 @@ interface VerifyBody {
4855
powTiming?: { duration?: number; iterations?: number } | null;
4956
action?: string;
5057
token?: string;
58+
/** The browser widget's session id, sent by `@webdecoy/client`. */
59+
sessionId?: string;
5160
}
5261

5362
const JSON_HEADERS = { 'content-type': 'application/json' };
@@ -58,7 +67,7 @@ const JSON_HEADERS = { 'content-type': 'application/json' };
5867
* middleware can fall through to the next handler).
5968
*/
6069
export function createCaptchaEndpoints(options: CaptchaEndpointsOptions = {}) {
61-
const { basePath = '/__webdecoy', ...captchaOptions } = options;
70+
const { basePath = '/__webdecoy', signalStore, ...captchaOptions } = options;
6271
const captcha = new Captcha(captchaOptions);
6372
const base = basePath.replace(/\/$/, '');
6473

@@ -118,7 +127,26 @@ export function createCaptchaEndpoints(options: CaptchaEndpointsOptions = {}) {
118127
ja3Hash,
119128
action: b.action,
120129
});
121-
return json(200, result);
130+
131+
// Remember the verdict against the browser's session, so the requests
132+
// that follow can act on it. Without this the score goes back to the
133+
// browser and the origin never learns anything from it — which was the
134+
// gap: all the parts existed and nothing joined them.
135+
if (signalStore && b.sessionId) {
136+
await signalStore.set({
137+
sessionId: b.sessionId,
138+
score: result.score,
139+
recommendation: result.recommendation,
140+
at: Date.now(),
141+
});
142+
}
143+
144+
return json(200, {
145+
...result,
146+
// Echoed so the widget can set the cookie the rule reads, without the
147+
// developer wiring a second endpoint to hand it one.
148+
sessionId: b.sessionId ?? null,
149+
});
122150
}
123151

124152
// POST {base}/token/verify

0 commit comments

Comments
 (0)