@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/CHANGELOG.md
CHANGED
|
@@ -6,7 +6,163 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **`indexers-only`, a preset for sites that want search traffic and nothing else.**
|
|
12
|
+
Serves a bot only when its identity has been confirmed by DNS or a published range
|
|
13
|
+
*and* it is a `search` or `social` crawler; refuses every other proven bot; challenges
|
|
14
|
+
suspicion and holds weak signal to a ceiling. It is the first shipped preset whose
|
|
15
|
+
terminal rules cover all proven automation permanently rather than during an incident.
|
|
16
|
+
|
|
17
|
+
Read its documentation before choosing it. Twenty-three of the shipped search and
|
|
18
|
+
social signatures — Twitterbot, LinkedInBot, Slackbot, Discord, Telegram, WhatsApp,
|
|
19
|
+
Reddit, Mastodon, Bluesky among them — publish nothing that can confirm a claim, so
|
|
20
|
+
they can never be verified and `unverifiable-indexer-block` refuses them, taking your
|
|
21
|
+
link previews with it. Fifteen of the corpus's infrastructure cases are refused too,
|
|
22
|
+
including health checks, a server-side renderer and a payment webhook: allowlist your
|
|
23
|
+
own automation above the preset before switching it on.
|
|
24
|
+
|
|
25
|
+
- **`<bot-dashboard>`, the dashboard as an element.** A new entry point,
|
|
26
|
+
`@osqd/bothandlerjs/element`, exporting `defineBotDashboard()`. Mount
|
|
27
|
+
`createDashboardHandler` as before and drop the element into a page you already
|
|
28
|
+
have: it renders the whole dashboard into a shadow root in your own layout, under
|
|
29
|
+
your own heading, rather than on a route of its own.
|
|
30
|
+
|
|
31
|
+
`config.tabs` chooses which of the four screens appear, in what order and under what
|
|
32
|
+
labels. `config.theme` sets the scheme, the density and any of the stylesheet's
|
|
33
|
+
tokens — which is why every token block is now written `:root, :host`, since `:root`
|
|
34
|
+
matches nothing inside a shadow tree. `config.panels` adds panels of your own, fed
|
|
35
|
+
by a URL or a function and rendered as text.
|
|
36
|
+
|
|
37
|
+
**Be clear about what embedding costs.** A shadow root is a styling boundary and not
|
|
38
|
+
a security boundary: any script that can run on the host page can read every client
|
|
39
|
+
address and every piece of evidence the dashboard renders, and call its API with your
|
|
40
|
+
credentials. On the standalone page it could not. Mount it behind your admin
|
|
41
|
+
authentication and treat an XSS on that page as equivalent to handing the dashboard
|
|
42
|
+
over — or serve the standalone page, which is the same dashboard and already
|
|
43
|
+
isolated. `config.hide` is cosmetic; `sections` on the handler is the setting that
|
|
44
|
+
stops data leaving the process.
|
|
45
|
+
|
|
46
|
+
Embedded, the dashboard stops doing four things it does when it owns a page: binding
|
|
47
|
+
its keyboard shortcuts to the window, where a digit pressed on the host page switched
|
|
48
|
+
a tab in here; writing its tab and filter into `location.hash`, which is the host's
|
|
49
|
+
address bar and its back button; relying on a fragment anchor for the skip link,
|
|
50
|
+
which is inert across a shadow boundary; and refusing to mount a second time, which
|
|
51
|
+
made it unusable under any router, since unmount-and-remount is what a route change
|
|
52
|
+
is. A remount now keeps the feed history it had built.
|
|
53
|
+
|
|
54
|
+
`GET {basePath}/api/bootstrap` is new, serving the element the same configuration the
|
|
55
|
+
standalone page carries stamped into it.
|
|
56
|
+
|
|
57
|
+
The entry point imports safely on a server — Next, Remix, Astro and the rest evaluate
|
|
58
|
+
top-level imports while rendering, where there is no DOM — and `defineBotDashboard()`
|
|
59
|
+
does nothing until it is in a browser, so it can be imported like anything else and
|
|
60
|
+
called on mount.
|
|
61
|
+
|
|
62
|
+
- **An interaction challenge.** `challenge.interaction` asks the interstitial for a
|
|
63
|
+
deliberate gesture as well as the proof of work, and probes what the browser can
|
|
64
|
+
actually do while it waits. Six probes read back things only a rendering engine
|
|
65
|
+
produces — a computed style that requires the cascade to have run, a laid-out box,
|
|
66
|
+
font metrics that differ between two families, a frame loop, a media query, an element
|
|
67
|
+
that `display: none` actually hid. One of them is different for every challenge: the
|
|
68
|
+
box count and box height of the layout probe are drawn from the nonce under the
|
|
69
|
+
signing secret, so the client cannot compute the answer and can only measure it.
|
|
70
|
+
|
|
71
|
+
When it is on, solving the puzzle alone no longer grants clearance: the gesture is
|
|
72
|
+
required, and passing grants the `interaction` clearance level, which the `clearance`
|
|
73
|
+
detector has always known how to read and which nothing until now ever granted.
|
|
74
|
+
`challengeTtlMs` defaults to ten minutes rather than two, because the page now stops
|
|
75
|
+
and waits for a person to read it.
|
|
76
|
+
|
|
77
|
+
The gesture is a checkbox, and that is the whole accessibility argument: it is the one
|
|
78
|
+
interactive control that a pointer, a touch screen, the space bar, a screen reader,
|
|
79
|
+
switch access and voice control can all operate. A tap and a keypress are reported as
|
|
80
|
+
what they are and never marked down for producing no pointer path, and a pointer path
|
|
81
|
+
too short to measure is treated as no evidence rather than bad evidence.
|
|
82
|
+
|
|
83
|
+
**Be clear about the ceiling, which is measured rather than assumed.** A client that
|
|
84
|
+
replays a report captured from a real browser gets nowhere, and one that hardcodes a
|
|
85
|
+
formula for the layout probe gets nowhere — but one that *parses the served HTML and
|
|
86
|
+
CSS each time* passes every check, because both numbers have to reach the browser to
|
|
87
|
+
be rendered. Against an adversary who writes a parser for your challenge page this
|
|
88
|
+
adds nothing over the plain proof of work beyond the server-verified elapsed-time
|
|
89
|
+
floor, and no client-side probe can. What it defeats is every scraper that does not
|
|
90
|
+
bother. Nothing here is proof of humanity and none of it is ever `certain`.
|
|
91
|
+
|
|
92
|
+
- **Counters for the challenge that were not there.** `bothandler_clearances_total` by
|
|
93
|
+
level, `bothandler_challenge_rejections_total` by cause, and
|
|
94
|
+
`bothandler_interaction_score_bucket` in tenths, fed by refusals as well as successes
|
|
95
|
+
so the distribution is not censored at the threshold it exists to inform. The
|
|
96
|
+
`challenge` event carries `level`, `score` and `reason` to match.
|
|
97
|
+
|
|
98
|
+
`MetricsSnapshot` gains three fields, so anything constructing one by hand needs them.
|
|
99
|
+
|
|
100
|
+
- **A test that parses the interstitial's own inline script.** The page is built inside
|
|
101
|
+
a template literal, where one backtick — in a comment, a string, a regular
|
|
102
|
+
expression — ends the literal early and turns the rest into markup.
|
|
103
|
+
`tests/challenge-page.test.ts` parses the rendered script with `node:vm`, including
|
|
104
|
+
with operator-supplied copy that contains a backtick.
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
### Fixed
|
|
108
|
+
|
|
109
|
+
- **The "N not streamed" badge outlived the feed it described.** The badge adds two
|
|
110
|
+
counts: the server's rate-cap `skipped`, which `FeedRing.clear()` resets, and the
|
|
111
|
+
connection's lagged drops, which nothing did. After a Reset — or after the replace-sync
|
|
112
|
+
that follows a dropped stream, which is the likelier path, since a viewer dropped for
|
|
113
|
+
lagging reconnects with a stale cursor and is sent a fresh backlog — the number went on
|
|
114
|
+
reporting a gap in a window that had just been replaced, under a tooltip promising those
|
|
115
|
+
entries were still in it.
|
|
116
|
+
|
|
117
|
+
## [0.3.0] — 2026-09-06
|
|
118
|
+
|
|
119
|
+
### Changed
|
|
120
|
+
|
|
121
|
+
- **The package is now `@osqd/bothandlerjs`.** Every import moves with it —
|
|
122
|
+
`@osqd/bothandlerjs`, `/adapters`, `/client`, `/corpus`, `/cli` — and
|
|
123
|
+
`npm install bothandlerjs` becomes `npm install @osqd/bothandlerjs`. The CLI command
|
|
124
|
+
is unchanged: a `bin` name is independent of the package name, so `bothandlerjs check`
|
|
125
|
+
still works once installed, and `npx @osqd/bothandlerjs` installs it.
|
|
126
|
+
|
|
127
|
+
npm refuses capital letters in a new package name, so the spelling is
|
|
128
|
+
`@osqd/bothandlerjs` rather than `@osqd/BotHandlerJS`. `publishConfig.access` is set
|
|
129
|
+
to public, without which a scoped package publishes private on the first attempt, and
|
|
130
|
+
the tarball is now `osqd-bothandlerjs-<version>.tgz`.
|
|
131
|
+
|
|
132
|
+
### Added
|
|
133
|
+
|
|
134
|
+
- **Releases are cut from `main`.** `scripts/next-version.mjs` reads the commits since
|
|
135
|
+
the last release tag and works out the version — `feat:` minor, `fix:`/`perf:` patch,
|
|
136
|
+
a `!` or a `BREAKING CHANGE:` footer major (or minor while the major is 0), and
|
|
137
|
+
everything else nothing at all. That last rule is what makes publishing on every push
|
|
138
|
+
tolerable: a documentation fix releases nothing.
|
|
139
|
+
`.github/workflows/publish.yml` runs the full gate against the commit, refuses a
|
|
140
|
+
version already on the registry, bumps and tags, inspects the tarball, and publishes
|
|
141
|
+
with provenance.
|
|
142
|
+
|
|
143
|
+
- **A sixteen-lesson course**, in `docs/course/`, that builds one integration from a
|
|
144
|
+
first assessment to a policy you can defend. Every checkpoint in it is real output
|
|
145
|
+
from running the code.
|
|
146
|
+
- **A test for the published entry points.** `tests/entry-points.test.ts` asserts the
|
|
147
|
+
surface of all four, which is the actual fix for the two entries below: every module
|
|
148
|
+
in the repository imports its neighbours by path, so nothing exercised the paths the
|
|
149
|
+
documentation tells other people to use.
|
|
150
|
+
|
|
151
|
+
### Fixed
|
|
152
|
+
|
|
153
|
+
- **`@osqd/bothandlerjs/corpus` did not export `runCorpus`.** That is the entire point
|
|
154
|
+
of publishing the entry point, and the README, the changelog, the design notes and
|
|
155
|
+
three documentation pages all told people to import it. It would have failed for
|
|
156
|
+
everyone outside this repository, and no test could have noticed because every
|
|
157
|
+
internal caller reaches past the entry point to `./runner.js`.
|
|
158
|
+
|
|
159
|
+
- **`renderClientScript` and `parseClientSignals` were documented on the root export.**
|
|
160
|
+
They live behind `/client`, and the example omitted the required `endpoint` argument.
|
|
161
|
+
|
|
162
|
+
- **A challenge test failed about one run in 271, claiming a bad proof of work had been
|
|
163
|
+
accepted.** It submitted the fixed solution `"1"` against a random nonce at difficulty
|
|
164
|
+
8, where one guess in 256 is a valid proof by accident. It searches for a counter that
|
|
165
|
+
provably misses now, and asserts that it misses before submitting it.
|
|
10
166
|
|
|
11
167
|
## [0.2.0]
|
|
12
168
|
|
package/README.md
CHANGED
|
@@ -41,7 +41,7 @@ explaining why a thing exists as well as how to use it.
|
|
|
41
41
|
| **Concepts** | [Evidence and certainty](docs/concepts/evidence.md) · [Verdicts and scores](docs/concepts/verdicts.md) · [The safety guard](docs/concepts/the-guard.md) · [Actors](docs/concepts/actors.md) · [Threat model](docs/concepts/threat-model.md) |
|
|
42
42
|
| **[Detection](docs/detection/index.md)** | [The 20 detectors](docs/detection/detectors.md) · [Signatures](docs/detection/signatures.md) · [Verification](docs/detection/verification.md) · [Browser signals](docs/detection/client-signals.md) · [Writing a detector](docs/detection/writing-a-detector.md) |
|
|
43
43
|
| **[Policy](docs/policy/index.md)** | [Rules](docs/policy/rules.md) · [Actions](docs/policy/actions.md) · [Presets](docs/policy/presets.md) · [robots.txt](docs/policy/robots.md) · [The challenge](docs/challenge/index.md) |
|
|
44
|
-
| **[Operations](docs/operations/index.md)** | [The dashboard](docs/operations/dashboard.md) · [Metrics](docs/operations/metrics.md) · [The audit](docs/operations/audit.md) · [Notifications](docs/operations/notifications.md) · [Runtime changes](docs/operations/runtime-changes.md) |
|
|
44
|
+
| **[Operations](docs/operations/index.md)** | [The dashboard](docs/operations/dashboard.md) · [Embedding it](docs/operations/embedding.md) · [Metrics](docs/operations/metrics.md) · [The audit](docs/operations/audit.md) · [Notifications](docs/operations/notifications.md) · [Runtime changes](docs/operations/runtime-changes.md) |
|
|
45
45
|
| **[Integration](docs/integration/index.md)** | [Adapters](docs/integration/adapters.md) · [The client IP](docs/integration/client-ip.md) · [Stores](docs/integration/stores.md) |
|
|
46
46
|
| **[Testing](docs/testing/index.md)** | [The CLI](docs/testing/cli.md) · [The corpus](docs/testing/corpus.md) · [Log replay](docs/testing/replay.md) · [Try it locally](docs/testing/try-it.md) |
|
|
47
47
|
| **Reference** | [Configuration](docs/reference/configuration.md) · [API](docs/reference/api.md) · [Design decisions](docs/design/decisions.md) |
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ChallengeCopy } from "./language.js";
|
|
2
|
+
import type { InteractionSettings } from "./interaction.js";
|
|
2
3
|
import type { Clock } from "../internal/clock.js";
|
|
3
4
|
import type { BotHandlerStore } from "../stores/types.js";
|
|
4
5
|
import type { ClearanceClaims, ClearanceLevel } from "./token.js";
|
|
@@ -73,6 +74,16 @@ export interface ChallengeOptions {
|
|
|
73
74
|
* write rather than by a heuristic here.
|
|
74
75
|
*/
|
|
75
76
|
translations?: Record<string, ChallengeCopy>;
|
|
77
|
+
/**
|
|
78
|
+
* Ask for a deliberate gesture as well as the proof of work, and measure what the
|
|
79
|
+
* browser can actually do while waiting for it.
|
|
80
|
+
*
|
|
81
|
+
* `true` uses {@link DEFAULT_INTERACTION_SETTINGS}. Solving the puzzle alone no longer
|
|
82
|
+
* grants clearance when this is on: the gesture is required. Read
|
|
83
|
+
* `src/challenge/interaction.ts` before turning it on — in particular the part about
|
|
84
|
+
* what is and is not verifiable — because it changes who can get through your site.
|
|
85
|
+
*/
|
|
86
|
+
interaction?: boolean | Partial<InteractionSettings>;
|
|
76
87
|
store?: BotHandlerStore;
|
|
77
88
|
clock?: Clock;
|
|
78
89
|
}
|
|
@@ -85,10 +96,13 @@ export type SolutionOutcome = {
|
|
|
85
96
|
ok: true;
|
|
86
97
|
setCookie: string;
|
|
87
98
|
level: ClearanceLevel;
|
|
99
|
+
interactionScore?: number;
|
|
100
|
+
notes?: readonly string[];
|
|
88
101
|
} | {
|
|
89
102
|
ok: false;
|
|
90
103
|
status: number;
|
|
91
104
|
reason: string;
|
|
105
|
+
interactionScore?: number | undefined;
|
|
92
106
|
};
|
|
93
107
|
/**
|
|
94
108
|
* Issues challenges, verifies solutions and grants clearance.
|
|
@@ -106,6 +120,8 @@ export declare class ChallengeService {
|
|
|
106
120
|
private readonly challengeTtlMs;
|
|
107
121
|
private readonly clock;
|
|
108
122
|
private readonly store;
|
|
123
|
+
/** Resolved interaction settings, or `undefined` when the gesture is not asked for. */
|
|
124
|
+
private readonly interaction;
|
|
109
125
|
readonly verifyPath: string;
|
|
110
126
|
readonly cookieName: string;
|
|
111
127
|
/**
|
|
@@ -115,6 +131,8 @@ export declare class ChallengeService {
|
|
|
115
131
|
*/
|
|
116
132
|
readonly clearanceTtlMs: number;
|
|
117
133
|
constructor(options: ChallengeOptions);
|
|
134
|
+
/** Whether this service asks for a gesture as well as the puzzle. */
|
|
135
|
+
get wantsInteraction(): boolean;
|
|
118
136
|
/**
|
|
119
137
|
* Derives the subject a token is bound to.
|
|
120
138
|
*
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The interaction challenge: what a client did on the interstitial, and what that is
|
|
3
|
+
* worth.
|
|
4
|
+
*
|
|
5
|
+
* The proof of work in `pow.ts` demonstrates that a JavaScript engine ran. That is a
|
|
6
|
+
* real cost and it is the whole of what the plain challenge asks for. This asks for
|
|
7
|
+
* something else: evidence that a *browser* rendered the page and that a *person*
|
|
8
|
+
* acted on it.
|
|
9
|
+
*
|
|
10
|
+
* ## What is actually verifiable, stated plainly
|
|
11
|
+
*
|
|
12
|
+
* Everything on this page is reported by the client, and the client is the one place an
|
|
13
|
+
* adversary has complete control. A determined attacker driving a real browser through
|
|
14
|
+
* CDP dispatches genuine input events, produces real layout, and can synthesise pointer
|
|
15
|
+
* paths from a minimum-jerk model — the literature on human motor movement is public and
|
|
16
|
+
* so are the libraries. **Nothing here is proof of humanity and none of it may ever be
|
|
17
|
+
* `certain`.**
|
|
18
|
+
*
|
|
19
|
+
* What it does is move the cost. A scraper that was `fetch()` in a loop must now run a
|
|
20
|
+
* browser engine, render CSS, and synthesise a plausible gesture per request. That is
|
|
21
|
+
* three or four orders of magnitude more expensive per page, and for most bulk
|
|
22
|
+
* extraction it is the difference between worth doing and not.
|
|
23
|
+
*
|
|
24
|
+
* Exactly one signal here is **server-verified** and cannot be fabricated: the elapsed
|
|
25
|
+
* wall-clock time between issuing the challenge and receiving the answer, which comes
|
|
26
|
+
* from the signed token's `iat` rather than from anything the client says. See
|
|
27
|
+
* {@link verifyInteraction}.
|
|
28
|
+
*
|
|
29
|
+
* ## Why the analysis lives here and not in the page
|
|
30
|
+
*
|
|
31
|
+
* If the page scored itself and posted the number, an attacker would post the number.
|
|
32
|
+
* The client sends a bounded, quantised record of what happened; every judgement about
|
|
33
|
+
* it is made on this side.
|
|
34
|
+
*/
|
|
35
|
+
/**
|
|
36
|
+
* The shape of one challenge's layout probe: how many boxes, and how tall each one is.
|
|
37
|
+
*
|
|
38
|
+
* Both are drawn from the nonce **under the signing secret**, which is the whole point.
|
|
39
|
+
* An earlier version fixed the height at 7px in the stylesheet and derived the count from
|
|
40
|
+
* the nonce in the page's own script, so the expected answer was a formula an attacker
|
|
41
|
+
* could read once and hardcode for ever — a client that never rendered anything answered
|
|
42
|
+
* it correctly five times out of five. Deriving it under the secret means the client
|
|
43
|
+
* cannot compute the answer at all. It can only measure it.
|
|
44
|
+
*
|
|
45
|
+
* That does not make it unforgeable. Both numbers still have to reach the browser to be
|
|
46
|
+
* rendered — the count as elements in the markup, the height as a literal in the
|
|
47
|
+
* stylesheet — so anything willing to parse the page it was served can still find them.
|
|
48
|
+
* What it removes is the *formula*: there is no longer a fixed rule to implement once and
|
|
49
|
+
* reuse. See the honesty section in docs/challenge/interaction.md.
|
|
50
|
+
*/
|
|
51
|
+
export interface ProbeShape {
|
|
52
|
+
boxes: number;
|
|
53
|
+
height: number;
|
|
54
|
+
}
|
|
55
|
+
/** Derives {@link ProbeShape} for a nonce. The secret keeps it unpredictable to the client. */
|
|
56
|
+
export declare function probeShapeFor(nonce: string, secret: string): ProbeShape;
|
|
57
|
+
/** One pointer sample: movement since the previous sample, and the gap in milliseconds. */
|
|
58
|
+
export interface PointerSample {
|
|
59
|
+
dx: number;
|
|
60
|
+
dy: number;
|
|
61
|
+
dt: number;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* What the interstitial observed. Every field is client-supplied and therefore a claim
|
|
65
|
+
* rather than a fact; see the note at the top of this file.
|
|
66
|
+
*/
|
|
67
|
+
export interface InteractionReport {
|
|
68
|
+
/**
|
|
69
|
+
* `event.isTrusted` on the activation.
|
|
70
|
+
*
|
|
71
|
+
* False for anything `element.click()` or a synthesised `MouseEvent` produces, which
|
|
72
|
+
* is what a naive automation script reaches for first. A browser driven through the
|
|
73
|
+
* DevTools protocol produces `true`, so this is a floor rather than a test.
|
|
74
|
+
*/
|
|
75
|
+
trusted: boolean;
|
|
76
|
+
/**
|
|
77
|
+
* How the control was activated.
|
|
78
|
+
*
|
|
79
|
+
* `pointer` means a mouse, and is the only value from which a path is expected. `touch`
|
|
80
|
+
* covers a tap or a stylus, which legitimately produce almost no movement; `keyboard`
|
|
81
|
+
* covers the space bar, a screen reader, switch access and voice control; `other` is
|
|
82
|
+
* anything the page could not classify.
|
|
83
|
+
*/
|
|
84
|
+
via: "pointer" | "touch" | "keyboard" | "other";
|
|
85
|
+
/** Milliseconds from page load to activation, as the *client* measured it. */
|
|
86
|
+
msToActivate: number;
|
|
87
|
+
/** Bounded, quantised pointer path leading up to the activation. */
|
|
88
|
+
path: readonly PointerSample[];
|
|
89
|
+
/** Results of the capability probes. See {@link CAPABILITY_WEIGHTS}. */
|
|
90
|
+
capabilities: Readonly<Record<string, boolean>>;
|
|
91
|
+
/**
|
|
92
|
+
* Measured height of the nonce-bound layout probe, in CSS pixels, or `undefined` when
|
|
93
|
+
* the client reported none. See {@link probeShapeFor}.
|
|
94
|
+
*/
|
|
95
|
+
layoutHeight?: number | undefined;
|
|
96
|
+
}
|
|
97
|
+
/** What the movement looked like, once measured rather than asserted. */
|
|
98
|
+
export interface MovementAnalysis {
|
|
99
|
+
samples: number;
|
|
100
|
+
/**
|
|
101
|
+
* Coefficient of variation of the distance covered per sample.
|
|
102
|
+
*
|
|
103
|
+
* The primary spatial measure, and the one that cannot be manufactured by accident:
|
|
104
|
+
* it reads only where the pointer went, never how fast the events arrived.
|
|
105
|
+
*/
|
|
106
|
+
distanceVariation: number;
|
|
107
|
+
/** Coefficient of variation of speed. Human movement is ballistic; a lerp is flat. */
|
|
108
|
+
speedVariation: number;
|
|
109
|
+
/** Coefficient of variation of the gaps between samples. */
|
|
110
|
+
timingVariation: number;
|
|
111
|
+
/** Times the speed changed direction — sped up after slowing, or the reverse. */
|
|
112
|
+
accelerationChanges: number;
|
|
113
|
+
/** Net displacement over path length. 1 is a perfectly straight line. */
|
|
114
|
+
straightness: number;
|
|
115
|
+
/** Share of samples carrying a fractional component. */
|
|
116
|
+
fractionalShare: number;
|
|
117
|
+
/** Sum of absolute turn angles, in radians, over the whole path. */
|
|
118
|
+
totalTurning: number;
|
|
119
|
+
}
|
|
120
|
+
export type InteractionOutcome = {
|
|
121
|
+
ok: true;
|
|
122
|
+
level: "interaction" | "pow";
|
|
123
|
+
score: number;
|
|
124
|
+
notes: readonly string[];
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* `score` and `notes` are present whenever the refusal came from the grading rather
|
|
128
|
+
* than from a gate reached before it.
|
|
129
|
+
*
|
|
130
|
+
* Carrying them matters more than it looks. The score histogram exists so that an
|
|
131
|
+
* operator can see where to put `interactionAt`, and feeding it only from successes
|
|
132
|
+
* gives them a distribution with everything below the threshold cut out of it — which
|
|
133
|
+
* is precisely the part of the shape the decision depends on.
|
|
134
|
+
*/
|
|
135
|
+
| {
|
|
136
|
+
ok: false;
|
|
137
|
+
reason: string;
|
|
138
|
+
score?: number | undefined;
|
|
139
|
+
notes?: readonly string[] | undefined;
|
|
140
|
+
};
|
|
141
|
+
export interface InteractionSettings {
|
|
142
|
+
/**
|
|
143
|
+
* Least time that may pass between issuing a challenge and accepting its answer, ms.
|
|
144
|
+
*
|
|
145
|
+
* Checked against the signed token rather than against anything the client reports,
|
|
146
|
+
* which makes it the one part of this file an attacker cannot lie about. It is also
|
|
147
|
+
* the part that costs them most: a floor of one second is a hard ceiling on how fast
|
|
148
|
+
* a farm can work through challenges, however many browsers it runs.
|
|
149
|
+
*/
|
|
150
|
+
minElapsedMs: number;
|
|
151
|
+
/** Score at or above which the clearance is `interaction` rather than `pow`. */
|
|
152
|
+
interactionAt: number;
|
|
153
|
+
/** Score below which the answer is refused outright. */
|
|
154
|
+
refuseBelow: number;
|
|
155
|
+
}
|
|
156
|
+
export declare const DEFAULT_INTERACTION_SETTINGS: InteractionSettings;
|
|
157
|
+
/**
|
|
158
|
+
* What each capability probe is worth.
|
|
159
|
+
*
|
|
160
|
+
* These are ordered by how hard the capability is to fake *without actually having it*.
|
|
161
|
+
* `cssApplied` is the one that matters most: it asks the page to read back a computed
|
|
162
|
+
* style that only exists if a CSSOM parsed the stylesheet and applied the cascade. An
|
|
163
|
+
* HTTP client that parses HTML has no answer to it, and that is precisely the population
|
|
164
|
+
* a challenge is aimed at.
|
|
165
|
+
*
|
|
166
|
+
* None of them is worth much on its own, and a real browser driven by a script passes
|
|
167
|
+
* every one — which is why they contribute to a score rather than to a verdict.
|
|
168
|
+
*/
|
|
169
|
+
export declare const CAPABILITY_WEIGHTS: Readonly<Record<string, number>>;
|
|
170
|
+
/**
|
|
171
|
+
* Measures a pointer path.
|
|
172
|
+
*
|
|
173
|
+
* The statistics are chosen for what they cost to fake convincingly rather than for how
|
|
174
|
+
* well they describe a human. Anyone can add noise to a straight line; producing a path
|
|
175
|
+
* whose *speed profile* is ballistic, whose turning is concentrated near the target, and
|
|
176
|
+
* whose sample timing jitters the way a real event stream does takes deliberate work.
|
|
177
|
+
*/
|
|
178
|
+
export declare function analyseMovement(path: readonly PointerSample[]): MovementAnalysis;
|
|
179
|
+
/**
|
|
180
|
+
* How human-shaped a measured path is, 0 to 1.
|
|
181
|
+
*
|
|
182
|
+
* Each term is a property that costs an attacker something different to reproduce, and
|
|
183
|
+
* the sum is deliberately forgiving: a person making one short, quick, straight-ish
|
|
184
|
+
* movement to a checkbox should not be punished for it. What this separates reliably is
|
|
185
|
+
* *linear interpolation* — the constant-velocity, uniform-timing, integer-coordinate
|
|
186
|
+
* path that every naive automation library produces — from anything organic.
|
|
187
|
+
*/
|
|
188
|
+
export declare function scoreMovement(analysis: MovementAnalysis): number;
|
|
189
|
+
/** How much of the browser is really there, 0 to 1. */
|
|
190
|
+
export declare function scoreCapabilities(capabilities: Readonly<Record<string, boolean>>): number;
|
|
191
|
+
/**
|
|
192
|
+
* Reads an interaction report off the wire.
|
|
193
|
+
*
|
|
194
|
+
* Every field is attacker-supplied, so this is written to be total: anything malformed
|
|
195
|
+
* becomes `undefined` rather than throwing, and every array and number is bounded before
|
|
196
|
+
* it reaches the analysis. A report that arrives as a two-megabyte array of NaN is a
|
|
197
|
+
* request to burn CPU, not an interaction.
|
|
198
|
+
*/
|
|
199
|
+
export declare function parseInteractionReport(value: unknown): InteractionReport | undefined;
|
|
200
|
+
/**
|
|
201
|
+
* Decides what an interaction report is worth.
|
|
202
|
+
*
|
|
203
|
+
* `elapsedMs` is measured by the server from the signed challenge token, and it is the
|
|
204
|
+
* only argument here that the client cannot influence.
|
|
205
|
+
*
|
|
206
|
+
* ## Why a keyboard activation is not penalised
|
|
207
|
+
*
|
|
208
|
+
* A pointer path is worth something, and its absence is worth nothing either way. Voice
|
|
209
|
+
* control produces no pointer movement; switch access produces machine-regular timing;
|
|
210
|
+
* a screen reader activates the control from the keyboard. Scoring those down would put
|
|
211
|
+
* assistive technology on the wrong side of a check the rest of this library exists to
|
|
212
|
+
* keep people out of, so a keyboard or `other` activation is graded on its capabilities
|
|
213
|
+
* and its timing alone.
|
|
214
|
+
*/
|
|
215
|
+
export declare function verifyInteraction(report: InteractionReport | undefined, elapsedMs: number, settings?: InteractionSettings, expected?: ProbeShape): InteractionOutcome;
|
package/dist/challenge/page.d.ts
CHANGED
|
@@ -13,6 +13,24 @@ export interface ChallengePageOptions {
|
|
|
13
13
|
contactHtml?: string;
|
|
14
14
|
/** Page language for the `lang` attribute. Default "en". */
|
|
15
15
|
lang?: string;
|
|
16
|
+
/**
|
|
17
|
+
* Ask for a deliberate gesture as well as the proof of work, and probe what the
|
|
18
|
+
* browser can actually do while waiting for it.
|
|
19
|
+
*
|
|
20
|
+
* The control is a plain checkbox for a reason. It is the one interactive element that
|
|
21
|
+
* every way of using a computer can operate — pointer, touch, the space bar, a screen
|
|
22
|
+
* reader, switch access, voice control — so requiring it excludes far less than a
|
|
23
|
+
* drag, a puzzle or anything needing visual acuity would.
|
|
24
|
+
*/
|
|
25
|
+
interaction?: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Shape of this challenge's layout probe. Rendered into the markup and the stylesheet
|
|
28
|
+
* so that answering it requires laying it out; see `probeShapeFor`.
|
|
29
|
+
*/
|
|
30
|
+
probe?: {
|
|
31
|
+
boxes: number;
|
|
32
|
+
height: number;
|
|
33
|
+
};
|
|
16
34
|
}
|
|
17
35
|
export interface RenderedChallenge {
|
|
18
36
|
html: string;
|