@osqd/bothandlerjs 0.3.0 → 0.4.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 +157 -1
- package/README.md +1 -1
- package/dist/challenge/index.d.ts +18 -0
- package/dist/challenge/interaction.d.ts +215 -0
- package/dist/challenge/page.d.ts +18 -0
- package/dist/cli.cjs +653 -51
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +653 -51
- package/dist/cli.js.map +1 -1
- package/dist/core.d.ts +15 -0
- package/dist/corpus/index.cjs +20 -0
- package/dist/corpus/index.cjs.map +1 -1
- package/dist/corpus/index.js +20 -0
- package/dist/corpus/index.js.map +1 -1
- package/dist/dashboard/client/actions.d.ts +17 -0
- package/dist/dashboard/client/actor.d.ts +5 -0
- package/dist/dashboard/client/api.d.ts +15 -0
- package/dist/dashboard/client/app.d.ts +37 -0
- package/dist/dashboard/client/bars.d.ts +4 -0
- package/dist/dashboard/client/boot.d.ts +5 -0
- package/dist/dashboard/client/charts.d.ts +25 -0
- package/dist/dashboard/client/css.d.ts +11 -0
- package/dist/dashboard/client/dom.d.ts +55 -0
- package/dist/dashboard/client/draft.d.ts +46 -0
- package/dist/dashboard/client/feed.d.ts +6 -0
- package/dist/dashboard/client/format.d.ts +19 -0
- package/dist/dashboard/client/guard.d.ts +3 -0
- package/dist/dashboard/client/index.d.ts +1 -0
- package/dist/dashboard/client/outcome.d.ts +33 -0
- package/dist/dashboard/client/panels.d.ts +40 -0
- package/dist/dashboard/client/policy.d.ts +25 -0
- package/dist/dashboard/client/query.d.ts +42 -0
- package/dist/dashboard/client/ranges.d.ts +2 -0
- package/dist/dashboard/client/registry.d.ts +4 -0
- package/dist/dashboard/client/replay.d.ts +14 -0
- package/dist/dashboard/client/result.d.ts +10 -0
- package/dist/dashboard/client/store.d.ts +68 -0
- package/dist/dashboard/client/stream.d.ts +14 -0
- package/dist/dashboard/client/tester.d.ts +1 -0
- package/dist/dashboard/client/types.d.ts +69 -0
- package/dist/dashboard/client.generated.d.ts +1 -1
- package/dist/dashboard/page.d.ts +25 -0
- package/dist/dashboard/sections.d.ts +92 -0
- package/dist/dashboard/types.d.ts +2 -82
- package/dist/element/config.d.ts +135 -0
- package/dist/element/index.cjs +4844 -0
- package/dist/element/index.cjs.map +1 -0
- package/dist/element/index.d.ts +137 -0
- package/dist/element/index.js +4825 -0
- package/dist/element/index.js.map +1 -0
- package/dist/index.cjs +644 -51
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +635 -51
- package/dist/index.js.map +1 -1
- package/dist/metrics.d.ts +27 -0
- package/dist/policy/index.d.ts +1 -1
- package/dist/policy/presets.d.ts +47 -0
- package/docs/challenge/index.md +1 -0
- package/docs/challenge/interaction.md +325 -0
- package/docs/course/11-the-challenge.md +36 -1
- package/docs/course/13-operating-it.md +20 -2
- package/docs/index.md +2 -0
- package/docs/operations/dashboard.md +2 -0
- package/docs/operations/embedding.md +348 -0
- package/docs/operations/index.md +1 -0
- package/docs/policy/presets.md +53 -0
- package/docs/start/choosing-a-policy.md +1 -0
- package/package.json +8 -3
package/dist/metrics.d.ts
CHANGED
|
@@ -47,6 +47,18 @@ export interface MetricsSnapshot {
|
|
|
47
47
|
solved: number;
|
|
48
48
|
rejected: number;
|
|
49
49
|
};
|
|
50
|
+
/**
|
|
51
|
+
* Clearance granted, by level, and why the rest were turned down.
|
|
52
|
+
*
|
|
53
|
+
* Without these an operator running the interaction challenge is tuning blind. The
|
|
54
|
+
* split between `pow` and `interaction` says how many clients are clearing the higher
|
|
55
|
+
* bar; the rejection reasons say whether a rise is bots or a population whose browsers
|
|
56
|
+
* cannot answer a probe — and those call for opposite responses.
|
|
57
|
+
*/
|
|
58
|
+
clearances: Record<string, number>;
|
|
59
|
+
challengeRejections: Record<string, number>;
|
|
60
|
+
/** Interaction scores, in ten buckets of 0.1. The distribution `interactionAt` sits in. */
|
|
61
|
+
interactionScores: number[];
|
|
50
62
|
/**
|
|
51
63
|
* How suspicion is distributed across the traffic that was scored.
|
|
52
64
|
*
|
|
@@ -91,6 +103,9 @@ export declare class Metrics {
|
|
|
91
103
|
private challengesIssued;
|
|
92
104
|
private challengesSolved;
|
|
93
105
|
private challengesRejected;
|
|
106
|
+
private readonly clearances;
|
|
107
|
+
private readonly challengeRejections;
|
|
108
|
+
private readonly interactionScores;
|
|
94
109
|
private scoreCount;
|
|
95
110
|
private scoreTotal;
|
|
96
111
|
private readonly scoreBuckets;
|
|
@@ -104,6 +119,18 @@ export declare class Metrics {
|
|
|
104
119
|
recordDetectorTiming(detector: string, ms: number): void;
|
|
105
120
|
recordDecision(decision: Decision): void;
|
|
106
121
|
recordChallenge(event: "issued" | "solved" | "rejected"): void;
|
|
122
|
+
/** Which clearance a solve earned. */
|
|
123
|
+
recordClearance(level: string): void;
|
|
124
|
+
/**
|
|
125
|
+
* Why a challenge was turned down, bucketed by cause.
|
|
126
|
+
*
|
|
127
|
+
* Bucketed rather than recorded verbatim: the reason string carries measured numbers
|
|
128
|
+
* ("answered in 120ms"), and a counter keyed on those would grow without bound on a
|
|
129
|
+
* label an attacker controls the shape of.
|
|
130
|
+
*/
|
|
131
|
+
recordChallengeRejection(reason: string): void;
|
|
132
|
+
/** The interaction score a solve was graded on, into ten buckets of 0.1. */
|
|
133
|
+
recordInteractionScore(score: number): void;
|
|
107
134
|
snapshot(actorsTracked: number): MetricsSnapshot;
|
|
108
135
|
}
|
|
109
136
|
export interface PrometheusOptions {
|
package/dist/policy/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export { Policy } from "./policy.js";
|
|
2
2
|
export type { GuardSettings } from "./policy.js";
|
|
3
3
|
export { compileMatch, independentStrongSignals } from "./match.js";
|
|
4
|
-
export { monitorOnly, allowCrawlers, protectContent, declineAiTraining, protectData, protectApi, protectAuth, underAttack, PRESETS } from "./presets.js";
|
|
4
|
+
export { monitorOnly, allowCrawlers, protectContent, declineAiTraining, protectData, protectApi, protectAuth, indexersOnly, underAttack, PRESETS } from "./presets.js";
|
|
5
5
|
export type { PresetName } from "./presets.js";
|
|
6
6
|
export { ACTION_NAMES, TERMINAL_ACTIONS } from "./types.js";
|
|
7
7
|
export type { ActionName, ActionParams, Decision, FalsePositivePolicy, MatchSpec, PolicyOptions, Rule } from "./types.js";
|
package/dist/policy/presets.d.ts
CHANGED
|
@@ -111,6 +111,52 @@ export declare function declineAiTraining(): Rule[];
|
|
|
111
111
|
* challenges one on a content site is deliberately absent.
|
|
112
112
|
*/
|
|
113
113
|
export declare function protectApi(): Rule[];
|
|
114
|
+
/**
|
|
115
|
+
* Indexers welcome, everything else automated is not.
|
|
116
|
+
*
|
|
117
|
+
* The strictest permanent posture in this file: a crawler is served only when its
|
|
118
|
+
* identity has been *confirmed* against its operator's DNS or published ranges, and
|
|
119
|
+
* only when the job it does is bringing people to the site. Every other proven bot is
|
|
120
|
+
* refused, and suspicion escalates as far as the guard allows.
|
|
121
|
+
*
|
|
122
|
+
* Three things to be clear about before choosing it.
|
|
123
|
+
*
|
|
124
|
+
* **Most indexers cannot be verified at all.** Of the search and social signatures the
|
|
125
|
+
* library ships, twelve publish forward-confirmable DNS — Google, Bing, Yandex, Baidu,
|
|
126
|
+
* Apple, Sogou, Seznam, Naver, PetalBot, Cốc Cốc, Yahoo, Pinterest — and two more
|
|
127
|
+
* (DuckDuckBot, Facebook) are checkable only if you supply `crawlerRanges`. The other
|
|
128
|
+
* twenty-three, Twitterbot, LinkedInBot, Slackbot, Discord, Telegram, WhatsApp,
|
|
129
|
+
* Reddit, Mastodon and Bluesky among them, publish nothing to check a claim against.
|
|
130
|
+
* They can never reach `verified-bot`, so `unverifiable-indexer-block` refuses them and
|
|
131
|
+
* your pages stop getting link previews when somebody shares them. That rule is
|
|
132
|
+
* deliberately separate and deliberately named: change its action to `rate-limit` and
|
|
133
|
+
* you serve them at a ceiling instead, which is the trade every site makes differently.
|
|
134
|
+
*
|
|
135
|
+
* **A confirmed crawler outside `search` and `social` is refused too** — the AI
|
|
136
|
+
* crawlers, the SEO tools, the archivers, the uptime monitors, and **your own webhooks,
|
|
137
|
+
* health probes and server-side renderer**. That last group is how this preset breaks
|
|
138
|
+
* your own infrastructure on the first deploy; fifteen of the corpus's thirty-three
|
|
139
|
+
* infrastructure cases are refused by it. Allowlist yours first, by identity or by
|
|
140
|
+
* address, above everything else:
|
|
141
|
+
*
|
|
142
|
+
* ```ts
|
|
143
|
+
* rules: [{ id: "our-renderer", match: { identity: "our-ssr" }, action: "allow" }, ...indexersOnly()]
|
|
144
|
+
* ```
|
|
145
|
+
*
|
|
146
|
+
* **Suspicion is challenged, not blocked**, and no rule here asks otherwise. Under the
|
|
147
|
+
* default `strict` guard a `block` on a probabilistic verdict is downgraded to a
|
|
148
|
+
* challenge anyway, so writing one would express a strictness the engine does not have
|
|
149
|
+
* while reporting a guard stop on every suspicious request. If you want denial on
|
|
150
|
+
* suspicion, say so where it is visible — `falsePositivePolicy: "balanced"`, which
|
|
151
|
+
* additionally demands two independent strong signals — and add the rule that asks:
|
|
152
|
+
*
|
|
153
|
+
* ```ts
|
|
154
|
+
* { id: "high-suspicion-block", match: { verdict: "suspected-bot", minScore: 90 }, action: "block" }
|
|
155
|
+
* ```
|
|
156
|
+
*
|
|
157
|
+
* Some real people will be denied by that. Nothing in this preset does it for you.
|
|
158
|
+
*/
|
|
159
|
+
export declare function indexersOnly(): Rule[];
|
|
114
160
|
/**
|
|
115
161
|
* For while it is happening.
|
|
116
162
|
*
|
|
@@ -156,6 +202,7 @@ export declare const PRESETS: {
|
|
|
156
202
|
readonly "protect-data": typeof protectData;
|
|
157
203
|
readonly "protect-api": typeof protectApi;
|
|
158
204
|
readonly "protect-auth": typeof protectAuth;
|
|
205
|
+
readonly "indexers-only": typeof indexersOnly;
|
|
159
206
|
readonly "under-attack": typeof underAttack;
|
|
160
207
|
};
|
|
161
208
|
export type PresetName = keyof typeof PRESETS;
|
package/docs/challenge/index.md
CHANGED
|
@@ -146,6 +146,7 @@ and rules can read it:
|
|
|
146
146
|
|
|
147
147
|
## Related
|
|
148
148
|
|
|
149
|
+
- [The interaction challenge](interaction.md) — asking for a gesture, and probing the browser
|
|
149
150
|
- [Localisation](localisation.md) — showing the page in a language the visitor reads
|
|
150
151
|
- [Actions](../policy/actions.md) — where `challenge` sits on the ladder
|
|
151
152
|
- [The guard](../concepts/the-guard.md) — why it is the ceiling for unproven verdicts
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
# The interaction challenge
|
|
2
|
+
|
|
3
|
+
A gesture and a browser examination, on top of the proof of work — and a precise account
|
|
4
|
+
of what that is worth.
|
|
5
|
+
|
|
6
|
+
← [Documentation](../index.md) · [The challenge](index.md)
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## What it adds
|
|
11
|
+
|
|
12
|
+
The [proof of work](index.md) demonstrates that a JavaScript engine ran. This asks for two
|
|
13
|
+
more things:
|
|
14
|
+
|
|
15
|
+
1. **A deliberate gesture** — a checkbox the visitor ticks.
|
|
16
|
+
2. **Evidence that a browser rendered the page** — six probes that read back things only a
|
|
17
|
+
real rendering engine can produce.
|
|
18
|
+
|
|
19
|
+
Turn it on with one option:
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
new BotHandler({
|
|
23
|
+
challenge: {
|
|
24
|
+
secrets: [process.env.CHALLENGE_SECRET!],
|
|
25
|
+
contactHtml: '<p>Locked out? Email <a href="mailto:help@example.com">help@example.com</a>.</p>',
|
|
26
|
+
interaction: true,
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
**Solving the puzzle alone no longer grants clearance when this is on.** The gesture is
|
|
32
|
+
required. Read [what it costs people](#what-it-costs-people) before you enable it.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## What is actually verifiable
|
|
37
|
+
|
|
38
|
+
Everything the page reports is client-supplied, and the client is the one place an
|
|
39
|
+
adversary has complete control. Being precise about this is the difference between a
|
|
40
|
+
security control and a decoration.
|
|
41
|
+
|
|
42
|
+
| Signal | Who says so | Can it be faked? |
|
|
43
|
+
| ------ | ----------- | ---------------- |
|
|
44
|
+
| **Elapsed time between issue and answer** | **the server**, from the signed token's `iat` | **no** |
|
|
45
|
+
| `isTrusted` on the activation | the client | yes, by a browser driven through CDP |
|
|
46
|
+
| Capability probes | the client | yes, by actually having a browser — which is the point |
|
|
47
|
+
| Pointer path | the client | yes, with deliberate effort |
|
|
48
|
+
|
|
49
|
+
Exactly one row is server-verified. The rest raise cost rather than establish fact.
|
|
50
|
+
|
|
51
|
+
### The ceiling
|
|
52
|
+
|
|
53
|
+
Every probe here is answered by the client, and every value a probe asks about has to reach
|
|
54
|
+
the browser in order to be rendered. So anything willing to **parse the page it was served**
|
|
55
|
+
can answer without rendering anything. Measured, five challenges each:
|
|
56
|
+
|
|
57
|
+
| A client that… | Gets |
|
|
58
|
+
| -------------- | ---- |
|
|
59
|
+
| replays a report captured from a real browser | **0/5** |
|
|
60
|
+
| hardcodes a formula for the layout probe | **0/5** |
|
|
61
|
+
| **parses the served HTML and CSS each time** | **5/5** |
|
|
62
|
+
| actually renders the page | 5/5 |
|
|
63
|
+
|
|
64
|
+
Read the third row carefully. Against an adversary who writes a parser for your challenge
|
|
65
|
+
page, the interaction challenge adds **nothing over the plain proof of work except the
|
|
66
|
+
server-verified elapsed-time floor**. It is not a defence against somebody targeting you
|
|
67
|
+
specifically, and no client-side probe can be.
|
|
68
|
+
|
|
69
|
+
**What it does buy**, and this is worth having: it defeats every scraper that does not
|
|
70
|
+
bother — off-the-shelf tooling, `fetch()` in a loop, a report captured once and replayed, a
|
|
71
|
+
naive headless driver with a lerped mouse path. That is the overwhelming majority of
|
|
72
|
+
automated traffic, and for bulk extraction it is often the difference between worth doing
|
|
73
|
+
and not.
|
|
74
|
+
|
|
75
|
+
Nothing here is proof of humanity, and none of it is ever `certain`. If you need a bar an
|
|
76
|
+
adversary cannot step over by writing a parser, the bar is an account, not a challenge.
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## The capability probes
|
|
81
|
+
|
|
82
|
+
Each reads back something that only exists if a rendering engine produced it. An HTTP
|
|
83
|
+
client that parsed the HTML has no answer to any of them.
|
|
84
|
+
|
|
85
|
+
One of them is different for every challenge. **The layout probe** asks the page to lay out
|
|
86
|
+
a block whose box count and box height are both drawn from the challenge's nonce **under the
|
|
87
|
+
signing secret**, and to report the measured height. The client cannot compute that answer;
|
|
88
|
+
it can only measure it.
|
|
89
|
+
|
|
90
|
+
An earlier version fixed the height at 7px in the stylesheet and derived the count from the
|
|
91
|
+
nonce in the page's own script, which made the answer a formula an attacker reads once and
|
|
92
|
+
hardcodes for ever — a client that never rendered anything answered it correctly five times
|
|
93
|
+
out of five.
|
|
94
|
+
|
|
95
|
+
That closes a hole the other probes leave wide open. Every other answer in a report is the
|
|
96
|
+
same from one challenge to the next, so a report captured once from a real browser could be
|
|
97
|
+
replayed against fresh challenges for ever: solve the puzzle headlessly, paste the blob,
|
|
98
|
+
and never run a browser again — skipping precisely the cost this feature exists to impose.
|
|
99
|
+
Measured before the probe existed, one captured report was accepted for **five consecutive
|
|
100
|
+
challenges**; with it, for one.
|
|
101
|
+
|
|
102
|
+
It is still not proof against somebody who parses the page: both numbers have to reach the
|
|
103
|
+
browser to be rendered, so a parser can find them. What it removes is the *formula* — there
|
|
104
|
+
is no longer a fixed rule to implement once and reuse — and what it stops outright is
|
|
105
|
+
*replay*. See [the ceiling](#the-ceiling).
|
|
106
|
+
|
|
107
|
+
| Probe | Weight | What it asks |
|
|
108
|
+
| ----- | -----: | ------------ |
|
|
109
|
+
| `cssApplied` | 0.30 | A computed `letter-spacing` that only exists if a CSSOM parsed the stylesheet and ran the cascade |
|
|
110
|
+
| `layout` | 0.20 | A laid-out element has a non-zero box |
|
|
111
|
+
| `fontMetrics` | 0.15 | The same string in two families measures differently — there is a font engine |
|
|
112
|
+
| `animationFrame` | 0.15 | Two frames arrive, a plausible interval apart |
|
|
113
|
+
| `mediaQuery` | 0.10 | A media query evaluates against a real viewport |
|
|
114
|
+
| `hiddenIsHidden` | 0.10 | `display: none` is honoured, not merely parsed |
|
|
115
|
+
|
|
116
|
+
`cssApplied` carries the most weight because it is the one that separates *the population
|
|
117
|
+
this challenge is aimed at* — HTTP-client scrapers — from browsers. A real browser passes
|
|
118
|
+
every probe, including a headless one; that is expected and is the cost being imposed.
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
## Reading the pointer
|
|
123
|
+
|
|
124
|
+
When the checkbox is ticked with a pointer, the page sends a bounded, quantised record of
|
|
125
|
+
the movement leading up to it — a rolling window of the **most recent** 128 samples of
|
|
126
|
+
`[dx, dy, dt]`, rounded to two decimal places. Most recent matters: a fixed buffer that
|
|
127
|
+
stopped accepting samples once full kept a reader's idle wandering and discarded the
|
|
128
|
+
approach to the control, which is the one movement the analysis exists to recognise. **The analysis runs on the server.** A page that scored itself would simply
|
|
129
|
+
be asked to report a good score.
|
|
130
|
+
|
|
131
|
+
Five properties are measured, and they are weighted by *what they cost an attacker who is
|
|
132
|
+
trying*:
|
|
133
|
+
|
|
134
|
+
| Property | Weight | Naive path | A hand |
|
|
135
|
+
| -------- | -----: | ---------- | ------ |
|
|
136
|
+
| Distance per sample | 0.30 | 0.00 | ~0.60 |
|
|
137
|
+
| Straightness | 0.25 | 1.00 | ~0.85 |
|
|
138
|
+
| Speed variation | 0.15 | 0.00 | ~0.98 |
|
|
139
|
+
| Timing jitter | 0.10 | 0.00 | ~0.50 |
|
|
140
|
+
| Acceleration changes | 0.10 | 0 | many |
|
|
141
|
+
| Sub-pixel coordinates | 0.10 | none | most samples |
|
|
142
|
+
|
|
143
|
+
**Distance per sample carries the most, and speed carries less than it looks like it
|
|
144
|
+
should.** Speed is distance over time, so jittering the event timing alone manufactures
|
|
145
|
+
speed variation for free — 0.33 on a perfectly straight constant-step path, measured — and
|
|
146
|
+
ragged dispatch timing is exactly what an awaited automation loop produces without trying.
|
|
147
|
+
Distance per sample reads only where the pointer went, so a constant-step path scores zero
|
|
148
|
+
however uneven its timing.
|
|
149
|
+
|
|
150
|
+
The last two terms are worth little on purpose: bolting random noise onto a straight line
|
|
151
|
+
maxes both out immediately and is nearly free.
|
|
152
|
+
|
|
153
|
+
A **discontinuity is excluded rather than measured**. A sample arriving more than 250 ms
|
|
154
|
+
after the last one is the pointer reappearing — entering the window, coming back from
|
|
155
|
+
another application, or simply resting — and the distance across that gap is not a distance
|
|
156
|
+
a hand travelled. This matters more than it sounds: a perfectly even synthetic path
|
|
157
|
+
measured through a real browser scored 0.76 on distance variation, indistinguishable from a
|
|
158
|
+
person, because of exactly one sample — the pointer's first appearance, a 60×60 jump
|
|
159
|
+
recorded 1.25 s after load. One outlier in twenty-six.
|
|
160
|
+
|
|
161
|
+
### How much this actually separates
|
|
162
|
+
|
|
163
|
+
Fed paths directly, the analysis discriminates sharply:
|
|
164
|
+
|
|
165
|
+
| Path | Movement score |
|
|
166
|
+
| ---- | -------------: |
|
|
167
|
+
| Constant-velocity interpolation | **0.00** |
|
|
168
|
+
| The same line with random jitter | **0.38** |
|
|
169
|
+
| A short, quick human move | **0.69** |
|
|
170
|
+
| A full human move | **0.93** |
|
|
171
|
+
|
|
172
|
+
Driven through a **real browser**, it discriminates much less:
|
|
173
|
+
|
|
174
|
+
| Through Chromium | Movement | Composite |
|
|
175
|
+
| ---------------- | -------: | --------: |
|
|
176
|
+
| A person-like move | 0.75 | 0.90 |
|
|
177
|
+
| Evenly-stepped automation | 0.56 | 0.83 |
|
|
178
|
+
|
|
179
|
+
That gap is not wide enough to sit a threshold in, and raising `interactionAt` to catch the
|
|
180
|
+
second row would start refusing the first. **So be clear about what the movement analysis
|
|
181
|
+
is for:** it raises the bar for a client that *fabricates* a report without a browser —
|
|
182
|
+
where a lazy path scores 0.00 — and it is only a weak tiebreaker between two clients that
|
|
183
|
+
both really are browsers. The signals doing the load-bearing work against a real browser are
|
|
184
|
+
the server-verified elapsed time and the capability probes.
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
## How the clearance is graded
|
|
189
|
+
|
|
190
|
+
```
|
|
191
|
+
score = capabilities × 0.6 + movement × 0.4
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
| Outcome | Result |
|
|
195
|
+
| ------- | ------ |
|
|
196
|
+
| No interaction reported | **refused** |
|
|
197
|
+
| `isTrusted` false | **refused** |
|
|
198
|
+
| Answered faster than `minElapsedMs` | **refused** |
|
|
199
|
+
| Client claims it took longer than the challenge has existed | **refused** |
|
|
200
|
+
| The path describes more movement than the challenge lasted | **refused** |
|
|
201
|
+
| The layout probe is unanswered, or answers a different challenge | **refused** |
|
|
202
|
+
| Score below `refuseBelow` (0.2) | **refused** |
|
|
203
|
+
| Score at or above `interactionAt` (0.75) | `interaction` clearance |
|
|
204
|
+
| Anything in between | `pow` clearance |
|
|
205
|
+
|
|
206
|
+
`bothandler_interaction_score_bucket` counts refusals as well as successes. A distribution
|
|
207
|
+
fed only from what already passed has everything below the threshold cut out of it, which
|
|
208
|
+
is the part of the shape the threshold decision actually turns on.
|
|
209
|
+
|
|
210
|
+
Rejections name the probes that failed rather than only counting them — `capabilities 70%
|
|
211
|
+
(missing: fontMetrics, animationFrame)`. A percentage says something is wrong; the names say
|
|
212
|
+
whether it is bots or a population whose browsers cannot answer one particular question, and
|
|
213
|
+
those call for opposite responses.
|
|
214
|
+
|
|
215
|
+
`interactionAt` is set from measurement rather than taste. Fed a fabricated
|
|
216
|
+
constant-velocity path a client scores **0.60**, and a person in a browser scores **0.90**;
|
|
217
|
+
the bar sits between them. It does **not** separate crude automation inside a real browser
|
|
218
|
+
from a person — see the table above — and it is not set as though it does.
|
|
219
|
+
|
|
220
|
+
The [`clearance` detector](../detection/detectors.md) then reads the level: `interaction`
|
|
221
|
+
is `strong` human evidence at weight 0.7, `pow` is `moderate` at 0.45.
|
|
222
|
+
|
|
223
|
+
### A keyboard is never penalised
|
|
224
|
+
|
|
225
|
+
A pointer path is worth something, and its **absence is worth nothing either way**. Voice
|
|
226
|
+
control produces no pointer movement. Switch access produces machine-regular timing. A
|
|
227
|
+
screen reader activates the control from the keyboard.
|
|
228
|
+
|
|
229
|
+
Scoring those down would put assistive technology on the wrong side of a check the rest of
|
|
230
|
+
this library exists to keep people out of. So a keyboard or unclassified activation is
|
|
231
|
+
graded on its capabilities and its timing alone.
|
|
232
|
+
|
|
233
|
+
The consequence, stated plainly: **claiming keyboard activation is the cheapest way
|
|
234
|
+
through, and it costs a real browser.** That is the floor this feature raises, and it is
|
|
235
|
+
deliberate. The alternative — penalising the absence of mouse movement — refuses people
|
|
236
|
+
for how they use a computer.
|
|
237
|
+
|
|
238
|
+
A **tap is not a mouse**, and is reported as its own thing. Touch produces almost no
|
|
239
|
+
`pointermove` — one sample, frequently none — so classifying a tap as a pointer would score
|
|
240
|
+
it zero for movement and quietly grade every phone down to the weaker clearance. The page
|
|
241
|
+
reads `click.detail`, which the platform sets to 0 for a keyboard activation and to the
|
|
242
|
+
click count for a pointer, and takes the device from `pointerdown`.
|
|
243
|
+
|
|
244
|
+
A path **too short to describe is no evidence, not bad evidence**. Somebody whose cursor
|
|
245
|
+
already rested on the control, or who nudged it a few pixels, is graded on capabilities like
|
|
246
|
+
a keyboard or a tap.
|
|
247
|
+
|
|
248
|
+
Scoring that as zero was tried first, on the reasoning that a pointer activation showing no
|
|
249
|
+
movement describes something that did not happen. Measured, it was wrong in both directions
|
|
250
|
+
at once: it downgraded honest clients, and it caught nobody — `via` is a field the client
|
|
251
|
+
fills in, so an attacker with no path to show simply writes `keyboard` and is graded on
|
|
252
|
+
capabilities like everybody else. A rule that only ever costs honest people something is not
|
|
253
|
+
strictness.
|
|
254
|
+
|
|
255
|
+
The discrimination that does work is untouched: a path of four samples or more that looks
|
|
256
|
+
interpolated scores zero, and that is a claim about movement the client *did* report.
|
|
257
|
+
|
|
258
|
+
---
|
|
259
|
+
|
|
260
|
+
## Tuning
|
|
261
|
+
|
|
262
|
+
```ts
|
|
263
|
+
challenge: {
|
|
264
|
+
secrets: [SECRET],
|
|
265
|
+
interaction: {
|
|
266
|
+
minElapsedMs: 1000, // server-measured floor between issue and answer
|
|
267
|
+
interactionAt: 0.75, // at or above this, grant `interaction`
|
|
268
|
+
refuseBelow: 0.2, // below this, refuse outright
|
|
269
|
+
},
|
|
270
|
+
}
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
**`challengeTtlMs` defaults to ten minutes when the gesture is on**, rather than the two
|
|
274
|
+
the plain challenge uses. Two minutes is the right budget for a puzzle a machine solves in
|
|
275
|
+
milliseconds; it is the wrong one for a page that stops and waits for a person to read it
|
|
276
|
+
and act. Somebody using a screen reader that announces the whole page, on a slow device
|
|
277
|
+
where the proof of work itself takes twenty seconds, or simply interrupted, runs out and is
|
|
278
|
+
told to reload — having done nothing wrong and with no way to know why.
|
|
279
|
+
|
|
280
|
+
`minElapsedMs` is the one worth raising during an incident. It is server-verified, so it is
|
|
281
|
+
a hard ceiling on how fast a farm can work through challenges however many browsers it
|
|
282
|
+
runs — and it costs a real visitor nothing, because a person takes longer than a second to
|
|
283
|
+
read a page and tick a box anyway.
|
|
284
|
+
|
|
285
|
+
---
|
|
286
|
+
|
|
287
|
+
## What it costs people
|
|
288
|
+
|
|
289
|
+
The gesture is a **checkbox**, and that choice is the whole accessibility argument. It is
|
|
290
|
+
the one interactive control that every way of using a computer can operate: pointer, touch,
|
|
291
|
+
the space bar, a screen reader, switch access, voice control. A slider, a press-and-hold or
|
|
292
|
+
an image puzzle would each exclude somebody.
|
|
293
|
+
|
|
294
|
+
The interstitial with the gesture is axe-audited in both themes at every release, the
|
|
295
|
+
control has a real `<label>`, and the page moves focus to it when the puzzle finishes so
|
|
296
|
+
that a keyboard user is put on the one remaining action rather than having to go looking.
|
|
297
|
+
|
|
298
|
+
**Who still cannot get through:** anyone without JavaScript or WebCrypto, and anyone whose
|
|
299
|
+
browser cannot run the probes — a text-mode browser, for instance. They could not complete
|
|
300
|
+
the plain proof of work either, so this changes nothing for them, and `contactHtml` remains
|
|
301
|
+
their route. Supply a real one.
|
|
302
|
+
|
|
303
|
+
When the page gives up, **it withdraws the checkbox** rather than leaving it on screen. A
|
|
304
|
+
live control that cannot work offers a way through that does not exist, and it is worst for
|
|
305
|
+
somebody using a screen reader, who would find it, activate it, and be told nothing.
|
|
306
|
+
|
|
307
|
+
## Why not an image puzzle
|
|
308
|
+
|
|
309
|
+
Because they no longer work and they exclude people.
|
|
310
|
+
|
|
311
|
+
Machine learning solves image grids more accurately than humans do, and solving farms
|
|
312
|
+
charge about a dollar per thousand — so a visual CAPTCHA adds friction for people and a
|
|
313
|
+
rounding error for attackers. It also locks out blind and low-vision users, motor
|
|
314
|
+
impairments and cognitive disabilities.
|
|
315
|
+
|
|
316
|
+
The [guard](../concepts/the-guard.md) permits a `challenge` on a *guess* precisely because
|
|
317
|
+
the client can pass it on its own. For somebody who cannot see the puzzle, that is not a
|
|
318
|
+
challenge — it is a block, delivered on a guess. This library will not ship one.
|
|
319
|
+
|
|
320
|
+
## Related
|
|
321
|
+
|
|
322
|
+
- [The challenge](index.md) — the proof of work this builds on
|
|
323
|
+
- [Localisation](localisation.md) — the interstitial in a language the visitor reads
|
|
324
|
+
- [Client signals](../detection/client-signals.md) — the same reasoning, on ordinary pages
|
|
325
|
+
- [Threat model](../concepts/threat-model.md) — what none of this can do
|
|
@@ -133,6 +133,41 @@ Passing a challenge cannot change a proven verdict, so re-issuing would loop for
|
|
|
133
133
|
**Challenging on an API** breaks your customers' integrations and stops nobody — see
|
|
134
134
|
`protect-api` in [lesson 10](10-actions-and-presets.md).
|
|
135
135
|
|
|
136
|
+
## Asking for a gesture as well
|
|
137
|
+
|
|
138
|
+
The proof of work shows a JavaScript engine ran. One option asks for two more things — a
|
|
139
|
+
deliberate gesture, and evidence that a *browser* rendered the page:
|
|
140
|
+
|
|
141
|
+
```js
|
|
142
|
+
challenge: {
|
|
143
|
+
secrets: [process.env.SERIF_CHALLENGE_SECRET],
|
|
144
|
+
contactHtml: "<p>…</p>",
|
|
145
|
+
interaction: true,
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
The interstitial grows a checkbox, and six probes read back things only a rendering engine
|
|
150
|
+
produces — a computed style that needs the cascade to have run, a laid-out box, font
|
|
151
|
+
metrics, a frame loop. **Solving the puzzle alone no longer grants clearance**: the gesture
|
|
152
|
+
is required, and passing grants the stronger `interaction` clearance rather than `pow`.
|
|
153
|
+
|
|
154
|
+
The control is a checkbox rather than a slider or a puzzle for one reason: it is the only
|
|
155
|
+
interactive element every way of using a computer can operate — pointer, touch, the space
|
|
156
|
+
bar, a screen reader, switch access, voice control.
|
|
157
|
+
|
|
158
|
+
**Be clear about what it buys.** It does not prove a person. What it does is move a scraper
|
|
159
|
+
from `fetch()` in a loop to running a browser engine and rendering CSS per request, which
|
|
160
|
+
is three or four orders of magnitude more expensive. Exactly one signal in the exchange is
|
|
161
|
+
server-verified and cannot be faked: the elapsed time between issuing the challenge and
|
|
162
|
+
receiving the answer, taken from the signed token.
|
|
163
|
+
|
|
164
|
+
Watch it with the counters it emits — `bothandler_clearances_total{level=…}`,
|
|
165
|
+
`bothandler_challenge_rejections_total{cause=…}` and `bothandler_interaction_score_bucket`.
|
|
166
|
+
Without the score distribution, moving the threshold is guessing.
|
|
167
|
+
|
|
168
|
+
See [the interaction challenge](../challenge/interaction.md) for the full account,
|
|
169
|
+
including where its movement analysis stops working.
|
|
170
|
+
|
|
136
171
|
## Unsolved challenges as a signal
|
|
137
172
|
|
|
138
173
|
Every issued-and-never-solved challenge is counted on the [actor](07-actors.md):
|
|
@@ -221,7 +256,7 @@ browser" over an English "email us" is half a fix.
|
|
|
221
256
|
|
|
222
257
|
## Reference
|
|
223
258
|
|
|
224
|
-
- [The challenge](../challenge/index.md) · [Localisation](../challenge/localisation.md)
|
|
259
|
+
- [The challenge](../challenge/index.md) · [The interaction challenge](../challenge/interaction.md) · [Localisation](../challenge/localisation.md)
|
|
225
260
|
- [Actions](../policy/actions.md) — where `challenge` sits
|
|
226
261
|
|
|
227
262
|
Next: [Going live](12-going-live.md).
|
|
@@ -63,6 +63,23 @@ That combination is worth understanding. The dashboard people watch all day need
|
|
|
63
63
|
one that names individuals, or the one that explains your detection to whoever is scraping
|
|
64
64
|
you.
|
|
65
65
|
|
|
66
|
+
**It does not have to be its own page.** The same dashboard is also a custom element, so it
|
|
67
|
+
can live inside the admin tool your team already opens, rather than behind a second link
|
|
68
|
+
they have to remember:
|
|
69
|
+
|
|
70
|
+
```html
|
|
71
|
+
<bot-dashboard src="/_bots"></bot-dashboard>
|
|
72
|
+
<script type="module">
|
|
73
|
+
import { defineBotDashboard } from "@osqd/bothandlerjs/element";
|
|
74
|
+
defineBotDashboard();
|
|
75
|
+
</script>
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`sections` and `redact` above still apply — they are enforced on the server, and embedding
|
|
79
|
+
changes nothing about that. What the element adds is which screens appear and how it looks.
|
|
80
|
+
[Embedding it](../operations/embedding.md) has the whole of it, including what putting the
|
|
81
|
+
dashboard inside your own page costs you.
|
|
82
|
+
|
|
66
83
|
## Metrics
|
|
67
84
|
|
|
68
85
|
On by default; a handful of integer increments per request.
|
|
@@ -232,7 +249,8 @@ miss.
|
|
|
232
249
|
|
|
233
250
|
## Reference
|
|
234
251
|
|
|
235
|
-
- [The dashboard](../operations/dashboard.md) · [
|
|
236
|
-
- [
|
|
252
|
+
- [The dashboard](../operations/dashboard.md) · [Embedding it](../operations/embedding.md)
|
|
253
|
+
- [Metrics](../operations/metrics.md) · [The audit](../operations/audit.md)
|
|
254
|
+
- [Notifications](../operations/notifications.md)
|
|
237
255
|
|
|
238
256
|
Next: [Scaling and changing it live](14-scaling.md).
|
package/docs/index.md
CHANGED
|
@@ -59,6 +59,7 @@ Read these once and everything else follows from them. They are short.
|
|
|
59
59
|
| | |
|
|
60
60
|
| --- | --- |
|
|
61
61
|
| [The challenge](challenge/index.md) | Proof of work, clearance, and exactly what it buys. |
|
|
62
|
+
| [The interaction challenge](challenge/interaction.md) | A gesture and a browser examination, and exactly what they are worth. |
|
|
62
63
|
| [Languages](challenge/localisation.md) | Writing the interstitial in a language the visitor reads. |
|
|
63
64
|
|
|
64
65
|
## Running it
|
|
@@ -67,6 +68,7 @@ Read these once and everything else follows from them. They are short.
|
|
|
67
68
|
| --- | --- |
|
|
68
69
|
| [Operations overview](operations/index.md) | What to watch, and what to do when it moves. |
|
|
69
70
|
| [The dashboard](operations/dashboard.md) | What it shows, what it refuses to do, and every option it takes. |
|
|
71
|
+
| [Embedding it](operations/embedding.md) | `<bot-dashboard>` in a page you already have: screens, themes, panels of your own. |
|
|
70
72
|
| [Metrics](operations/metrics.md) | Counters, histograms and the Prometheus exposition. |
|
|
71
73
|
| [The traffic audit](operations/audit.md) | Watching the shape of your traffic rather than any one request. |
|
|
72
74
|
| [Notifications](operations/notifications.md) | Getting told, without being told a thousand times. |
|
|
@@ -719,6 +719,8 @@ what you see at `localhost:9674` is what you get in your own application.
|
|
|
719
719
|
|
|
720
720
|
## Related
|
|
721
721
|
|
|
722
|
+
- [Embedding it](embedding.md) — the same dashboard as an element inside your own admin
|
|
723
|
+
page, with the screens and theme you choose
|
|
722
724
|
- [Operations](index.md) — events, metrics, the audit
|
|
723
725
|
- [Runtime changes](runtime-changes.md) — what the editor and the guard panel actually call
|
|
724
726
|
- [Try it](../testing/try-it.md) — the demo, including the role-gated dashboards
|