@osqd/bothandlerjs 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +516 -0
- package/LICENSE +102 -0
- package/README.md +308 -0
- package/bin/bothandlerjs.mjs +14 -0
- package/dist/actions/index.d.ts +22 -0
- package/dist/actions/types.d.ts +59 -0
- package/dist/adapters/fastify.d.ts +43 -0
- package/dist/adapters/fetch.d.ts +51 -0
- package/dist/adapters/index.cjs +723 -0
- package/dist/adapters/index.cjs.map +1 -0
- package/dist/adapters/index.d.ts +8 -0
- package/dist/adapters/index.js +692 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/koa.d.ts +27 -0
- package/dist/adapters/node.d.ts +29 -0
- package/dist/adapters/shared.d.ts +39 -0
- package/dist/audit.d.ts +172 -0
- package/dist/challenge/index.d.ts +176 -0
- package/dist/challenge/language.d.ts +57 -0
- package/dist/challenge/page.d.ts +42 -0
- package/dist/challenge/pow.d.ts +33 -0
- package/dist/challenge/token.d.ts +72 -0
- package/dist/cli.cjs +13388 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +13358 -0
- package/dist/cli.js.map +1 -0
- package/dist/client/index.cjs +139 -0
- package/dist/client/index.cjs.map +1 -0
- package/dist/client/index.d.ts +63 -0
- package/dist/client/index.js +112 -0
- package/dist/client/index.js.map +1 -0
- package/dist/config.d.ts +316 -0
- package/dist/core.d.ts +443 -0
- package/dist/corpus/adversarial.d.ts +2 -0
- package/dist/corpus/advertising-email.d.ts +2 -0
- package/dist/corpus/ai-crawlers.d.ts +2 -0
- package/dist/corpus/benign-bots.d.ts +2 -0
- package/dist/corpus/cdn-gateways.d.ts +2 -0
- package/dist/corpus/cookies.d.ts +49 -0
- package/dist/corpus/crawlers-regional.d.ts +2 -0
- package/dist/corpus/crawlers-vertical.d.ts +2 -0
- package/dist/corpus/headers.d.ts +403 -0
- package/dist/corpus/humans-apps.d.ts +2 -0
- package/dist/corpus/humans-browsers.d.ts +2 -0
- package/dist/corpus/humans.d.ts +2 -0
- package/dist/corpus/index.cjs +4196 -0
- package/dist/corpus/index.cjs.map +1 -0
- package/dist/corpus/index.d.ts +37 -0
- package/dist/corpus/index.js +4132 -0
- package/dist/corpus/index.js.map +1 -0
- package/dist/corpus/infrastructure.d.ts +17 -0
- package/dist/corpus/libraries-extended.d.ts +2 -0
- package/dist/corpus/ranges.d.ts +31 -0
- package/dist/corpus/reputation.d.ts +21 -0
- package/dist/corpus/runner.d.ts +130 -0
- package/dist/corpus/schema.d.ts +185 -0
- package/dist/corpus/tooling.d.ts +2 -0
- package/dist/corpus/unwanted.d.ts +2 -0
- package/dist/crawler-ranges.d.ts +113 -0
- package/dist/dashboard/client.generated.d.ts +7 -0
- package/dist/dashboard/feed.d.ts +186 -0
- package/dist/dashboard/index.d.ts +5 -0
- package/dist/dashboard/page.d.ts +51 -0
- package/dist/dashboard/parse-request.d.ts +28 -0
- package/dist/dashboard/preview.d.ts +45 -0
- package/dist/dashboard/server.d.ts +70 -0
- package/dist/dashboard/types.d.ts +714 -0
- package/dist/detectors/accept-signature.d.ts +6 -0
- package/dist/detectors/browsing-coherence.d.ts +44 -0
- package/dist/detectors/cadence.d.ts +28 -0
- package/dist/detectors/clearance.d.ts +25 -0
- package/dist/detectors/client-hints.d.ts +19 -0
- package/dist/detectors/client-signals.d.ts +27 -0
- package/dist/detectors/crawl-breadth.d.ts +28 -0
- package/dist/detectors/crawler-verification.d.ts +39 -0
- package/dist/detectors/fetch-metadata.d.ts +20 -0
- package/dist/detectors/header-integrity.d.ts +26 -0
- package/dist/detectors/header-order.d.ts +27 -0
- package/dist/detectors/identity-rotation.d.ts +34 -0
- package/dist/detectors/index.d.ts +56 -0
- package/dist/detectors/ip-intelligence.d.ts +30 -0
- package/dist/detectors/known-bots.d.ts +94 -0
- package/dist/detectors/probe-signature.d.ts +50 -0
- package/dist/detectors/rate-anomaly.d.ts +36 -0
- package/dist/detectors/self-identified.d.ts +33 -0
- package/dist/detectors/session-integrity.d.ts +24 -0
- package/dist/detectors/tls-fingerprint.d.ts +34 -0
- package/dist/detectors/trap.d.ts +98 -0
- package/dist/detectors/types.d.ts +70 -0
- package/dist/detectors/ua-coherence.d.ts +32 -0
- package/dist/evidence.d.ts +45 -0
- package/dist/facts.d.ts +46 -0
- package/dist/index.cjs +9600 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +63 -0
- package/dist/index.js +9444 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/async.d.ts +16 -0
- package/dist/internal/clock.d.ts +17 -0
- package/dist/internal/crypto.d.ts +26 -0
- package/dist/internal/dns.d.ts +100 -0
- package/dist/internal/emitter.d.ts +18 -0
- package/dist/internal/http.d.ts +22 -0
- package/dist/internal/ip.d.ts +73 -0
- package/dist/internal/lru.d.ts +34 -0
- package/dist/internal/matcher.d.ts +41 -0
- package/dist/internal/pattern.d.ts +14 -0
- package/dist/internal/ua.d.ts +92 -0
- package/dist/metrics.d.ts +120 -0
- package/dist/notify/hub.d.ts +70 -0
- package/dist/notify/index.d.ts +7 -0
- package/dist/notify/redact.d.ts +48 -0
- package/dist/notify/sinks.d.ts +62 -0
- package/dist/notify/types.d.ts +56 -0
- package/dist/policy/index.d.ts +7 -0
- package/dist/policy/match.d.ts +20 -0
- package/dist/policy/policy.d.ts +123 -0
- package/dist/policy/presets.d.ts +161 -0
- package/dist/policy/types.d.ts +155 -0
- package/dist/robots.d.ts +83 -0
- package/dist/state.d.ts +161 -0
- package/dist/stores/index.d.ts +5 -0
- package/dist/stores/memory.d.ts +33 -0
- package/dist/stores/redis.d.ts +59 -0
- package/dist/stores/types.d.ts +37 -0
- package/dist/types.d.ts +250 -0
- package/docs/challenge/index.md +152 -0
- package/docs/challenge/localisation.md +105 -0
- package/docs/concepts/actors.md +113 -0
- package/docs/concepts/evidence.md +133 -0
- package/docs/concepts/the-guard.md +121 -0
- package/docs/concepts/threat-model.md +120 -0
- package/docs/concepts/verdicts.md +108 -0
- package/docs/course/01-first-assessment.md +178 -0
- package/docs/course/02-proof-and-suspicion.md +164 -0
- package/docs/course/03-verdicts-and-scores.md +160 -0
- package/docs/course/04-the-guard.md +200 -0
- package/docs/course/05-detectors.md +178 -0
- package/docs/course/06-identity.md +161 -0
- package/docs/course/07-actors.md +178 -0
- package/docs/course/08-traps.md +183 -0
- package/docs/course/09-rules.md +224 -0
- package/docs/course/10-actions-and-presets.md +216 -0
- package/docs/course/11-the-challenge.md +227 -0
- package/docs/course/12-going-live.md +211 -0
- package/docs/course/13-operating-it.md +238 -0
- package/docs/course/14-scaling.md +195 -0
- package/docs/course/15-extending.md +259 -0
- package/docs/course/16-proving-it.md +266 -0
- package/docs/course/index.md +112 -0
- package/docs/design/decisions.md +216 -0
- package/docs/detection/client-signals.md +96 -0
- package/docs/detection/detectors.md +376 -0
- package/docs/detection/index.md +134 -0
- package/docs/detection/signatures.md +111 -0
- package/docs/detection/verification.md +123 -0
- package/docs/detection/writing-a-detector.md +145 -0
- package/docs/index.md +100 -0
- package/docs/integration/adapters.md +138 -0
- package/docs/integration/client-ip.md +114 -0
- package/docs/integration/index.md +55 -0
- package/docs/integration/stores.md +76 -0
- package/docs/operations/audit.md +116 -0
- package/docs/operations/dashboard.md +725 -0
- package/docs/operations/index.md +101 -0
- package/docs/operations/metrics.md +74 -0
- package/docs/operations/notifications.md +72 -0
- package/docs/operations/runtime-changes.md +118 -0
- package/docs/policy/actions.md +196 -0
- package/docs/policy/index.md +84 -0
- package/docs/policy/presets.md +191 -0
- package/docs/policy/robots.md +128 -0
- package/docs/policy/rules.md +134 -0
- package/docs/reference/api.md +126 -0
- package/docs/reference/configuration.md +175 -0
- package/docs/start/choosing-a-policy.md +127 -0
- package/docs/start/first-integration.md +127 -0
- package/docs/start/installation.md +63 -0
- package/docs/start/upgrading.md +72 -0
- package/docs/testing/cli.md +119 -0
- package/docs/testing/corpus.md +129 -0
- package/docs/testing/index.md +61 -0
- package/docs/testing/replay.md +102 -0
- package/docs/testing/try-it.md +189 -0
- package/package.json +145 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# Adapters
|
|
2
|
+
|
|
3
|
+
Four adapters, six frameworks, and how to write a fifth.
|
|
4
|
+
|
|
5
|
+
← [Documentation](../index.md) · [Integration](index.md)
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { botHandler, fastifyBotHandler, koaBotHandler, withBotHandler } from "@osqd/bothandlerjs/adapters";
|
|
11
|
+
|
|
12
|
+
app.use(botHandler(detector)); // Express / Connect / node:http
|
|
13
|
+
fastify.addHook("onRequest", fastifyBotHandler(detector));
|
|
14
|
+
app.use(koaBotHandler(detector));
|
|
15
|
+
export default { fetch: withBotHandler(detector, myHandler) }; // Workers / Deno / Bun / Edge
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Why adapters exist at all
|
|
19
|
+
|
|
20
|
+
The engine never touches a response object. It takes `RequestFacts` and returns an
|
|
21
|
+
`ActionOutcome` — "serve it", "respond with this", "destroy the connection" — and the
|
|
22
|
+
adapter is the only code that knows what a response *is* in your framework.
|
|
23
|
+
|
|
24
|
+
That separation is what makes a policy portable, makes the engine testable without a
|
|
25
|
+
server, and makes the [request tester](../operations/dashboard.md) and the
|
|
26
|
+
[corpus](../testing/corpus.md) possible.
|
|
27
|
+
|
|
28
|
+
## Hono and Next.js need no adapter of their own
|
|
29
|
+
|
|
30
|
+
Both speak the platform's `Request` and `Response`, which is exactly what
|
|
31
|
+
`createFetchAdapter` takes and returns. The only difference between them is how each says
|
|
32
|
+
"carry on".
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
const guard = createFetchAdapter(detector, { ipHeaders: ["cf-connecting-ip"] });
|
|
36
|
+
|
|
37
|
+
// Hono
|
|
38
|
+
app.use(async (c, next) => {
|
|
39
|
+
const decision = await guard(c.req.raw, c.env);
|
|
40
|
+
if (decision.response) return decision.response;
|
|
41
|
+
await next();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Next.js — middleware.ts
|
|
45
|
+
export async function middleware(request: Request) {
|
|
46
|
+
const decision = await guard(request);
|
|
47
|
+
if (decision.response) return decision.response;
|
|
48
|
+
return NextResponse.next({ request: { headers: decision.request.headers } });
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Passing `decision.request.headers` on is the part worth doing: the adapter puts the verdict
|
|
53
|
+
on the request it hands back, so a route further in can read it without assessing anything
|
|
54
|
+
itself.
|
|
55
|
+
|
|
56
|
+
`examples/hono-and-next.ts` is both, executable, and `tests/adapters.test.ts` drives both
|
|
57
|
+
shapes — because "it probably works with X" is how a framework ends up unsupported by
|
|
58
|
+
accident.
|
|
59
|
+
|
|
60
|
+
## What every adapter does for you
|
|
61
|
+
|
|
62
|
+
**Attaches the verdict to the request** — `x-bot-verdict`, `x-bot-score`, `x-bot-class`,
|
|
63
|
+
`x-bot-certain`, `x-bot-reason` — so your handlers can react without re-running detection.
|
|
64
|
+
|
|
65
|
+
Response-side headers are **off by default** (`exposeVerdictHeaders`): an `X-Bot-Score` in
|
|
66
|
+
the response is a live feedback signal for anyone tuning a scraper against you.
|
|
67
|
+
|
|
68
|
+
**Serves the [challenge](../challenge/index.md) verification endpoint.** Fastify and Koa
|
|
69
|
+
read the raw stream in the hook, because by the time a route handler sees the request its
|
|
70
|
+
body has been consumed by a parser that knows nothing about this endpoint.
|
|
71
|
+
|
|
72
|
+
**Fails open.** An unexpected failure inside detection serves the request; the error goes to
|
|
73
|
+
your `onError`.
|
|
74
|
+
|
|
75
|
+
## Handing over a parsed body
|
|
76
|
+
|
|
77
|
+
The engine reads no request body — doing so would consume the stream before your own parser
|
|
78
|
+
saw it. So a [trap](../detection/detectors.md) field on a `method="post"` form arrives
|
|
79
|
+
somewhere this library cannot see, and the forms worth protecting are POSTs.
|
|
80
|
+
|
|
81
|
+
`enrich` is the hand-over:
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
import { TRAP_FIELD_SOURCE, defaultDetectors, trapDetector } from "@osqd/bothandlerjs";
|
|
85
|
+
|
|
86
|
+
const detector = new BotHandler({
|
|
87
|
+
detectors: defaultDetectors().map((d) => (d.id === "trap" ? trapDetector({ formFields: ["company_url"] }) : d)),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
app.use(express.urlencoded({ extended: false })); // your parser runs first
|
|
91
|
+
app.use(
|
|
92
|
+
botHandler(detector, {
|
|
93
|
+
enrich: (request, facts) => ({ ...facts, extra: { [TRAP_FIELD_SOURCE]: request.body } }),
|
|
94
|
+
}),
|
|
95
|
+
);
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
A field arriving in the query string is read without any of this.
|
|
99
|
+
|
|
100
|
+
## Fetch runtimes
|
|
101
|
+
|
|
102
|
+
The three Node adapters read the socket address and honour [`proxy`](client-ip.md). There is
|
|
103
|
+
no socket on a Fetch runtime, so `withBotHandler` and `createFetchAdapter` take the address
|
|
104
|
+
from a header instead — which is the same decision `proxy` exists to make carefully.
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
export default {
|
|
108
|
+
fetch: withBotHandler(detector, myHandler, {
|
|
109
|
+
// Best: ask the platform, not a header.
|
|
110
|
+
clientIp: (request, env) => (env as { cf?: { connectingIp?: string } }).cf?.connectingIp,
|
|
111
|
+
}),
|
|
112
|
+
};
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Only `cf-connecting-ip` and `x-real-ip` are trusted by default. `x-forwarded-for` is **not**
|
|
116
|
+
— see [the client IP](client-ip.md) for why.
|
|
117
|
+
|
|
118
|
+
Two platform notes: on Workers, enable `nodejs_compat`, because the engine uses
|
|
119
|
+
`node:crypto`. And Fetch runtimes normalise header order, so `header-order` returns nothing
|
|
120
|
+
there — drop it.
|
|
121
|
+
|
|
122
|
+
## Writing your own
|
|
123
|
+
|
|
124
|
+
About thirty lines. `src/adapters/node.ts` is the model. The shape is:
|
|
125
|
+
|
|
126
|
+
1. Build `RequestFacts` with `createFacts({ method, url, headers, rawHeaders, ip })`.
|
|
127
|
+
2. `await detector.handle(facts)`.
|
|
128
|
+
3. Apply the returned `ActionOutcome` in your framework's terms.
|
|
129
|
+
4. Route the challenge verification path to `detector.verifyChallenge`.
|
|
130
|
+
|
|
131
|
+
Pass `rawHeaders` if your framework exposes them — the `header-order` detector needs the
|
|
132
|
+
order as it arrived, and a normalised object has already lost it.
|
|
133
|
+
|
|
134
|
+
## Related
|
|
135
|
+
|
|
136
|
+
- [The client IP](client-ip.md) — do this before deploying behind a proxy
|
|
137
|
+
- [Actions](../policy/actions.md) — the outcomes an adapter applies
|
|
138
|
+
- [Detectors](../detection/detectors.md) — including `trap`, which `enrich` feeds
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# The client IP
|
|
2
|
+
|
|
3
|
+
The highest-consequence setting in the library, and the easiest to get wrong.
|
|
4
|
+
|
|
5
|
+
← [Documentation](../index.md) · [Integration](index.md)
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Why it is the highest-consequence setting
|
|
10
|
+
|
|
11
|
+
The client address becomes the [actor key](../concepts/actors.md). Everything per-client
|
|
12
|
+
depends on it: rate limits, the allowlist, the denylist, and every behavioural detector.
|
|
13
|
+
|
|
14
|
+
`X-Forwarded-For` is a **client-supplied header**. Trust it without knowing how many proxies
|
|
15
|
+
sit in front of you and anyone can prepend a fake hop and choose the address you rate-limit,
|
|
16
|
+
allowlist and block on.
|
|
17
|
+
|
|
18
|
+
The failure is silent. Nothing errors, nothing logs, and every per-actor mechanism in this
|
|
19
|
+
library becomes an attacker input.
|
|
20
|
+
|
|
21
|
+
There is deliberately **no convenient default**.
|
|
22
|
+
|
|
23
|
+
## The three settings
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
proxy: { trustProxy: true, trustedProxies: ["10.0.0.0/8"] } // recommended
|
|
27
|
+
proxy: { trustProxy: true, hops: 2 } // correct only while the count is
|
|
28
|
+
proxy: { trustProxy: false } // default — header ignored entirely
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### `trustedProxies` — the one to use
|
|
32
|
+
|
|
33
|
+
The chain is walked **from the right**, discarding your own infrastructure, and the first
|
|
34
|
+
address outside it is the client. That is robust against an extra hop appearing, which is
|
|
35
|
+
what makes it survive a change to your topology that nobody remembered to tell you about.
|
|
36
|
+
|
|
37
|
+
### `hops` — correct only while the count is
|
|
38
|
+
|
|
39
|
+
Counts a fixed number of entries from the right. It is right until somebody adds a CDN,
|
|
40
|
+
inserts a sidecar, or moves a service behind an extra load balancer — and then it is wrong
|
|
41
|
+
in the direction that lets clients choose their own address.
|
|
42
|
+
|
|
43
|
+
### `trustProxy: false` — the default
|
|
44
|
+
|
|
45
|
+
The forwarded header is ignored entirely and the socket address is used. Correct when
|
|
46
|
+
nothing sits in front of you, and safe everywhere else in the sense that matters: it can be
|
|
47
|
+
*useless* behind a proxy (every request looks like it came from the load balancer), but it
|
|
48
|
+
cannot be *forged*.
|
|
49
|
+
|
|
50
|
+
## Addresses are compared as bytes
|
|
51
|
+
|
|
52
|
+
`::ffff:127.0.0.1`, `0177.0.0.1` and `127.0.0.001` are all the same address, and all of them
|
|
53
|
+
slip past an allowlist that compares strings. Parsing to bytes is the only comparison that
|
|
54
|
+
holds.
|
|
55
|
+
|
|
56
|
+
Invalid CIDRs throw at construction rather than matching silently. A range that matches
|
|
57
|
+
nothing is a control you believe you have and do not.
|
|
58
|
+
|
|
59
|
+
## The peer is checked too
|
|
60
|
+
|
|
61
|
+
The connecting peer counts as the first hop and is checked the same way.
|
|
62
|
+
|
|
63
|
+
That is the part that matters on a server reachable **both** through the load balancer and
|
|
64
|
+
directly. A request arriving from outside your trusted ranges did not come through your
|
|
65
|
+
proxies, so its forwarded header is not evidence of anything and the socket address is used
|
|
66
|
+
instead.
|
|
67
|
+
|
|
68
|
+
Without that check, anyone who finds the origin address picks their own client IP — and
|
|
69
|
+
origin addresses are not secret.
|
|
70
|
+
|
|
71
|
+
## Do not allowlist loopback
|
|
72
|
+
|
|
73
|
+
The moment you sit behind nginx or beside a sidecar, every request in the world arrives from
|
|
74
|
+
`127.0.0.1`.
|
|
75
|
+
|
|
76
|
+
## On Fetch runtimes
|
|
77
|
+
|
|
78
|
+
There is no socket, so the address comes from a header. Only `cf-connecting-ip` and
|
|
79
|
+
`x-real-ip` are trusted by default: both are single-valued and written by the edge that
|
|
80
|
+
terminated the connection.
|
|
81
|
+
|
|
82
|
+
`x-forwarded-for` is **not** in that list, because a proxy *appends* to it — its leftmost
|
|
83
|
+
entry is whatever the client wrote. List it explicitly only if you know your edge replaces
|
|
84
|
+
the whole header.
|
|
85
|
+
|
|
86
|
+
Better still, ask the platform rather than a header:
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
createFetchAdapter(detector, {
|
|
90
|
+
clientIp: (request, env) => (env as { cf?: { connectingIp?: string } }).cf?.connectingIp,
|
|
91
|
+
});
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
If no address can be found, every visitor is tracked under one empty actor key — which makes
|
|
95
|
+
rate limits and behavioural detection apply to your whole site at once. The adapter says so
|
|
96
|
+
through `onWarning` the first time it happens.
|
|
97
|
+
|
|
98
|
+
## When the address is not the right identity anyway
|
|
99
|
+
|
|
100
|
+
An address is a poor identity: shared by a whole office, changed by a phone every few
|
|
101
|
+
minutes. `actorKey` is the single most valuable thing to replace:
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
new BotHandler({ actorKey: (facts) => facts.session ?? facts.ip });
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
A session id, an authenticated user id, or an address plus a TLS fingerprint all make the
|
|
108
|
+
same detectors sharper — sharp enough that `identity-rotation` becomes worth enabling.
|
|
109
|
+
|
|
110
|
+
## Related
|
|
111
|
+
|
|
112
|
+
- [Actors](../concepts/actors.md) — what the key is used for
|
|
113
|
+
- [Adapters](adapters.md) — where the address is read
|
|
114
|
+
- [Configuration reference](../reference/configuration.md) — `proxy`, `actorKey`, `allowlist`
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Integration
|
|
2
|
+
|
|
3
|
+
Getting it into your application: frameworks, the client address, and shared state.
|
|
4
|
+
|
|
5
|
+
← [Documentation](../index.md)
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
| Page | For |
|
|
10
|
+
| ---- | --- |
|
|
11
|
+
| [Adapters](adapters.md) | Express, Fastify, Koa, Fetch runtimes, Hono, Next.js — and writing your own |
|
|
12
|
+
| [The client IP](client-ip.md) | the highest-consequence setting in the library |
|
|
13
|
+
| [Stores](stores.md) | what needs to be shared across replicas, and what deliberately is not |
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## The shape of it
|
|
18
|
+
|
|
19
|
+
Three lines in the common case:
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { BotHandler } from "@osqd/bothandlerjs";
|
|
23
|
+
import { botHandler } from "@osqd/bothandlerjs/adapters";
|
|
24
|
+
|
|
25
|
+
const detector = new BotHandler({ preset: "protect-content" });
|
|
26
|
+
app.use(botHandler(detector));
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
One `BotHandler` instance per policy, mounted where that policy applies. Most applications
|
|
30
|
+
have one; an application that runs [`protect-auth`](../policy/presets.md) on its login
|
|
31
|
+
routes has two.
|
|
32
|
+
|
|
33
|
+
## The one thing to get right before anything else
|
|
34
|
+
|
|
35
|
+
**The client address.** It becomes the [actor key](../concepts/actors.md), which every
|
|
36
|
+
behavioural detector, every rate limit and every allowlist entry depends on. Behind a proxy
|
|
37
|
+
it has no safe default, and getting it wrong turns every per-actor mechanism here into an
|
|
38
|
+
attacker input.
|
|
39
|
+
|
|
40
|
+
Read [the client IP](client-ip.md) before you deploy this behind anything.
|
|
41
|
+
|
|
42
|
+
## Two guarantees the integration relies on
|
|
43
|
+
|
|
44
|
+
**The engine never touches a response object.** It returns an `ActionOutcome` and the
|
|
45
|
+
adapter applies it — which is why the same policy behaves identically on Express and on a
|
|
46
|
+
Worker, and why writing your own adapter is about thirty lines.
|
|
47
|
+
|
|
48
|
+
**It fails open.** An unexpected failure inside detection serves the request. The error
|
|
49
|
+
goes to your `onError`; the visitor gets their page. A bot filter that fails closed is an
|
|
50
|
+
outage with extra steps.
|
|
51
|
+
|
|
52
|
+
## Related
|
|
53
|
+
|
|
54
|
+
- [Configuration reference](../reference/configuration.md) — every option
|
|
55
|
+
- [First integration](../start/first-integration.md) — the walk-through
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Stores
|
|
2
|
+
|
|
3
|
+
What has to be shared across replicas, what deliberately is not, and why.
|
|
4
|
+
|
|
5
|
+
← [Documentation](../index.md) · [Integration](index.md)
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## The default
|
|
10
|
+
|
|
11
|
+
An in-memory store. Correct for a single process, and honest about what it is not.
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { RedisStore } from "@osqd/bothandlerjs";
|
|
15
|
+
new BotHandler({ store: new RedisStore(redis) }); // ioredis or node-redis; no dependency added
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
`RedisStore` takes a client you already have. The package stays dependency-free — it uses
|
|
19
|
+
the handful of methods both major clients share.
|
|
20
|
+
|
|
21
|
+
## What needs a shared store
|
|
22
|
+
|
|
23
|
+
**Single-use challenge nonces.** Without sharing, a scraper retries a solved nonce against
|
|
24
|
+
another replica until one has not seen it. See [the challenge](../challenge/index.md).
|
|
25
|
+
|
|
26
|
+
**Rate limits.** A limit of 100/minute enforced independently by four replicas is a limit of
|
|
27
|
+
400/minute. See [`rate-limit`](../policy/actions.md#rate-limit).
|
|
28
|
+
|
|
29
|
+
Those two are correctness, not optimisation. If you run more than one process and use either
|
|
30
|
+
feature, you need a store.
|
|
31
|
+
|
|
32
|
+
## What deliberately stays process-local
|
|
33
|
+
|
|
34
|
+
**Behavioural state** — arrival rates, cadence, path breadth, User-Agent history — lives in
|
|
35
|
+
memory, on purpose.
|
|
36
|
+
|
|
37
|
+
A round trip per request would buy accuracy for signals that are only ever allowed to *raise
|
|
38
|
+
suspicion*, never to deny anybody: the [guard](../concepts/the-guard.md) sees to that.
|
|
39
|
+
Behind four replicas each one sees a quarter of an actor's traffic and is correspondingly
|
|
40
|
+
less sure, which is the right trade for something that cannot close a door on its own.
|
|
41
|
+
|
|
42
|
+
Spending a network round trip on the request path to sharpen a signal that cannot act alone
|
|
43
|
+
is a bad bargain, and the request path is the one place this library refuses to make bad
|
|
44
|
+
bargains.
|
|
45
|
+
|
|
46
|
+
## Confirmations are not one of those
|
|
47
|
+
|
|
48
|
+
A `confirmed-bot` verdict is *proven* — something declared itself, forged an identity, or
|
|
49
|
+
walked into a trap. That is a fact about the client rather than a judgement about it, so it
|
|
50
|
+
can travel:
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
new BotHandler({ store: new RedisStore(redis), shareConfirmations: true });
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Without it, a client proven to be a bot on one replica is a stranger to the other seven, and
|
|
57
|
+
`minPriorConfirmations: 1` fires about an eighth as often as it reads.
|
|
58
|
+
|
|
59
|
+
**Proof travels; suspicion stays home.** That single sentence is the whole design.
|
|
60
|
+
|
|
61
|
+
The cost is one store read the **first time each instance sees an actor** — not one per
|
|
62
|
+
request — and it is never awaited, so nothing joins the request path. A store outage means
|
|
63
|
+
the count falls back to what that process saw itself.
|
|
64
|
+
|
|
65
|
+
## Writing your own
|
|
66
|
+
|
|
67
|
+
Implement `BotHandlerStore`. The interface is small — get, set, increment, an atomic
|
|
68
|
+
claim — and every method may fail: the engine treats a store error as "no answer" and
|
|
69
|
+
carries on serving, because a store outage must not become a site outage.
|
|
70
|
+
|
|
71
|
+
## Related
|
|
72
|
+
|
|
73
|
+
- [The challenge](../challenge/index.md) — replay protection
|
|
74
|
+
- [Actions](../policy/actions.md#rate-limit) — the one action that needs this
|
|
75
|
+
- [Actors](../concepts/actors.md) — what the local state actually holds
|
|
76
|
+
- [The guard](../concepts/the-guard.md) — why local behavioural state is safe
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# The audit
|
|
2
|
+
|
|
3
|
+
Noticing that the traffic changed shape.
|
|
4
|
+
|
|
5
|
+
← [Documentation](../index.md) · [Operations](index.md)
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Why this exists
|
|
10
|
+
|
|
11
|
+
[Counters](metrics.md) tell you what is happening. They do not tell you it is *unusual*,
|
|
12
|
+
and bot traffic is not a level — it is an **event**. A scrape starts, a scanner sweeps a
|
|
13
|
+
range, somebody points a stuffing tool at your login form. The number that matters is not
|
|
14
|
+
"12% of requests are bots" but "12% now, 2% for the hour before".
|
|
15
|
+
|
|
16
|
+
So the audit keeps a short **window**, compares it against the **baseline that precedes
|
|
17
|
+
it**, and raises a structured anomaly when a check clears its bar.
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
new BotHandler({
|
|
21
|
+
audit: {
|
|
22
|
+
windowMs: 5 * 60_000, // the stretch being judged
|
|
23
|
+
baselineMs: 60 * 60_000, // what it is compared against — the hour before it
|
|
24
|
+
intervalMs: 60_000, // how often the comparison runs
|
|
25
|
+
minSamples: 50, // below this, no check may speak
|
|
26
|
+
cooldownMs: 15 * 60_000, // one alert per check per quarter hour
|
|
27
|
+
},
|
|
28
|
+
onAnomaly: (anomaly) => pager.send(anomaly.severity, anomaly.summary),
|
|
29
|
+
});
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
critical — Automated traffic is 87.3% of assessed requests, against 0.0% in the
|
|
34
|
+
baseline — where there was none.
|
|
35
|
+
warning — The guard stopped 37 terminal action(s) — 35.9% of traffic. Your rules
|
|
36
|
+
are asking to deny requests the evidence does not prove.
|
|
37
|
+
critical — Traffic is 298 requests a minute, against a baseline of 1.5 (198.7x).
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
**The baseline ends where the window begins.** A baseline that contained the window would
|
|
41
|
+
be partly made of the thing being measured, and a large enough spike would raise its own
|
|
42
|
+
bar until it stopped looking like one.
|
|
43
|
+
|
|
44
|
+
## What ships
|
|
45
|
+
|
|
46
|
+
| Check | Fires when |
|
|
47
|
+
| ----- | ---------- |
|
|
48
|
+
| `bot-share-spike` | Automation is a large and much larger share of traffic than the baseline. A baseline of zero is the loudest case, not a reason to stay quiet. |
|
|
49
|
+
| `traffic-spike` | Volume is far above the baseline rate. |
|
|
50
|
+
| `denial-spike` | A much larger share of requests is being denied. **Read these before assuming they are all bots.** |
|
|
51
|
+
| `guard-stop-spike` | The [guard](../concepts/the-guard.md) is refusing far more rules than usual — your policy is asking to deny requests the evidence does not prove. |
|
|
52
|
+
| `human-share-drop` | Traffic that reads as human has fallen away. Either your traffic changed or your detection did. |
|
|
53
|
+
| `detector-failures` | Detectors are erroring or timing out. Detection is degraded — usually a resolver or a store, not the traffic. |
|
|
54
|
+
| `challenge-solve-rate` | Nearly everything being challenged is passing. **High is the bad direction.** |
|
|
55
|
+
|
|
56
|
+
### Why `challenge-solve-rate` reads backwards
|
|
57
|
+
|
|
58
|
+
It is the odd one out and the closest to this library's own thesis. Every other check asks
|
|
59
|
+
whether the *traffic* changed shape; this one asks whether the mitigation is landing on the
|
|
60
|
+
right population.
|
|
61
|
+
|
|
62
|
+
A proof-of-work [challenge](../challenge/index.md) is trivial for a browser and trivial for
|
|
63
|
+
a competent scraper — what it actually costs is a few seconds of somebody's afternoon — so
|
|
64
|
+
when nearly everything challenged goes on to pass, the challenges are not filtering bots
|
|
65
|
+
out, they are **taxing people**. From every other angle a challenge that gets solved looks
|
|
66
|
+
like a challenge that worked, which is precisely why this needs saying out loud.
|
|
67
|
+
|
|
68
|
+
### Floors and cooldowns
|
|
69
|
+
|
|
70
|
+
Every check has a **floor** as well as a ratio, because a quiet site at 3am produces "800%
|
|
71
|
+
more bots" from four requests, and an alerting system that cries wolf at 3am gets muted —
|
|
72
|
+
which is worse than not having one.
|
|
73
|
+
|
|
74
|
+
And every one has a **cooldown**: a spike lasting an hour is one event, not sixty.
|
|
75
|
+
|
|
76
|
+
## Your own checks
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
audit: {
|
|
80
|
+
extraChecks: [{
|
|
81
|
+
id: "checkout-scraping",
|
|
82
|
+
description: "Bots on the checkout funnel specifically",
|
|
83
|
+
evaluate: ({ window, baseline }) =>
|
|
84
|
+
window.botShare > 0.4 && window.botShare > baseline.botShare * 2
|
|
85
|
+
? { id: "checkout-scraping", severity: "warning", metric: "bot share",
|
|
86
|
+
value: window.botShare, baseline: baseline.botShare,
|
|
87
|
+
summary: `Checkout is ${(window.botShare * 100).toFixed(0)}% automated.` }
|
|
88
|
+
: undefined,
|
|
89
|
+
}],
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
A check that throws is skipped, not a reason for the audit to stop running. `checks`
|
|
94
|
+
replaces the built-in set entirely; `extraChecks` adds to it.
|
|
95
|
+
|
|
96
|
+
## Asking it yourself
|
|
97
|
+
|
|
98
|
+
The timer is a convenience, not the mechanism. `handler.audit` is the object, and
|
|
99
|
+
`runAudit()` runs the checks now and emits whatever they found — which is what a health
|
|
100
|
+
endpoint, a cron job or a test with a manual clock wants:
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
app.get("/internal/traffic", (_req, res) => res.json(detector.audit?.summary()));
|
|
104
|
+
|
|
105
|
+
const anomalies = detector.runAudit();
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Anomalies also reach configured [notification sinks](notifications.md) as `type: "anomaly"`
|
|
109
|
+
events, and land in the [dashboard's](dashboard.md) notices panel beside your startup
|
|
110
|
+
warnings. Switch the whole thing off with `audit: false`.
|
|
111
|
+
|
|
112
|
+
## Related
|
|
113
|
+
|
|
114
|
+
- [Metrics](metrics.md) — the counters this reads
|
|
115
|
+
- [Notifications](notifications.md) — where an anomaly goes
|
|
116
|
+
- [The guard](../concepts/the-guard.md) — what `guard-stop-spike` is about
|