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

### Added

- **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`.

- **`@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.

- **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.
Expand Down
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,38 @@ names the ones in your deny set that do not:

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

## Catching a real browser that isn't a real user

A Playwright-driven Chrome that browses only the links a human would presents a
genuine fingerprint, follows no hidden links and requests no honeypot paths — the
one thing a tripwire cannot see. What it cannot fake is having a person behind
it.

```typescript
import { MemoryClientSignalStore, clientSignals, tripwire } from '@webdecoy/node';

const signalStore = new MemoryClientSignalStore();

app.use(webdecoyCaptcha({ secret: process.env.WEBDECOY_SECRET, signalStore }));
app.use(webdecoy({
rules: [
tripwire(), // intent — deterministic
clientSignals({ store: signalStore, dryRun: true }), // interaction — probabilistic
],
}));
```

`@webdecoy/client` collects behavioural, environmental and form signals in the
browser; `/score` records the verdict against the session; `clientSignals()` acts
on it for the requests that follow. Before this the score went back to the
browser and the origin never learned anything from 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 you most
need to keep. This augments the keyless rules; it does not replace them.

Full guide: [**Catching a real browser that isn't a real user**](docs/client-signals.md).

## Attack signatures

Tripwires catch scanners by the path they ask for. `attackSignatures()` looks at
Expand Down
120 changes: 120 additions & 0 deletions docs/client-signals.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# Catching a real browser that isn't a real user

Tripwires catch automation by intent: a hidden path a person can never reach, so
any request for it is a bot by construction. That is deterministic and
unspoofable, and it has one blind spot — a Playwright-driven Chrome that browses
only the links a human would. It presents a genuine fingerprint, it follows no
hidden links, and it requests no honeypot paths.

What it cannot fake is having a person behind it. `@webdecoy/client` collects
that evidence in the browser, `DetectionEngine` scores it on your server, and
`clientSignals()` lets the requests that follow act on the result.

**This augments the keyless rules; it does not replace them.** No JavaScript
means no signals, and 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 you most need to keep.

## 1. Serve the endpoints, with a store

The `/score` endpoint already existed. What is new is the store: without it the
verdict goes back to the browser and your origin never learns anything from it.

```typescript
import express from 'express';
import { webdecoyCaptcha } from '@webdecoy/express';
import { MemoryClientSignalStore } from '@webdecoy/node';

const signalStore = new MemoryClientSignalStore();

const app = express();
app.use(express.json());
app.use(webdecoyCaptcha({ secret: process.env.WEBDECOY_SECRET, signalStore }));
```

`MemoryClientSignalStore` is per-process, like the rate limiter. On more than one
replica implement `ClientSignalStore` over something shared, or the request after
the submission may land on a different instance and find nothing.

## 2. Add the rule

```typescript
import { webdecoy } from '@webdecoy/express';
import { tripwire, clientSignals } from '@webdecoy/node';

app.use(webdecoy({
rules: [
tripwire(), // intent — deterministic
clientSignals({ store: signalStore }), // interaction — probabilistic
],
}));
```

By default the rule follows the engine's own recommendation. `minScore` overrides
it with a threshold of your own, 0–1, higher being more bot-like.

Start with `dryRun: true`. This is the one rule in the SDK that is a judgement
rather than a fact, and you want a day of your own traffic before it blocks
anyone.

## 3. Load the widget

```bash
npm install @webdecoy/client
```

```typescript
import { WebDecoyCaptcha } from '@webdecoy/client';

WebDecoyCaptcha.configure({ serverUrl: '' }); // same origin
WebDecoyCaptcha.invisible({ action: 'browse' });
```

The package also ships a prebuilt global bundle at
`@webdecoy/client/global` for pages without a bundler. Serve it from your own
origin rather than a third-party CDN, or pin a version and add
`integrity`/`crossorigin` — a script tag with neither is a supply-chain
dependency on whoever is serving it.

The widget submits to `/score` with a `sessionId` and sets the `wd_cs` cookie.
The rule reads that cookie — or an `X-WD-Session` header, for a client that
cannot use cookies.

## 4. Confirm it works

```typescript
import { createTestHarness, request, expectDenied } from '@webdecoy/node/testing';

const wd = createTestHarness({ rules: [clientSignals({ store, minScore: 0.5 })] });
expectDenied(await wd.protect(request({ headers: { cookie: 'wd_cs=sess-1' } })));
```

Against a real browser: drive the page with Playwright, let the widget submit,
and compare the recorded score with your own session. `webdriver` alone
contributes to the score, and a session with no pointer movement, no scroll and
no keystrokes contributes considerably more.

## What the signals are

The collection contract is the code — `summarizeBehavior()` in
`@webdecoy/client` is the published list of what leaves the browser. In outline:

| Group | Examples |
|---|---|
| Behavioural | pointer trajectory, micro-tremor, velocity variance, scroll and key events |
| Environmental | `navigator.webdriver`, plugin count, automation flags, CDP artifacts |
| Temporal | time to first interaction, session duration, event deltas |
| Form | per-field dwell times, paste versus keystroke, submit timing |

No page content, no form values, no cookies other than the session id.

## Limits worth knowing

- **A client signal is a claim by code running on the client.** A determined
attacker can lie to it. Its value is that most automation does not bother, and
that faking human interaction convincingly is much harder than faking a
fingerprint.
- **The score is probabilistic.** Unlike a tripwire hit, a high score is not
proof. That is why `dryRun` is the recommended starting point and why the
deterministic rules stay in the list.
- **Sessions expire** after 15 minutes by default.
32 changes: 30 additions & 2 deletions packages/webdecoy/src/captcha/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

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

/** Normalized inbound request the adapters construct. */
export interface CaptchaRequest {
Expand All @@ -36,6 +37,12 @@ export interface CaptchaHttpResponse {
}

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

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

Expand Down Expand Up @@ -118,7 +127,26 @@ export function createCaptchaEndpoints(options: CaptchaEndpointsOptions = {}) {
ja3Hash,
action: b.action,
});
return json(200, result);

// Remember the verdict against the browser's session, so the requests
// that follow can act on it. Without this the score goes back to the
// browser and the origin never learns anything from it — which was the
// gap: all the parts existed and nothing joined them.
if (signalStore && b.sessionId) {
await signalStore.set({
sessionId: b.sessionId,
score: result.score,
recommendation: result.recommendation,
at: Date.now(),
});
}

return json(200, {
...result,
// Echoed so the widget can set the cookie the rule reads, without the
// developer wiring a second endpoint to hand it one.
sessionId: b.sessionId ?? null,
});
}

// POST {base}/token/verify
Expand Down
Loading
Loading