@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,152 @@
|
|
|
1
|
+
# The challenge
|
|
2
|
+
|
|
3
|
+
A proof-of-work interstitial: what it buys, what it does not, and how to wire it up.
|
|
4
|
+
|
|
5
|
+
← [Documentation](../index.md)
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## What it actually buys, stated plainly
|
|
10
|
+
|
|
11
|
+
The client must find a counter such that `SHA-256(nonce + ":" + counter)` begins with
|
|
12
|
+
`difficulty` zero bits. Verification is one hash; solving takes on average `2^difficulty`
|
|
13
|
+
of them.
|
|
14
|
+
|
|
15
|
+
**It does not identify anyone and it does not prove a human is present.** A headless
|
|
16
|
+
Chrome solves it as readily as a person's phone, just paying for the CPU.
|
|
17
|
+
|
|
18
|
+
What it does is convert a scrape from free into merely cheap, and change the *shape* of the
|
|
19
|
+
attack: a stateless scraper pulling a million pages must now run a JavaScript engine and
|
|
20
|
+
burn CPU on every one. That is often enough to make bulk extraction not worth doing, and it
|
|
21
|
+
costs a real visitor a fraction of a second, once.
|
|
22
|
+
|
|
23
|
+
This is why the challenge sits where it does in the [action ladder](../policy/actions.md):
|
|
24
|
+
it is the heaviest thing the [guard](../concepts/the-guard.md) will let a *probabilistic*
|
|
25
|
+
verdict ask for, precisely because a client that is wrongly suspected can pass it on its
|
|
26
|
+
own and carry on.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Turning it on
|
|
31
|
+
|
|
32
|
+
One thing is required and has no default:
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
import { BotHandler } from "@osqd/bothandlerjs";
|
|
36
|
+
|
|
37
|
+
const detector = new BotHandler({
|
|
38
|
+
preset: "protect-content",
|
|
39
|
+
challenge: {
|
|
40
|
+
secrets: [process.env.BOT_CHALLENGE_SECRET!],
|
|
41
|
+
contactHtml: '<p>Locked out? Email <a href="mailto:support@example.com">support@example.com</a>.</p>',
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
A library-supplied fallback secret is a library-supplied forgery key, and it would end up
|
|
47
|
+
in production somewhere. Without `secrets`, a rule asking for a challenge degrades to a
|
|
48
|
+
`tag` and says so through [`onWarning`](../operations/notifications.md).
|
|
49
|
+
|
|
50
|
+
The [adapters](../integration/adapters.md) serve the verification endpoint for you. Nothing
|
|
51
|
+
else to mount.
|
|
52
|
+
|
|
53
|
+
### Rotating secrets
|
|
54
|
+
|
|
55
|
+
The first secret signs; all of them verify. Prepend a new one and keep the old for a token
|
|
56
|
+
lifetime, and nobody is logged out:
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
challenge: { secrets: [NEW_SECRET, PREVIOUS_SECRET] }
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## Every option
|
|
65
|
+
|
|
66
|
+
| Option | Default | Notes |
|
|
67
|
+
| ------ | ------- | ----- |
|
|
68
|
+
| `secrets` | — | required; first signs, all verify |
|
|
69
|
+
| `difficulty` | `16` | leading zero bits; see below |
|
|
70
|
+
| `challengeTtlMs` | `120_000` | how long a challenge may be solved for |
|
|
71
|
+
| `clearanceTtlMs` | `3_600_000` | how long a granted clearance lasts |
|
|
72
|
+
| `verifyPath` | `/__bothandler/verify` | where the solution is POSTed |
|
|
73
|
+
| `cookieName` | `__bh_clearance` | |
|
|
74
|
+
| `cookieSecure` | `true` | set `false` only for local plaintext development |
|
|
75
|
+
| `cookieSameSite` | `"Lax"` | |
|
|
76
|
+
| `title`, `message` | English defaults | page copy |
|
|
77
|
+
| `contactHtml` | — | **supply something real** |
|
|
78
|
+
| `translations` | — | see [localisation](localisation.md) |
|
|
79
|
+
|
|
80
|
+
### Difficulty
|
|
81
|
+
|
|
82
|
+
In *bits*, so each step doubles the work. The default 16 is around 65k hashes — tens of
|
|
83
|
+
milliseconds in any modern browser. It is refused above 24.
|
|
84
|
+
|
|
85
|
+
Past about 20 you are charging real people a visible delay, and the oldest and slowest
|
|
86
|
+
devices — which disproportionately belong to the users least able to replace them — pay
|
|
87
|
+
the most. Raise it during an incident, not as a posture.
|
|
88
|
+
|
|
89
|
+
### `contactHtml`
|
|
90
|
+
|
|
91
|
+
Everyone who sees the no-JavaScript fallback is a person your site just turned away: no
|
|
92
|
+
JavaScript, no WebCrypto, or a device too slow to finish. Put a support address, a phone
|
|
93
|
+
number or a link to a form there. This is the single highest-value line of configuration on
|
|
94
|
+
this page.
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## What a clearance proves
|
|
99
|
+
|
|
100
|
+
Solving grants a signed cookie, which the `clearance` [detector](../detection/detectors.md)
|
|
101
|
+
reads on subsequent requests. It carries a **level**, and the level is what a rule should
|
|
102
|
+
key on:
|
|
103
|
+
|
|
104
|
+
| Level | What was demonstrated | Certainty |
|
|
105
|
+
| ----- | --------------------- | --------- |
|
|
106
|
+
| `pow` | ran JavaScript, has WebCrypto, spent measurable CPU | not conclusive |
|
|
107
|
+
| `interaction` | a trusted input event was observed | stronger, still forgeable by a driven browser |
|
|
108
|
+
| `operator` | **your application** asserted this is a person | `certain` |
|
|
109
|
+
|
|
110
|
+
Only `operator` is treated as conclusive, because that assertion comes from you rather than
|
|
111
|
+
from the client. See [`isHuman`](../reference/configuration.md) for how to make one.
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## The lifecycle, and the one piece of state
|
|
116
|
+
|
|
117
|
+
Deliberately stateless up to the moment of success. A challenge is a signed blob the client
|
|
118
|
+
carries; the server stores nothing while it is being solved, so a flood of unsolved
|
|
119
|
+
challenges costs nothing but the bytes to send them.
|
|
120
|
+
|
|
121
|
+
Exactly one thing is written, at the one moment it is indispensable: the solved challenge's
|
|
122
|
+
nonce is **claimed atomically**, so a solution cannot be replayed.
|
|
123
|
+
|
|
124
|
+
That claim lives in the [store](../integration/stores.md). With the default in-memory store
|
|
125
|
+
and several replicas, a scraper retries a solved nonce against other instances until one
|
|
126
|
+
has not seen it. Pass a `RedisStore` if you run more than one process.
|
|
127
|
+
|
|
128
|
+
## Two refusals worth knowing
|
|
129
|
+
|
|
130
|
+
**Challenging an actor that already holds valid clearance** is refused and warned about.
|
|
131
|
+
Passing a challenge cannot change a proven verdict, so re-issuing would loop for ever.
|
|
132
|
+
|
|
133
|
+
**Challenging on an API** is a mistake the [`protect-api` preset](../policy/presets.md)
|
|
134
|
+
deliberately avoids: an API client is not a browser, so a challenge breaks your customers'
|
|
135
|
+
integrations while an attacker solves it once in headless Chrome. Rate-limit instead.
|
|
136
|
+
|
|
137
|
+
## Unsolved challenges as evidence
|
|
138
|
+
|
|
139
|
+
Every issued-and-never-solved challenge is counted on the [actor](../concepts/actors.md).
|
|
140
|
+
A client that has been asked five times and never once finished has told you something,
|
|
141
|
+
and rules can read it:
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
{ id: "persistent-refusal", match: { minUnsolvedChallenges: 5 }, action: "block" }
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Related
|
|
148
|
+
|
|
149
|
+
- [Localisation](localisation.md) — showing the page in a language the visitor reads
|
|
150
|
+
- [Actions](../policy/actions.md) — where `challenge` sits on the ladder
|
|
151
|
+
- [The guard](../concepts/the-guard.md) — why it is the ceiling for unproven verdicts
|
|
152
|
+
- [Stores](../integration/stores.md) — why replay protection needs a shared one
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# Challenge localisation
|
|
2
|
+
|
|
3
|
+
Showing the one public-facing page in a language the visitor can read.
|
|
4
|
+
|
|
5
|
+
← [Documentation](../index.md) · [The challenge](index.md)
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Why this exists
|
|
10
|
+
|
|
11
|
+
The interstitial is the only page this library shows to a member of the public, and it is
|
|
12
|
+
shown because a *probabilistic* verdict went against them.
|
|
13
|
+
|
|
14
|
+
Serving "Checking your browser" in English to somebody whose browser has been asking for
|
|
15
|
+
Japanese since the first request is the same unfairness [the guard](../concepts/the-guard.md)
|
|
16
|
+
exists to prevent, applied to the one screen where it is most visible: **a person who
|
|
17
|
+
cannot read the page cannot find the contact link on it either.** That turns a check into a
|
|
18
|
+
wall.
|
|
19
|
+
|
|
20
|
+
## The library ships no translations, and will not
|
|
21
|
+
|
|
22
|
+
A machine-translated apology on a page that just turned somebody away is worse than an
|
|
23
|
+
honest English one — and only you know which languages your audience actually reads. What
|
|
24
|
+
this does is pick between the translations *you* supply.
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
new BotHandler({
|
|
28
|
+
challenge: {
|
|
29
|
+
secrets: [process.env.BOT_CHALLENGE_SECRET!],
|
|
30
|
+
contactHtml: '<p>Locked out? <a href="/support">Contact us</a>.</p>',
|
|
31
|
+
translations: {
|
|
32
|
+
ja: {
|
|
33
|
+
title: "ブラウザーを確認しています",
|
|
34
|
+
message: "数秒で完了します。",
|
|
35
|
+
contactHtml: '<p>お困りですか?<a href="/support">サポート</a>へご連絡ください。</p>',
|
|
36
|
+
},
|
|
37
|
+
de: { title: "Browser wird überprüft" },
|
|
38
|
+
"pt-BR": { title: "Verificando seu navegador" },
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Anything omitted from a translation falls back to the default text, so a `title`-only entry
|
|
45
|
+
is a perfectly reasonable first step.
|
|
46
|
+
|
|
47
|
+
## How a language is chosen
|
|
48
|
+
|
|
49
|
+
`Accept-Language` is parsed, `q` honoured, malformed entries dropped rather than fatal —
|
|
50
|
+
this is a client-supplied header and the page it decides is one somebody is already having
|
|
51
|
+
a bad time with. `*` is dropped too: it means "anything", which is what the default is for.
|
|
52
|
+
Ordering is stable within a `q` value, so a header listing two equally-weighted languages
|
|
53
|
+
means the first one.
|
|
54
|
+
|
|
55
|
+
Then, in order:
|
|
56
|
+
|
|
57
|
+
1. **Exact tag.** `pt-BR` matches a `pt-BR` key.
|
|
58
|
+
2. **Primary subtag.** `pt-BR` matches a `pt` key — European Portuguese is far better than
|
|
59
|
+
English for a Brazilian visitor.
|
|
60
|
+
3. **Nothing.** The default English copy.
|
|
61
|
+
|
|
62
|
+
## Why `pt` never silently becomes `pt-BR`
|
|
63
|
+
|
|
64
|
+
Matching stops after the primary subtag. A visitor asking for `pt-PT` will **not** be handed
|
|
65
|
+
`pt-BR`.
|
|
66
|
+
|
|
67
|
+
That looks unhelpful until you consider the case it protects: serving Simplified Chinese to
|
|
68
|
+
somebody who asked for Traditional is a worse failure than serving English, and no rule can
|
|
69
|
+
tell the two situations apart from the header alone. Whether one regional variant stands in
|
|
70
|
+
for another is a judgement about *your* audience, so it is made by which keys you write
|
|
71
|
+
rather than by a heuristic here.
|
|
72
|
+
|
|
73
|
+
**Key by the primary tag** — `pt`, `zh`, `de` — unless you genuinely have separate regional
|
|
74
|
+
copy.
|
|
75
|
+
|
|
76
|
+
## The `lang` attribute
|
|
77
|
+
|
|
78
|
+
Each translation may set `lang`, defaulting to the key it is filed under.
|
|
79
|
+
|
|
80
|
+
It matters more than it looks. A screen reader picks its voice and its pronunciation rules
|
|
81
|
+
from this attribute, so Japanese text announced as `lang="en"` is read aloud by an English
|
|
82
|
+
voice and is unintelligible. Getting the copy right and the attribute wrong helps nobody.
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
translations: {
|
|
86
|
+
"zh-Hant": { lang: "zh-Hant", title: "正在檢查您的瀏覽器" },
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Testing it
|
|
91
|
+
|
|
92
|
+
`parseAcceptLanguage` and `pickTranslation` are pure and exported, so the choice can be
|
|
93
|
+
tested by calling it:
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
import { parseAcceptLanguage, pickTranslation } from "@osqd/bothandlerjs";
|
|
97
|
+
|
|
98
|
+
pickTranslation(translations, parseAcceptLanguage("pt-BR,pt;q=0.9,en;q=0.5"));
|
|
99
|
+
// → { tag: "pt-BR", copy: { title: "Verificando seu navegador" } }
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Related
|
|
103
|
+
|
|
104
|
+
- [The challenge](index.md) — what the page is for
|
|
105
|
+
- [Configuration](../reference/configuration.md) — the rest of `challenge`
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# Actors and behavioural memory
|
|
2
|
+
|
|
3
|
+
Who "the same client" is, what is remembered about them, and for how long.
|
|
4
|
+
|
|
5
|
+
← [Documentation](../index.md)
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
Several detectors reason about behaviour over time — rate, rhythm, breadth, rotation —
|
|
10
|
+
and all of them need an answer to one question first: *are these two requests the same
|
|
11
|
+
client?*
|
|
12
|
+
|
|
13
|
+
## The actor key
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
actorKey: (facts) => facts.ip // the default
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Every behavioural signal in this library is only as good as this function. The default is
|
|
20
|
+
the client address, which is the only thing always available and is also the weakest: a
|
|
21
|
+
corporate NAT, a mobile carrier's CGNAT pool, a university and a VPN exit all present
|
|
22
|
+
hundreds of real people as one actor.
|
|
23
|
+
|
|
24
|
+
If you can do better, do:
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
actorKey: (facts) => sessionIdFrom(facts) ?? facts.ip,
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
A session id, an authenticated user id, or an address plus a TLS fingerprint all make the
|
|
31
|
+
same detectors sharper — sharp enough that
|
|
32
|
+
[`identity-rotation`](../detection/detectors.md#identity-rotation) becomes worth enabling,
|
|
33
|
+
which it is not on a shared address.
|
|
34
|
+
|
|
35
|
+
## What is remembered
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
const snapshot = assessment.actor;
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
| Field | |
|
|
42
|
+
| ----- | --- |
|
|
43
|
+
| `key` | Whatever `actorKey` returned |
|
|
44
|
+
| `requests` | Requests in the window |
|
|
45
|
+
| `distinctPaths` | Breadth of the crawl |
|
|
46
|
+
| `firstSeen` / `lastSeen` | Epoch ms |
|
|
47
|
+
| `sinceLastMs` | Gap since the previous request |
|
|
48
|
+
| `priorConfirmations` | Times this actor was **proven** a bot before now |
|
|
49
|
+
| `unsolvedChallenges` | Challenges issued that no solution came back for |
|
|
50
|
+
| `cleared` | Holds a currently-valid human clearance |
|
|
51
|
+
|
|
52
|
+
`priorConfirmations` is a snapshot taken *before* this request's own outcome is recorded,
|
|
53
|
+
so a rule reading `minPriorConfirmations: 1` does not match on an actor's very first
|
|
54
|
+
request.
|
|
55
|
+
|
|
56
|
+
## Everything here is bounded
|
|
57
|
+
|
|
58
|
+
Every key in this subsystem is attacker-chosen, so nothing may grow without limit:
|
|
59
|
+
|
|
60
|
+
- An actor's arrival history is a **fixed-size ring** of timestamps.
|
|
61
|
+
- Its path set is **capped**, and the cap is visible (`pathsSaturated`) so a detector can
|
|
62
|
+
tell "wide" from "we stopped counting".
|
|
63
|
+
- The registry itself is a **bounded LRU** with a TTL: `maxActors` (default 20,000) and
|
|
64
|
+
`actorWindowMs` (default 15 minutes).
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
actorWindowMs: 900_000,
|
|
68
|
+
maxActors: 20_000,
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
An actor still sending traffic is never the one evicted to make room, and eviction is
|
|
72
|
+
plain forgetting: the next request from that client is assessed as a first request.
|
|
73
|
+
|
|
74
|
+
## It is process-local, deliberately
|
|
75
|
+
|
|
76
|
+
Rates, cadence and path breadth live in memory rather than in a shared store. A round trip
|
|
77
|
+
per request would buy accuracy for signals that are only ever allowed to *raise suspicion*
|
|
78
|
+
— never to deny anybody — and would put a network dependency on the hot path of every
|
|
79
|
+
request to your site.
|
|
80
|
+
|
|
81
|
+
Behind four replicas each one sees a quarter of an actor's traffic and is correspondingly
|
|
82
|
+
less sure. That is the right trade for something that cannot close a door on its own.
|
|
83
|
+
|
|
84
|
+
**A confirmation is not one of those**, and it can travel:
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
new BotHandler({ store: new RedisStore(redis), shareConfirmations: true });
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`confirmed-bot` is a *proven* verdict — a fact about the client rather than a judgement
|
|
91
|
+
about it — and without sharing it a client proven to be a bot on one replica is a stranger
|
|
92
|
+
to the other seven. Proof travels; suspicion stays home. See
|
|
93
|
+
[Shared state](../integration/stores.md).
|
|
94
|
+
|
|
95
|
+
## Acting on one actor by hand
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
detector.forgetActor("203.0.113.9"); // discard its history
|
|
99
|
+
detector.clearActor("203.0.113.9", 3_600_000); // grant human clearance for an hour
|
|
100
|
+
detector.registry.top(50, Date.now()); // who is here, busiest first
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`forgetActor` is the cure for a false positive that has stuck: a person whose actor key
|
|
104
|
+
collected a `confirmed-bot` carries `priorConfirmations` for the rest of the window, and
|
|
105
|
+
before this existed the only remedy was clearing every actor's history to fix one.
|
|
106
|
+
|
|
107
|
+
These are also on the [dashboard](../operations/dashboard.md), behind a control flag.
|
|
108
|
+
|
|
109
|
+
## Related
|
|
110
|
+
|
|
111
|
+
- [The detectors](../detection/detectors.md) — which ones read this state
|
|
112
|
+
- [Shared state](../integration/stores.md) — what crosses replicas
|
|
113
|
+
- [Getting the client address right](../integration/client-ip.md) — the input to all of it
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# Evidence and certainty
|
|
2
|
+
|
|
3
|
+
The distinction the whole library rests on: **proof versus suspicion**.
|
|
4
|
+
|
|
5
|
+
← [Documentation](../index.md)
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
Every detector produces *evidence*, never a verdict. A piece of evidence carries a
|
|
10
|
+
`certainty` tier, and there are two kinds of tier with two different sets of rules.
|
|
11
|
+
|
|
12
|
+
| Tier | Weight | What it means |
|
|
13
|
+
| ---- | ------ | ------------- |
|
|
14
|
+
| `certain` | — | **Proof.** There is no benign explanation, and the detector has to write down why. |
|
|
15
|
+
| `strong` | 0.60 | A signal with a real population of exceptions. |
|
|
16
|
+
| `moderate` | 0.35 | Suggestive. |
|
|
17
|
+
| `weak` | 0.15 | Worth a point, not worth a sentence. |
|
|
18
|
+
|
|
19
|
+
`certain` is not "very strong". It is a different kind of claim, and it is the only kind
|
|
20
|
+
that can cost somebody their access.
|
|
21
|
+
|
|
22
|
+
## What makes something certain
|
|
23
|
+
|
|
24
|
+
A `certain` piece of evidence must carry a `deterministicBasis` — a written explanation
|
|
25
|
+
of why no legitimate client produces this. Not "no legitimate client we know of": none,
|
|
26
|
+
by construction.
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
{
|
|
30
|
+
detector: "self-identified",
|
|
31
|
+
certainty: "certain",
|
|
32
|
+
summary: 'User-Agent identifies curl',
|
|
33
|
+
deterministicBasis:
|
|
34
|
+
'The product token "curl/" is emitted by an HTTP library or an automation ' +
|
|
35
|
+
"runtime and by no browser. Nothing a person does in a browser produces it.",
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
There are only a few genuine sources of proof, and they are all of the same shape —
|
|
40
|
+
**the client told us, or the client did something only automation does**:
|
|
41
|
+
|
|
42
|
+
- **A self-declaration.** `python-requests/2.31.0` in a User-Agent is not an inference.
|
|
43
|
+
If it is a lie, the misclassification belongs to whoever lied.
|
|
44
|
+
- **A refuted identity.** Something claimed to be Googlebot; the operator's own DNS says
|
|
45
|
+
the address is not Google's. The claim is disproven by the party entitled to answer.
|
|
46
|
+
- **A trap.** A path no link points at, a form field no rendered browser displays.
|
|
47
|
+
Reaching it requires reading the page as data rather than as a page.
|
|
48
|
+
- **A protocol violation.** Two `Host` headers. `Connection: keep-alive` on HTTP/2.
|
|
49
|
+
Something a compliant client cannot emit.
|
|
50
|
+
- **An operator assertion.** Your code said this is a person. We believe you.
|
|
51
|
+
|
|
52
|
+
Everything else is a judgement. A missing `Accept-Language` is *suspicious* and belongs
|
|
53
|
+
to a real population: privacy browsers, corporate proxies, screen readers, old phones.
|
|
54
|
+
|
|
55
|
+
## How the two tiers combine
|
|
56
|
+
|
|
57
|
+
Differently, on purpose.
|
|
58
|
+
|
|
59
|
+
**Proof does not accumulate.** One `certain` piece is enough, and a second adds nothing:
|
|
60
|
+
the verdict is already `confirmed-bot` or `verified-bot`, the score is reported as 100
|
|
61
|
+
for anything that charts one, and no amount of probabilistic evidence can produce the
|
|
62
|
+
same outcome. Certainty short-circuits scoring entirely.
|
|
63
|
+
|
|
64
|
+
**Suspicion accumulates by noisy-OR**, not by addition:
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
pBot = 1 − Π (1 − weight)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Three `moderate` signals reach 0.72, not 1.05. The function saturates, which is the
|
|
71
|
+
point: adding a fourth weak signal to three strong ones should barely move a number that
|
|
72
|
+
is already near certainty, and adding twenty weak ones must never *reach* it.
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
import { combineEvidence, noisyOr } from "@osqd/bothandlerjs";
|
|
76
|
+
|
|
77
|
+
noisyOr([0.6, 0.35, 0.35]); // 0.831 — not 1.3
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Families
|
|
81
|
+
|
|
82
|
+
Signals that are one signal wearing different hats are counted once. A client missing
|
|
83
|
+
`Accept-Language`, `Accept-Encoding` and `Referer` has one property — it is not a
|
|
84
|
+
browser — reported three times, and letting each contribute independently would triple a
|
|
85
|
+
single observation.
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
{ detector: "header-integrity", family: "absent-browser-headers", certainty: "moderate" }
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Within a family only the strongest counts. It is a scoring correction and nothing more:
|
|
92
|
+
families never touch the proven path.
|
|
93
|
+
|
|
94
|
+
### Human evidence subtracts
|
|
95
|
+
|
|
96
|
+
Almost every detector argues in one direction, which is a problem the scoring model has
|
|
97
|
+
to make visible. A person reading forty pages of documentation from a university's shared
|
|
98
|
+
address accumulates `rate-anomaly`, `cadence`, `crawl-breadth` and `ip-intelligence`
|
|
99
|
+
without a single thing being wrong with their request.
|
|
100
|
+
|
|
101
|
+
So evidence has a `direction`, and the engine discounts the bot score by whatever human
|
|
102
|
+
evidence it holds:
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
score = pBot × (1 − pHuman)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
[`browsing-coherence`](../detection/detectors.md#browsing-coherence) exists entirely to
|
|
109
|
+
produce that counterweight — a cache validator, a cookie jar, a same-site navigation are
|
|
110
|
+
the marks of a browsing session, and a scraper has none of them.
|
|
111
|
+
|
|
112
|
+
## Reading it back
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
const assessment = await detector.assess(facts);
|
|
116
|
+
|
|
117
|
+
assessment.certain; // true ← this, not the score, gates terminal actions
|
|
118
|
+
assessment.verdict; // "confirmed-bot"
|
|
119
|
+
assessment.score; // 100 for proven; 0–99 for a judgement
|
|
120
|
+
assessment.evidence; // strongest first
|
|
121
|
+
assessment.humanEvidence; // what argued the other way
|
|
122
|
+
assessment.evidence[0].deterministicBasis; // why it cannot be wrong
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
`strictEvidence` (on by default) makes the rule mechanical rather than cultural: a
|
|
126
|
+
detector returning `certain` with no `deterministicBasis` is rejected and reported
|
|
127
|
+
through `onWarning`. See [Writing a detector](../detection/writing-a-detector.md).
|
|
128
|
+
|
|
129
|
+
## Related
|
|
130
|
+
|
|
131
|
+
- [Verdicts, classes and scores](verdicts.md) — what the engine does with all this
|
|
132
|
+
- [The safety guard](the-guard.md) — what stops a score from denying anybody
|
|
133
|
+
- [The detectors](../detection/detectors.md) — which tier each one can reach
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# The safety guard
|
|
2
|
+
|
|
3
|
+
The mechanism that stops a guess from closing a door. If you read one page, read this one.
|
|
4
|
+
|
|
5
|
+
← [Documentation](../index.md)
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
Every bot detector eventually faces the same problem. The signals that catch sophisticated
|
|
10
|
+
automation — header consistency, TLS fingerprints, timing regularity, missing cookies —
|
|
11
|
+
are all *probabilistic*. Each one has a population of real people who trip it: somebody on
|
|
12
|
+
a privacy-hardened browser, behind a corporate proxy, using a screen reader, on a hotel
|
|
13
|
+
network, on a five-year-old phone.
|
|
14
|
+
|
|
15
|
+
The usual answer is to add the signals into a score and block above a threshold. That is
|
|
16
|
+
exactly the mistake. Points do not compose into proof. Two unrelated suspicions about an
|
|
17
|
+
unusual but entirely real browser reach 100 just as readily as two well-founded ones, and
|
|
18
|
+
the people who get caught are disproportionately the ones with the strongest reasons for
|
|
19
|
+
their unusual setup.
|
|
20
|
+
|
|
21
|
+
## What the guard does
|
|
22
|
+
|
|
23
|
+
It runs **after** a rule has been selected and before its action is applied:
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
rule matches → guard → decision
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Under the default `strict` mode there is exactly one question — *is there proof?* — and
|
|
30
|
+
everything else is a downgrade:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
if (!TERMINAL_ACTIONS.has(action)) return decision; // not terminal, nothing to check
|
|
34
|
+
if (mode === "aggressive") return decision; // the guard is off
|
|
35
|
+
if (assessment.certain) return decision; // proven; the rule stands
|
|
36
|
+
return downgrade(decision); // a guess. Not this.
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Terminal actions are `block`, `drop` and `redirect` — the three that end the request
|
|
40
|
+
without a way through. A downgraded decision becomes `fallbackAction` and records what it
|
|
41
|
+
was:
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
{
|
|
45
|
+
action: "challenge",
|
|
46
|
+
downgradedFrom: "block",
|
|
47
|
+
downgradeReason:
|
|
48
|
+
"Strict mode permits a terminal action only on proven evidence. This request's " +
|
|
49
|
+
"verdict (suspected-bot, score 78) rests on probabilistic signals, any of which " +
|
|
50
|
+
"a real client can trip.",
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Why it lives here and not in your rules
|
|
55
|
+
|
|
56
|
+
It cannot be forgotten in a rule, cannot be bypassed by a cleverly-worded predicate, and
|
|
57
|
+
does not depend on whoever wrote the rules understanding the certainty model. A rule that
|
|
58
|
+
says `action: "block"` on a probabilistic match is not a bug to be caught in review — it
|
|
59
|
+
is a thing the engine will simply decline to do.
|
|
60
|
+
|
|
61
|
+
Turning that off is a single, explicit, greppable line.
|
|
62
|
+
|
|
63
|
+
## The three modes
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
falsePositivePolicy: "strict" // default
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
| Mode | A terminal action survives when |
|
|
70
|
+
| ---- | ------------------------------- |
|
|
71
|
+
| `strict` | There is `certain` evidence. Nothing else. |
|
|
72
|
+
| `balanced` | There is proof, **or** the score clears `terminalScoreThreshold` with at least two *independent strong* signals. |
|
|
73
|
+
| `aggressive` | Always. The guard is off. |
|
|
74
|
+
|
|
75
|
+
`balanced` requires independence deliberately: three signals from the same family are one
|
|
76
|
+
observation, and `independentStrongSignals` counts families rather than pieces. Even so,
|
|
77
|
+
real people do trip two independent strong signals — a hardened browser behind a corporate
|
|
78
|
+
proxy is the usual pair — so `balanced` will eventually deny somebody who should have been
|
|
79
|
+
served. That is the trade it is; make it knowingly.
|
|
80
|
+
|
|
81
|
+
`aggressive` means every rule does exactly what it says, on proof or on suspicion alike.
|
|
82
|
+
The people it turns away first are the ones with the most unusual and most legitimate
|
|
83
|
+
setups.
|
|
84
|
+
|
|
85
|
+
## The fallback
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
fallbackAction: "challenge" // default when a challenge is configured, otherwise "tag"
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
What a stopped rule becomes. **It cannot be terminal**, and that is enforced rather than
|
|
92
|
+
advised: a `block` fallback would make every downgrade deny the request the downgrade
|
|
93
|
+
existed to protect — and the decision would still be *recorded* as a guard stop, so the
|
|
94
|
+
metric that exists to catch this would report success.
|
|
95
|
+
|
|
96
|
+
## Watching it
|
|
97
|
+
|
|
98
|
+
`bothandler_downgrades_total` is one of the two series worth alerting on. A rising count
|
|
99
|
+
means your rules are asking to deny requests the evidence does not support — the guard
|
|
100
|
+
working, and your policy needing attention.
|
|
101
|
+
|
|
102
|
+
The [dashboard](../operations/dashboard.md) breaks guard stops down by the rule that
|
|
103
|
+
overreached, which is usually enough to find the one at fault in a few seconds.
|
|
104
|
+
|
|
105
|
+
## Changing it
|
|
106
|
+
|
|
107
|
+
The guard is fixed at construction unless something opts in:
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
detector.updateGuard({ falsePositivePolicy: "balanced" }, { by: "ada@example.com" });
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
See [Runtime changes](../operations/runtime-changes.md). The dashboard exposes this behind
|
|
114
|
+
a control flag separate from its rule editor, because "which rules exist" and "how far a
|
|
115
|
+
rule may go" are different powers.
|
|
116
|
+
|
|
117
|
+
## Related
|
|
118
|
+
|
|
119
|
+
- [Evidence and certainty](evidence.md) — what "proof" means, precisely
|
|
120
|
+
- [Actions](../policy/actions.md) — which are terminal and which are not
|
|
121
|
+
- [Presets](../policy/presets.md) — every shipped policy keeps `strict`
|