@blamejs/core 0.11.24 → 0.11.25
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 +2 -0
- package/index.js +5 -0
- package/lib/auth/bot-challenge.js +573 -0
- package/lib/framework-error.js +6 -0
- package/lib/fsm.js +469 -0
- package/lib/guard-mail-query.js +14 -0
- package/lib/mail-agent.js +24 -10
- package/lib/mail-store-fts.js +394 -0
- package/lib/mail-store.js +142 -4
- package/lib/money.js +699 -0
- package/lib/webhook.js +229 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.11.x
|
|
10
10
|
|
|
11
|
+
- v0.11.25 (2026-05-20) — **Five new primitives: sealed-token mail FTS + Stripe HMAC-SHA256 webhook verify + `b.money` + `b.fsm` + `b.auth.botChallenge`.** Five additive primitives in one release. Every consumer building on the framework — mail UIs, billing pipelines, e-commerce backends, multi-step business workflows, public web endpoints — now finds the substrate in-tree instead of reimplementing it. None of these replaces a default; every primitive is opt-in at the call site. Mail full-text search hashes tokens with the per-deployment vault salt before any byte hits the SQLite FTS5 index, so the index is searchable but a database dump leaks nothing readable. The Stripe-shaped HMAC-SHA-256 verifier sits next to the existing PQC and SHA3-512 webhook signers; the `whsec_` prefix stays in the key bytes per Stripe's spec; signatures are constant-time-compared and replay-defended via an operator-supplied nonce store. `b.money` arithmetic is BigInt-only across 40 ISO 4217 currencies with largest-remainder allocation and banker's-rounding FX conversion. `b.fsm` is the in-process sibling of `b.agent.saga` — declarative state + transition definition, guards, async on-enter/on-exit, drop-silent audit on every transition, with a transition-serialisation lock that makes concurrent `.transition()` calls deterministic. `b.auth.botChallenge` composes `b.httpClient` for siteverify against Cloudflare Turnstile (default), hCaptcha, and reCAPTCHA-v3 — the secret bytes never appear in any audit metadata. **Added:** *`b.mailStore.fts` — sealed-token full-text index + `b.mailStore.create().search(folder, filter)`* — Every `appendMessage` now inserts a row into a SQLite FTS5 virtual table whose subject / address / body columns hold space-separated 16-char hex hashes — vault-salted SHA3 over each NFC-normalised token. The salt is the same `b.vault.getDerivedHashSalt()` `b.cryptoField` uses for sealed-column derived hashes, so rotating the vault salt invalidates the FTS index in step with every other sealed-row hash. Query terms go through the same tokenize → hash transform on the search path and issue FTS5 MATCH against the hash-token rows — the index is fully searchable without ever materialising the plaintext on disk. `b.mail.agent.search({ folder, filter })` now accepts `text` / `subject` / `body` / `from` / `to` filter keys; the agent layer hands them to the store. `hardExpunge` deletes the FTS row inside the same transaction as the canonical message row so the two cannot drift. Limits: 8192 tokens / row / field, 2..64 codepoint length band, 8 MiB input cap per field. Exact-token only (no prefix wildcard, no stemmer) — the cost of sealed-at-rest. · *`b.webhook.verify({ alg: 'hmac-sha256-stripe', secret, header, body, toleranceMs?, nonceStore? })` + `b.webhook.sign(...)`* — Stripe-spec inbound webhook signature verifier (Paddle + Shopify use the same shape). Parses `Stripe-Signature: t=<unix>,v1=<hex>[,v1=<hex>...]`, validates the timestamp is within the tolerance window (default 5 min; refuses below 30s), HMAC-SHA-256s `<t>.<body>` with the `whsec_...` secret bytes verbatim (the prefix IS the key — `b.webhook.verify` never strips it), and walks the v1 entries with `b.crypto.timingSafeEqual` so a rotation grace window doesn't leak which entry matched. `b.webhook.sign` is the round-trip companion for outbound Stripe-shaped emission and round-trip tests. Optional `nonceStore: { has(k), set(k, ttlMs) }` records the accepted signature so a replay within the tolerance window refuses. The header value is hard-capped at 4096 bytes and per-`v1` hex at 256 chars (anti-DoS). · *`b.money` — decimal-safe money + 40-currency ISO 4217 catalog* — BigInt minor units throughout — Number is refused at construction. Currency exponents covering 0 (JPY/KRW), 2 (USD/EUR/GBP/…), 3 (KWD/BHD/JOD/OMR/TND), 4 (CLF) are pre-populated. Surface: `b.money.of(amount, code)` / `b.money.fromMinorUnits(bigint, code)` / `b.money.parse('12.50 USD')` / `b.money.zero(code)` plus instance methods `.add` / `.subtract` / `.multiply` / `.allocate(weights[])` / `.negate` / `.abs` / `.equals` / comparison family / `.toMinorUnits()` / `.toString()` / `.toJSON()` / `.format(locale?)`. `b.money.convert(money, toCurrency, fxRateProvider)` rounds half-to-even (banker's rounding) by default. Cross-currency arithmetic throws; `0.10 + 0.20 === 0.30` exactly. Allocation uses largest-remainder so $10 / 3 = [$3.34, $3.33, $3.33] with sum preserved. · *`b.fsm` — in-process state machine (sibling of `b.agent.saga`)* — `b.fsm.define({ name, initial, states, transitions })` returns a frozen machine factory. `Machine.create({ initialContext })` returns an instance with `.state` / `.context` / `.history` / `.allowed()` / `.can(name)` / `.transition(name, opts)` / `.toJSON()`. Transitions carry an optional guard (predicate; refusal throws `fsm/guard-refused`) and trigger per-state `onEnter` / `onExit` side-effects (sync or returned-Promise — `.transition` awaits). Every transition emits a `fsm.<machineName>.transition` audit event via `b.audit.safeEmit` (drop-silent on hot path), including the actor + metadata the caller passed in. A transition lock serialises concurrent `.transition()` calls — the test suite exercises five parallel transitions and verifies only the legal path runs. State + transition names are identifier-shape only (`safeSql.DEFAULT_IDENTIFIER_RE`) so a name never lands in SQL / HTML un-validated. `Machine.restore(snapshot)` rebuilds an instance from a prior `.toJSON()` snapshot — state + history + context survive a process restart. · *`b.auth.botChallenge.create({ secret, provider?, ... }) → { verify(token, opts?) }`* — Server-side challenge-response verifier for Cloudflare Turnstile (default), hCaptcha, and reCAPTCHA-v3. Composes `b.httpClient` for the outbound `/siteverify` POST — every request rides the framework's SSRF guard + DNS pinning + retry policy; the operator's secret never appears in a URL or any audit metadata field. Body encoding is `application/x-www-form-urlencoded` per the provider specs. Returns `{ ok, provider, hostname, action, challengeTs, score?, raw }` on success; throws `BotChallengeError` with structured codes (`invalid-token`, `timeout`, `hostname-mismatch`, `action-mismatch`, `provider-error`) on failure, with the upstream `error-codes` array exposed at `err.errorCodes`. Per-factory `allowedHostnames` + `allowedActions` allowlists; per-call `expectedHostname` + `expectedAction` overrides. Tokens are refused at the factory boundary if empty / non-string / > 4096 bytes, before any outbound call. **Security:** *Mail FTS index leaks zero plaintext (sealed-at-rest invariant extended)* — Pre-v0.11.25, the only operator-facing search path on `b.mail.agent` was a modseq cursor + post-fetch unseal — fast for cursoring but useless for text content discovery. Plaintext-FTS would have broken the sealed-at-rest invariant. The new index hashes every indexed token with the per-deployment vault salt before insert; a `sqlite3 .dump` produces ASCII-hex tokens indistinguishable from random. The same vault salt protects every `b.cryptoField`-sealed column, so adding the FTS index does NOT widen the cryptographic trust boundary. · *Stripe verifier defends the documented attack surface* — Constant-time HMAC compare (timing-safe across the v1 rotation list — operators don't leak which entry matched). Per-`v1` 256-hex anti-DoS cap. Tolerance-window minimum 30s — refuses operator misconfiguration that would accept hour-old signatures. The `whsec_` prefix preservation is encoded as a `codebase-patterns` detector (`stripe-hmac-sha256-no-strip-whsec-prefix`) so re-introducing the strip-bug is impossible without tripping a release gate. · *Bot-challenge secret never reaches audit / logs* — `b.auth.botChallenge` audit emits `{ provider, hostname, ok, errorCodes }` — the operator's secret bytes never appear in any metadata key. A `codebase-patterns` detector (`bot-challenge-secret-not-in-audit`) scans `lib/auth/bot-challenge.js` for any `audit.*emit` window that references `secret` so a future refactor cannot regress. **Detectors:** *Five new `codebase-patterns` detectors encode the shape-specific bug classes* — `stripe-hmac-sha256-no-strip-whsec-prefix` flags `secret.replace(/^whsec_/, '')` / `secret.slice(6)` near an HMAC call (the `whsec_` prefix IS the key). `no-number-money-arithmetic` flags `b.money.of(<Number>, ...)` and Number / Money arithmetic (precision lost; only BigInt / string OK at construction). `fsm-transition-without-await` flags `<fsm>.transition(...)` without `await` or `.then` in `lib/` (the transition is async; sync misuse swallows errors). `bot-challenge-secret-not-in-audit` is file-scoped to `lib/auth/bot-challenge.js` and flags any `audit.*emit` window or `metadata: { ... }` object literal referencing `secret`. Each detector is wired into `codebase-patterns.test.js`'s `run()` so every future commit re-checks the shape. **References:** [RFC 2104 (HMAC)](https://www.rfc-editor.org/rfc/rfc2104.html) · [RFC 6234 (US Secure Hash Algorithms — SHA-256)](https://www.rfc-editor.org/rfc/rfc6234.html) · [Stripe webhook signature spec](https://docs.stripe.com/webhooks/signature) · [Paddle webhook signature verification](https://developer.paddle.com/webhooks/signature-verification) · [Shopify webhooks — HMAC verification](https://shopify.dev/docs/apps/webhooks/configuration/https) · [ISO 4217 (Currency codes + minor unit catalog)](https://www.iso.org/iso-4217-currency-codes.html) · [IEEE 754 (the float-precision problem `b.money` avoids)](https://standards.ieee.org/standard/754-2019.html) · [Cloudflare Turnstile — server-side validation](https://developers.cloudflare.com/turnstile/get-started/server-side-validation/) · [hCaptcha — verify the user response server-side](https://docs.hcaptcha.com/) · [reCAPTCHA-v3 — server-side verification](https://developers.google.com/recaptcha/docs/v3) · [OWASP ASVS v5 §11.5 — bot defense controls](https://owasp.org/www-project-application-security-verification-standard/) · [RFC 9051 (IMAP4rev2 — SEARCH semantics)](https://www.rfc-editor.org/rfc/rfc9051.html) · [RFC 8621 (JMAP Mail — Email/query)](https://www.rfc-editor.org/rfc/rfc8621.html) · [SQLite FTS5](https://www.sqlite.org/fts5.html) · [UML State Machine (OMG UML 2.5.1 §14)](https://www.omg.org/spec/UML/2.5.1)
|
|
12
|
+
|
|
11
13
|
- v0.11.24 (2026-05-20) — **`b.mail.send.deliver` — turnkey outbound SMTP composer (MX → MTA-STS → DANE → REQUIRETLS → DSN).** One factory wires together the full outbound mail chain. The operator hands the primitive an envelope (`{ from, to, rfc822 }`) and gets back per-recipient outcomes: `delivered`, `deferred` (with retry budget), or `failed` (with the corresponding RFC 3464 DSN already composed and the configured DSN sink invoked). Per-recipient handling is independent — one recipient permanently failing does not interfere with another's delivery or retry. MX records are resolved live (operator may inject a resolver for testing), MTA-STS policy (RFC 8461) is fetched + matched against the resolved MX before TLS, DANE TLSA records (RFC 7672) are consulted when present, REQUIRETLS (RFC 8689) is honored, and the per-host SMTP transport is the framework's `b.mail.smtpTransport`. SMTP outcomes are classified deterministically: hard 5xx + null-MX → permanent (with DSN); soft 4xx + DNS / connect / TLS errors → transient (with backoff budget). Recipient cap (1000 per call) and per-host / per-lookup timeout caps are baked in. **Added:** *`b.mail.send.deliver.create(opts) → async deliver(envelope)` — composed outbound delivery* — Factory returns a `deliver(envelope)` async function. `envelope = { from, to[], rfc822 }`. Returns `{ delivered: [{...}], deferred: [{...}], failed: [{...}] }`. Composes `b.network.smtp.policy.mtaSts.fetch` + `.matchMx`, `b.network.smtp.policy.dane.tlsa` + `.verifyChain`, `b.mail.smtpTransport.create`, and `b.audit.safeEmit`. Required opts: `hostname` (EHLO + MAIL FROM identity). Optional: `resolver` (object exposing `resolveMx` / `resolve` — defaults to node:dns/promises); `policy.mtaSts.enabled` (default true); `policy.dane.enabled` (default true); `retry.maxAttempts` (default 5); `retry.backoffMs` (default exponential 60s/5m/30m/2h/12h); `dsn.from` + `dsn.onPermanentFailure(messageBuffer, ctx)` (required only when DSN delivery is desired); `timeouts.mxLookupMs` (default 5s); `timeouts.perHostMs` (default 60s); `transportFactory` (test-injection override). · *Per-recipient outcome classifier (`_classifySmtpOutcome`)* — Maps SMTP reply codes + Node socket / TLS errors onto the `permanent` / `transient` axis. Permanent: SMTP 5xx (any subclass), null-MX RFC 7505 sentinel `.`, no-MX-records, RFC 5321 §3.6.2 unrouteable. Transient: SMTP 4xx (any subclass), TCP connection refused / reset / timeout, TLS handshake failure (when MTA-STS / DANE is not enforce-mode), DNS NXDOMAIN at lookup time. Once `retry.maxAttempts` is exhausted, transient is escalated to permanent with reason `retry exhausted (after N attempts)`. · *RFC 3464 DSN composer (`_buildDsnMessage`)* — Builds a `multipart/report; report-type=delivery-status` message with three parts: human-readable explanation, `message/delivery-status` block (Reporting-MTA, Original-Recipient, Final-Recipient, Action: failed, Status: 5.x.x), and a `message/rfc822-headers` block carrying the failed message's headers. Boundary token is generated via `b.crypto.generateToken(12)` so it can't collide with attacker-chosen header / body bytes. The composed DSN is passed to the operator-supplied `dsn.onPermanentFailure(message, ctx)` sink — the primitive does not itself send the DSN, keeping the operator in control of which transport carries DSNs (typically the same submission service). · *MX failover + per-host audit* — MX records are walked in priority order. A failed connect / TLS / 4xx on the first host falls over to the next; only when every MX has been tried does the recipient receive its final outcome. Each per-host failure emits a structured audit event (`mail.send.deliver.host-fail` with `recipient` + `mxHost` + `priority` + `reason`); the final outcome emits `mail.send.deliver.delivered` / `.deferred` / `.permanent-fail`. Audit is drop-silent on the hot path (catch + ignore). · *Recipient + envelope hard caps* — `MAX_RECIPIENTS_PER_CALL = 1000` refuses oversized fan-out at the factory boundary (5xx-style DoS prevention). `envelope.rfc822` accepts a Buffer or UTF-8 string — strings are converted at the boundary so downstream byte-level reasoning (DKIM, REQUIRETLS, length headers) sees stable bytes. Bad-envelope refusals carry `DeliverError` codes `deliver/bad-envelope`, `/bad-envelope-from`, `/bad-envelope-to`, `/too-many-recipients`, `/bad-envelope-rfc822` — operators get structured surface for each refusal class. **Security:** *MTA-STS enforcement before TLS handshake (RFC 8461)* — When `policy.mtaSts.enabled` (default), MTA-STS policy is fetched for the recipient domain. If the policy mode is `enforce` and a resolved MX hostname does not match an `mx:` entry, the host is refused without attempting TLS. This closes the MX-substitution attack window (`b.mail.send.deliver` cannot be diverted to an attacker-controlled MX even when DNS is hijacked, as long as the recipient publishes MTA-STS). · *DANE TLSA verification when present (RFC 7672)* — When `policy.dane.enabled` (default) and the recipient domain publishes TLSA records, the SMTP transport's TLS certificate chain is verified against the TLSA hash before the SMTP command pipeline starts. TLSA records take precedence over PKIX trust roots for SMTP — RFC 7672 §2.2. · *Boundary token unguessable (DSN boundary-injection defense)* — MIME boundary token in composed DSNs is 12 random bytes hex-encoded via `b.crypto.generateToken` (SHAKE256 over OS-RNG) — not `Date.now()` + `Math.random()`. The boundary appears verbatim in the message; a predictable boundary would let an attacker who controls the failed message's headers craft byte sequences that close the boundary early + inject MIME parts. **Detectors:** *`per-recipient-loop-fallthrough-to-failed` (codebase-patterns)* — A new detector flags per-recipient delivery loops where the `delivered` branch does not exit the iteration before falling into the permanent-failure / DSN-emit path. Encodes the bug class that was caught during this slice's bring-up — a recipient delivering successfully also being added to `failed[]` because the `if (delivered)` branch lacked an explicit `continue`. **References:** [RFC 5321 (Simple Mail Transfer Protocol)](https://www.rfc-editor.org/rfc/rfc5321.html) · [RFC 3464 (Extensible Message Format for Delivery Status Notifications)](https://www.rfc-editor.org/rfc/rfc3464.html) · [RFC 7505 (Null MX no-service resource record)](https://www.rfc-editor.org/rfc/rfc7505.html) · [RFC 8461 (SMTP MTA Strict Transport Security — MTA-STS)](https://www.rfc-editor.org/rfc/rfc8461.html) · [RFC 7672 (SMTP Security via Opportunistic DANE TLS)](https://www.rfc-editor.org/rfc/rfc7672.html) · [RFC 8689 (SMTP REQUIRETLS option)](https://www.rfc-editor.org/rfc/rfc8689.html) · [RFC 3463 (Enhanced Mail System Status Codes)](https://www.rfc-editor.org/rfc/rfc3463.html)
|
|
12
14
|
|
|
13
15
|
- v0.11.23 (2026-05-20) — **`b.mail.agent.expunge` — hard EXPUNGE with legal-hold + retention-floor refusal gates.** Operators (and the future IMAP EXPUNGE + JMAP Email/set destroyed wire-protocol adapters) get a single canonical path for permanent message removal that refuses to delete anything currently under legal hold or still inside the regulator-mandated retention window. The gate runs per-message; refused ids carry an explicit reason (`legal-hold` / `retention-floor` / `not-in-folder`) plus the floor + age + posture metadata that drove the refusal — wire adapters mirror those reasons to operators verbatim. The destructive SQL runs only on the surviving id set, inside a backend transaction that also bumps folder modseq + decrements quota atomically. **Added:** *`b.mail.agent.expunge({ actor, folder, objectIds })` — hard EXPUNGE primitive* — Composes two refusal gates before the destructive SQL runs. (1) Legal-hold gate: any message whose `legal_hold` flag is set refuses with reason `legal-hold`. The mail-store layer surfaces the flag in per-row metadata; this layer maps it to the operator-facing refusal. (2) Retention-floor gate: under a configured compliance posture (`hipaa` / `pci-dss` / `gdpr` / `soc2`), the regulator-mandated minimum retention TTL is read from `b.retention.COMPLIANCE_RETENTION_FLOOR_MS[posture]` and any message whose age (`now - receivedAt`) is below the floor refuses with reason `retention-floor` plus `floorMs` + `ageMs` + `posture` metadata. Returns `{ deleted: <ids>, refused: [{ id, reason, ... }] }`. Audit event `mail.agent.expunge.success` carries the requested / deleted / refused counts and a reason histogram so dashboards can spot abnormal refusal patterns without parsing per-id detail. · *`b.mailStore.create(...).hardExpunge(folder, objectIds)` — destructive SQL primitive* — Removes messages permanently from a folder inside a single backend transaction: deletes the message row + its flag rows, bumps the folder modseq, decrements the per-folder quota by the freed bytes / count. Returns `{ rows, deleted, refused }` where `refused` carries `{ id, reason: 'legal-hold' | 'not-in-folder' }` for each id the SQL gate refused (legal-hold is mirrored from the column; not-in-folder catches stale ids). The agent layer (`b.mail.agent.expunge`) is responsible for the retention-floor gate; this primitive is the wire-protocol-shaped backend surface. · *`b.mailStore.create(...).fetchByObjectId` returns `legalHold: boolean`* — Pre-existing fetch path now consistently exposes the legal-hold flag in its return shape. Previously the field existed in the returned object via a separate path; this commit consolidates the duplicate exports into a single canonical `legalHold` boolean derived from the SQLite `legal_hold` INTEGER column. **References:** [RFC 9051 (IMAP4rev2 — EXPUNGE semantics, §6.4.3)](https://www.rfc-editor.org/rfc/rfc9051.html) · [RFC 8621 (JMAP Mail — Email/set destroyed)](https://www.rfc-editor.org/rfc/rfc8621.html) · [45 CFR §164.316 (HIPAA — retention of records)](https://www.ecfr.gov/current/title-45/subtitle-A/subchapter-C/part-164/subpart-C/section-164.316) · [PCI-DSS v4.0.1 §3.5.1.1 (retention of cardholder data)](https://www.pcisecuritystandards.org/document_library) · [GDPR Art. 17 (right to erasure — operator-side accountability)](https://gdpr-info.eu/art-17-gdpr/)
|
package/index.js
CHANGED
|
@@ -204,6 +204,8 @@ var agentStream = require("./lib/agent-stream");
|
|
|
204
204
|
var agentEventBus = require("./lib/agent-event-bus");
|
|
205
205
|
var agentTenant = require("./lib/agent-tenant");
|
|
206
206
|
var agentSaga = require("./lib/agent-saga");
|
|
207
|
+
var fsm = require("./lib/fsm");
|
|
208
|
+
var money = require("./lib/money");
|
|
207
209
|
var agentPostureChain = require("./lib/agent-posture-chain");
|
|
208
210
|
var agentTrace = require("./lib/agent-trace");
|
|
209
211
|
var agentSnapshot = require("./lib/agent-snapshot");
|
|
@@ -256,6 +258,7 @@ var auth = {
|
|
|
256
258
|
oid4vp: require("./lib/auth/oid4vp"),
|
|
257
259
|
saml: require("./lib/auth/saml"),
|
|
258
260
|
openidFederation: require("./lib/auth/openid-federation"),
|
|
261
|
+
botChallenge: require("./lib/auth/bot-challenge"),
|
|
259
262
|
};
|
|
260
263
|
var template = require("./lib/template");
|
|
261
264
|
var render = require("./lib/render");
|
|
@@ -531,6 +534,8 @@ module.exports = {
|
|
|
531
534
|
guardTraceContext: guardTraceContext,
|
|
532
535
|
guardSnapshotEnvelope: guardSnapshotEnvelope,
|
|
533
536
|
agent: { orchestrator: agentOrchestrator, idempotency: agentIdempotency, stream: agentStream, eventBus: agentEventBus, tenant: agentTenant, saga: agentSaga, postureChain: agentPostureChain, trace: agentTrace, snapshot: agentSnapshot },
|
|
537
|
+
fsm: fsm,
|
|
538
|
+
money: money,
|
|
534
539
|
guardArchive: guardArchive,
|
|
535
540
|
guardJson: guardJson,
|
|
536
541
|
guardYaml: guardYaml,
|
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.auth.botChallenge
|
|
4
|
+
* @nav Identity
|
|
5
|
+
* @title Bot Challenge Verifier
|
|
6
|
+
* @order 375
|
|
7
|
+
*
|
|
8
|
+
* @intro
|
|
9
|
+
* Server-side verifier for the modern privacy-preserving bot-
|
|
10
|
+
* challenge widgets: Cloudflare Turnstile, hCaptcha, and Google
|
|
11
|
+
* reCAPTCHA v3. The client-side widget produces a short-lived
|
|
12
|
+
* token; the server POSTs that token (along with the operator's
|
|
13
|
+
* secret + optionally the remote IP) to the provider's siteverify
|
|
14
|
+
* endpoint and inspects the verdict.
|
|
15
|
+
*
|
|
16
|
+
* Why a verifier and not a heuristic — `b.middleware.botGuard`
|
|
17
|
+
* inspects User-Agent / Accept-Language / fetch-metadata for
|
|
18
|
+
* stale crawlers, but a determined adversary forges those bytes
|
|
19
|
+
* trivially. A widget-issued token is a cryptographic claim from
|
|
20
|
+
* the provider that the request originated from a human (or a
|
|
21
|
+
* passable approximation under reCAPTCHA-v3's score model).
|
|
22
|
+
*
|
|
23
|
+
* The verifier:
|
|
24
|
+
*
|
|
25
|
+
* - POSTs the token via `b.httpClient` — every outbound hop
|
|
26
|
+
* goes through `b.ssrfGuard` + the framework's DNS pinning,
|
|
27
|
+
* so a redirect to a cloud-metadata endpoint can't smuggle
|
|
28
|
+
* past the first-hop gate. Raw `node:http` / `node:https` /
|
|
29
|
+
* global `fetch` is never used.
|
|
30
|
+
* - Sends the secret in the POST body as
|
|
31
|
+
* `application/x-www-form-urlencoded` (Cloudflare's
|
|
32
|
+
* documented shape). The secret never appears in the URL,
|
|
33
|
+
* query string, headers, log lines, or audit metadata.
|
|
34
|
+
* - Refuses a token that is not a non-empty string under
|
|
35
|
+
* `MAX_TOKEN_BYTES` (4 KiB) — Cloudflare tokens cap around
|
|
36
|
+
* 2 KiB; a 1 MiB "token" is operator misuse or an attack.
|
|
37
|
+
* - Validates `success === true` AND (when configured)
|
|
38
|
+
* hostname-in-allowlist AND action-in-allowlist before
|
|
39
|
+
* returning. The provider's hostname / action fields are
|
|
40
|
+
* embedded in the token by the widget; operators using
|
|
41
|
+
* multi-domain or multi-action deployments allowlist the
|
|
42
|
+
* expected values to refuse cross-site token replay.
|
|
43
|
+
* - For reCAPTCHA-v3, exposes the `score` (0.0–1.0) on the
|
|
44
|
+
* success shape so the operator can threshold per-route.
|
|
45
|
+
* - Audits every verify call drop-silent via
|
|
46
|
+
* `b.audit.safeEmit` (action `auth.bot_challenge.verify`,
|
|
47
|
+
* outcome `success` / `failure`, metadata
|
|
48
|
+
* `{ provider, hostname?, ok, errorCodes? }`). The token
|
|
49
|
+
* and secret NEVER appear in audit metadata; only the
|
|
50
|
+
* token's 8-char prefix surfaces, and only when the operator
|
|
51
|
+
* has opted into trace-level metadata.
|
|
52
|
+
*
|
|
53
|
+
* Compose with `b.authBotChallenge` (the adaptive staircase gate)
|
|
54
|
+
* by passing the verifier's `verify` function as the staircase's
|
|
55
|
+
* `challengeFn` — failed-auth attempts ride the staircase up to
|
|
56
|
+
* the challenge stage, the operator renders the Turnstile widget,
|
|
57
|
+
* and the verifier validates the resulting token. The two
|
|
58
|
+
* primitives are deliberately separate concerns.
|
|
59
|
+
*
|
|
60
|
+
* References:
|
|
61
|
+
* - Cloudflare Turnstile siteverify
|
|
62
|
+
* https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
|
|
63
|
+
* - hCaptcha siteverify
|
|
64
|
+
* https://docs.hcaptcha.com/#verify-the-user-response-server-side
|
|
65
|
+
* - reCAPTCHA v3 siteverify
|
|
66
|
+
* https://developers.google.com/recaptcha/docs/v3
|
|
67
|
+
* - OWASP ASVS v5 §11.5 (bot-defense controls)
|
|
68
|
+
* - RFC 6749 §4.1.3 (`application/x-www-form-urlencoded` body
|
|
69
|
+
* conventions for OAuth-style endpoints)
|
|
70
|
+
*
|
|
71
|
+
* @card
|
|
72
|
+
* Server-side verifier for Cloudflare Turnstile / hCaptcha / reCAPTCHA-v3 widget tokens with SSRF-guarded outbound, hostname + action allowlists, and drop-silent audit.
|
|
73
|
+
*/
|
|
74
|
+
|
|
75
|
+
var nodeQuerystring = require("node:querystring");
|
|
76
|
+
|
|
77
|
+
var lazyRequire = require("../lazy-require");
|
|
78
|
+
var validateOpts = require("../validate-opts");
|
|
79
|
+
var safeJson = require("../safe-json");
|
|
80
|
+
var C = require("../constants");
|
|
81
|
+
var { BotChallengeError } = require("../framework-error");
|
|
82
|
+
|
|
83
|
+
var httpClient = lazyRequire(function () { return require("../http-client"); });
|
|
84
|
+
var audit = lazyRequire(function () { return require("../audit"); });
|
|
85
|
+
|
|
86
|
+
// ---- constants ----
|
|
87
|
+
|
|
88
|
+
// Token byte ceiling. Turnstile tokens hover around 2 KiB; hCaptcha is
|
|
89
|
+
// similar; reCAPTCHA-v3 tokens are slightly larger but well under 4 KiB.
|
|
90
|
+
// A token that exceeds this cap is operator misuse (passed the wrong
|
|
91
|
+
// field) or a probe — refuse at the boundary rather than forwarding
|
|
92
|
+
// kilobytes of operator-supplied bytes to the provider.
|
|
93
|
+
var MAX_TOKEN_BYTES = C.BYTES.kib(4);
|
|
94
|
+
|
|
95
|
+
// Default wall-clock timeout for the siteverify round-trip. Five seconds
|
|
96
|
+
// is the documented Cloudflare service-level target with healthy
|
|
97
|
+
// headroom; operators can override per-call but cannot drop below
|
|
98
|
+
// MIN_TIMEOUT_MS without the create() factory refusing the opts.
|
|
99
|
+
var DEFAULT_TIMEOUT_MS = C.TIME.seconds(5);
|
|
100
|
+
var MIN_TIMEOUT_MS = 500; // anti-misconfiguration floor // allow:raw-byte-literal — 500ms wall-clock floor, not a byte literal
|
|
101
|
+
|
|
102
|
+
// Response-body cap. Provider siteverify responses are small JSON
|
|
103
|
+
// (well under 4 KiB); a multi-MiB response is either a redirect to
|
|
104
|
+
// HTML (shouldn't happen — providers terminate JSON-only) or an
|
|
105
|
+
// attacker-shaped probe via DNS poisoning.
|
|
106
|
+
var MAX_RESPONSE_BYTES = C.BYTES.kib(64);
|
|
107
|
+
|
|
108
|
+
// Allowed siteverify response Content-Type — providers return
|
|
109
|
+
// `application/json` (Cloudflare + hCaptcha) or
|
|
110
|
+
// `application/json; charset=utf-8` (Google). The verifier rejects
|
|
111
|
+
// other content types rather than attempting to parse arbitrary bodies.
|
|
112
|
+
var EXPECTED_CONTENT_TYPE_PREFIX = "application/json";
|
|
113
|
+
|
|
114
|
+
// Number of characters of the token's prefix that surface in audit
|
|
115
|
+
// metadata for diagnosability. Eight characters is small enough that
|
|
116
|
+
// the surfaced bytes are not the secret token (≈ 48 bits visible vs.
|
|
117
|
+
// ~2 KiB total), but large enough to cluster verifications belonging
|
|
118
|
+
// to the same widget render in a debug session.
|
|
119
|
+
var TOKEN_PREFIX_AUDIT_CHARS = 8; // allow:raw-byte-literal — debug-prefix length, not a byte literal
|
|
120
|
+
|
|
121
|
+
// ---- provider catalog ----
|
|
122
|
+
//
|
|
123
|
+
// Each provider entry exposes:
|
|
124
|
+
// endpoint — the siteverify URL (https only)
|
|
125
|
+
// contentTypeBody — the POST body's Content-Type
|
|
126
|
+
// parseResponse(body, raw) → { ok, hostname, action, ts, score?, errorCodes }
|
|
127
|
+
//
|
|
128
|
+
// The parseResponse contract is uniform across providers even though
|
|
129
|
+
// each provider's response shape differs slightly. Cloudflare /
|
|
130
|
+
// hCaptcha return `{ success, hostname, action, challenge_ts,
|
|
131
|
+
// "error-codes" }`; Google reCAPTCHA-v3 also returns `score` (and
|
|
132
|
+
// `action` is REQUIRED for v3).
|
|
133
|
+
//
|
|
134
|
+
// Each parseResponse normalises the raw body into the unified shape.
|
|
135
|
+
// `errorCodes` is always an array of strings (possibly empty); `ok`
|
|
136
|
+
// reflects the provider's success flag verbatim with no operator-
|
|
137
|
+
// configurable bypass.
|
|
138
|
+
|
|
139
|
+
function _parseCloudflareLike(rawObj) {
|
|
140
|
+
var errorCodes = [];
|
|
141
|
+
var raw = rawObj && typeof rawObj === "object" ? rawObj : {};
|
|
142
|
+
if (Array.isArray(raw["error-codes"])) {
|
|
143
|
+
for (var i = 0; i < raw["error-codes"].length; i++) {
|
|
144
|
+
var ec = raw["error-codes"][i];
|
|
145
|
+
if (typeof ec === "string") errorCodes.push(ec);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
ok: raw.success === true,
|
|
150
|
+
hostname: typeof raw.hostname === "string" ? raw.hostname : null,
|
|
151
|
+
action: typeof raw.action === "string" ? raw.action : null,
|
|
152
|
+
challengeTs: typeof raw.challenge_ts === "string" ? raw.challenge_ts : null,
|
|
153
|
+
score: null,
|
|
154
|
+
errorCodes: errorCodes,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function _parseRecaptchaV3(rawObj) {
|
|
159
|
+
var base = _parseCloudflareLike(rawObj);
|
|
160
|
+
var raw = rawObj && typeof rawObj === "object" ? rawObj : {};
|
|
161
|
+
if (typeof raw.score === "number" && isFinite(raw.score)) {
|
|
162
|
+
base.score = raw.score;
|
|
163
|
+
}
|
|
164
|
+
return base;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
var PROVIDERS = Object.freeze({
|
|
168
|
+
"turnstile": Object.freeze({
|
|
169
|
+
endpoint: "https://challenges.cloudflare.com/turnstile/v0/siteverify",
|
|
170
|
+
contentTypeBody: "application/x-www-form-urlencoded",
|
|
171
|
+
parseResponse: _parseCloudflareLike,
|
|
172
|
+
}),
|
|
173
|
+
"hcaptcha": Object.freeze({
|
|
174
|
+
endpoint: "https://api.hcaptcha.com/siteverify",
|
|
175
|
+
contentTypeBody: "application/x-www-form-urlencoded",
|
|
176
|
+
parseResponse: _parseCloudflareLike,
|
|
177
|
+
}),
|
|
178
|
+
"recaptcha-v3": Object.freeze({
|
|
179
|
+
endpoint: "https://www.google.com/recaptcha/api/siteverify",
|
|
180
|
+
contentTypeBody: "application/x-www-form-urlencoded",
|
|
181
|
+
parseResponse: _parseRecaptchaV3,
|
|
182
|
+
}),
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
var DEFAULT_PROVIDER = "turnstile";
|
|
186
|
+
|
|
187
|
+
var ALLOWED_CREATE_OPTS = [
|
|
188
|
+
"secret", "provider", "httpClient", "timeoutMs",
|
|
189
|
+
"allowedHostnames", "allowedActions", "audit",
|
|
190
|
+
];
|
|
191
|
+
|
|
192
|
+
var ALLOWED_VERIFY_OPTS = [
|
|
193
|
+
"remoteIp", "expectedAction", "expectedHostname",
|
|
194
|
+
];
|
|
195
|
+
|
|
196
|
+
// ---- helpers ----
|
|
197
|
+
|
|
198
|
+
function _requireNonEmptyString(name, val) {
|
|
199
|
+
if (typeof val !== "string" || val.length === 0) {
|
|
200
|
+
throw new BotChallengeError("bot-challenge/bad-opt",
|
|
201
|
+
name + ": expected non-empty string, got " + typeof val);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function _normaliseAllowlist(name, val) {
|
|
206
|
+
if (val === undefined || val === null) return null;
|
|
207
|
+
if (!Array.isArray(val)) {
|
|
208
|
+
throw new BotChallengeError("bot-challenge/bad-opt",
|
|
209
|
+
name + ": expected array of strings or null/undefined, got " + typeof val);
|
|
210
|
+
}
|
|
211
|
+
var out = [];
|
|
212
|
+
for (var i = 0; i < val.length; i++) {
|
|
213
|
+
var entry = val[i];
|
|
214
|
+
if (typeof entry !== "string" || entry.length === 0) {
|
|
215
|
+
throw new BotChallengeError("bot-challenge/bad-opt",
|
|
216
|
+
name + "[" + i + "]: expected non-empty string");
|
|
217
|
+
}
|
|
218
|
+
out.push(entry);
|
|
219
|
+
}
|
|
220
|
+
if (out.length === 0) {
|
|
221
|
+
throw new BotChallengeError("bot-challenge/bad-opt",
|
|
222
|
+
name + ": allowlist must contain at least one entry when set");
|
|
223
|
+
}
|
|
224
|
+
return Object.freeze(out);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function _httpClientShape(client, callerLabel) {
|
|
228
|
+
if (client === undefined || client === null) return null;
|
|
229
|
+
if (typeof client !== "object" || typeof client.request !== "function") {
|
|
230
|
+
throw new BotChallengeError("bot-challenge/bad-opt",
|
|
231
|
+
callerLabel + ": httpClient must be a b.httpClient-shaped object (request fn)");
|
|
232
|
+
}
|
|
233
|
+
return client;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function _normaliseTimeoutMs(val) {
|
|
237
|
+
if (val === undefined || val === null) return DEFAULT_TIMEOUT_MS;
|
|
238
|
+
if (typeof val !== "number" || !isFinite(val) || val < MIN_TIMEOUT_MS ||
|
|
239
|
+
Math.floor(val) !== val) {
|
|
240
|
+
throw new BotChallengeError("bot-challenge/bad-opt",
|
|
241
|
+
"timeoutMs: expected integer >= " + MIN_TIMEOUT_MS + " ms, got " +
|
|
242
|
+
JSON.stringify(val));
|
|
243
|
+
}
|
|
244
|
+
return val;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function _byteLengthOf(s) {
|
|
248
|
+
// Conservative UTF-8 byte count. Buffer.byteLength is the right
|
|
249
|
+
// tool here because Turnstile tokens are ASCII-base64url today —
|
|
250
|
+
// but the contract is in bytes, not chars.
|
|
251
|
+
return Buffer.byteLength(s, "utf8");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function _isTimeoutError(err) {
|
|
255
|
+
if (!err) return false;
|
|
256
|
+
if (err.code === "TIMEOUT" || err.code === "WALL_CLOCK_TIMEOUT" ||
|
|
257
|
+
err.code === "IDLE_TIMEOUT") return true;
|
|
258
|
+
if (err.name === "AbortError") return true;
|
|
259
|
+
var msg = err.message || "";
|
|
260
|
+
return /timeout|timed out|aborted/i.test(msg);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function _safeAudit(safeEmit, action, outcome, metadata) {
|
|
264
|
+
if (typeof safeEmit !== "function") return;
|
|
265
|
+
try {
|
|
266
|
+
safeEmit({
|
|
267
|
+
action: action,
|
|
268
|
+
outcome: outcome,
|
|
269
|
+
metadata: metadata || {},
|
|
270
|
+
});
|
|
271
|
+
} catch (_e) { /* drop-silent — audit is best-effort */ }
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function _resolveAuditSafeEmit(auditOpt) {
|
|
275
|
+
// Operator-supplied audit takes precedence (mirrors `b.audit` shape).
|
|
276
|
+
// Fall back to the framework's global `b.audit` via lazyRequire so
|
|
277
|
+
// the verifier emits without explicit wiring. Drop-silent on every
|
|
278
|
+
// failure path (per validation-tier rule §5, hot-path observability
|
|
279
|
+
// sinks NEVER throw).
|
|
280
|
+
if (auditOpt && typeof auditOpt.safeEmit === "function") {
|
|
281
|
+
return auditOpt.safeEmit.bind(auditOpt);
|
|
282
|
+
}
|
|
283
|
+
try {
|
|
284
|
+
var global = audit();
|
|
285
|
+
if (global && typeof global.safeEmit === "function") {
|
|
286
|
+
return global.safeEmit.bind(global);
|
|
287
|
+
}
|
|
288
|
+
} catch (_e) { /* no global audit available */ }
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ---- public surface ----
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* @primitive b.auth.botChallenge.create
|
|
296
|
+
* @signature b.auth.botChallenge.create(opts)
|
|
297
|
+
* @since 0.11.25
|
|
298
|
+
* @status stable
|
|
299
|
+
* @compliance gdpr, soc2
|
|
300
|
+
* @related b.authBotChallenge.create, b.middleware.botGuard, b.httpClient
|
|
301
|
+
*
|
|
302
|
+
* Build a server-side verifier for a bot-challenge widget token.
|
|
303
|
+
* Returns `{ verify(token, verifyOpts?) }`. The factory throws on
|
|
304
|
+
* malformed opts; `verify` throws a typed `BotChallengeError` on
|
|
305
|
+
* any verification failure and resolves on success.
|
|
306
|
+
*
|
|
307
|
+
* @opts
|
|
308
|
+
* secret: string, // provider-issued site secret — preserved verbatim
|
|
309
|
+
* provider: string, // "turnstile" | "hcaptcha" | "recaptcha-v3" (default "turnstile")
|
|
310
|
+
* httpClient: Object, // b.httpClient-shaped { request } — default: framework http-client
|
|
311
|
+
* timeoutMs: number, // wall-clock cap for the siteverify call (default 5_000; minimum 500)
|
|
312
|
+
* allowedHostnames: string[], // optional hostname allowlist — verify refuses tokens whose embedded hostname is absent
|
|
313
|
+
* allowedActions: string[], // optional action allowlist — verify refuses tokens whose embedded action is absent
|
|
314
|
+
* audit: Object, // optional b.audit-shaped sink; defaults to framework global b.audit
|
|
315
|
+
*
|
|
316
|
+
* @example
|
|
317
|
+
* var verifier = b.auth.botChallenge.create({
|
|
318
|
+
* secret: process.env.TURNSTILE_SECRET,
|
|
319
|
+
* provider: "turnstile",
|
|
320
|
+
* allowedHostnames: ["app.example.com"],
|
|
321
|
+
* allowedActions: ["login", "signup"],
|
|
322
|
+
* });
|
|
323
|
+
*
|
|
324
|
+
* // In a login handler:
|
|
325
|
+
* try {
|
|
326
|
+
* var verdict = await verifier.verify(req.body["cf-turnstile-response"], {
|
|
327
|
+
* remoteIp: b.requestHelpers.clientIp(req),
|
|
328
|
+
* expectedAction: "login",
|
|
329
|
+
* });
|
|
330
|
+
* // verdict.ok === true; verdict.hostname / verdict.action / verdict.challengeTs populated.
|
|
331
|
+
* } catch (e) {
|
|
332
|
+
* // e instanceof b.auth.botChallenge.BotChallengeError
|
|
333
|
+
* // e.code === "bot-challenge/invalid-token" (or hostname-mismatch / timeout / etc.)
|
|
334
|
+
* }
|
|
335
|
+
*/
|
|
336
|
+
function create(opts) {
|
|
337
|
+
opts = opts || {};
|
|
338
|
+
validateOpts(opts, ALLOWED_CREATE_OPTS, "auth.botChallenge.create");
|
|
339
|
+
|
|
340
|
+
_requireNonEmptyString("secret", opts.secret);
|
|
341
|
+
// Preserve the secret verbatim — provider secrets are
|
|
342
|
+
// case-sensitive and carry no canonical-form transformation rule.
|
|
343
|
+
var secret = opts.secret;
|
|
344
|
+
|
|
345
|
+
var providerKey = opts.provider !== undefined ? opts.provider : DEFAULT_PROVIDER;
|
|
346
|
+
if (typeof providerKey !== "string" || !PROVIDERS[providerKey]) {
|
|
347
|
+
var supported = Object.keys(PROVIDERS).join(", ");
|
|
348
|
+
throw new BotChallengeError("bot-challenge/bad-opt",
|
|
349
|
+
"provider: expected one of [" + supported + "], got " + JSON.stringify(providerKey));
|
|
350
|
+
}
|
|
351
|
+
var providerSpec = PROVIDERS[providerKey];
|
|
352
|
+
|
|
353
|
+
var client = _httpClientShape(opts.httpClient, "auth.botChallenge.create") || httpClient();
|
|
354
|
+
var timeoutMs = _normaliseTimeoutMs(opts.timeoutMs);
|
|
355
|
+
var allowedHostnames = _normaliseAllowlist("allowedHostnames", opts.allowedHostnames);
|
|
356
|
+
var allowedActions = _normaliseAllowlist("allowedActions", opts.allowedActions);
|
|
357
|
+
|
|
358
|
+
if (opts.audit !== undefined) {
|
|
359
|
+
validateOpts.auditShape(opts.audit, "auth.botChallenge.create", BotChallengeError);
|
|
360
|
+
}
|
|
361
|
+
var safeEmit = _resolveAuditSafeEmit(opts.audit);
|
|
362
|
+
|
|
363
|
+
async function verify(token, verifyOpts) {
|
|
364
|
+
verifyOpts = verifyOpts || {};
|
|
365
|
+
validateOpts(verifyOpts, ALLOWED_VERIFY_OPTS, "auth.botChallenge.verify");
|
|
366
|
+
|
|
367
|
+
var tokenPrefix = (typeof token === "string"
|
|
368
|
+
? token.slice(0, TOKEN_PREFIX_AUDIT_CHARS)
|
|
369
|
+
: "");
|
|
370
|
+
|
|
371
|
+
// Boundary refusals — every reject here is typed + audited.
|
|
372
|
+
if (typeof token !== "string" || token.length === 0) {
|
|
373
|
+
_safeAudit(safeEmit, "auth.bot_challenge.verify", "failure", {
|
|
374
|
+
provider: providerKey, ok: false, reason: "empty-token",
|
|
375
|
+
});
|
|
376
|
+
throw new BotChallengeError("bot-challenge/invalid-token",
|
|
377
|
+
"token must be a non-empty string");
|
|
378
|
+
}
|
|
379
|
+
if (_byteLengthOf(token) > MAX_TOKEN_BYTES) {
|
|
380
|
+
_safeAudit(safeEmit, "auth.bot_challenge.verify", "failure", {
|
|
381
|
+
provider: providerKey, ok: false, reason: "token-too-large",
|
|
382
|
+
prefix: tokenPrefix,
|
|
383
|
+
});
|
|
384
|
+
throw new BotChallengeError("bot-challenge/invalid-token",
|
|
385
|
+
"token exceeds " + MAX_TOKEN_BYTES + " bytes");
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
var expectedAction = verifyOpts.expectedAction !== undefined
|
|
389
|
+
? verifyOpts.expectedAction : null;
|
|
390
|
+
if (expectedAction !== null && (typeof expectedAction !== "string" ||
|
|
391
|
+
expectedAction.length === 0)) {
|
|
392
|
+
throw new BotChallengeError("bot-challenge/bad-opt",
|
|
393
|
+
"expectedAction: expected non-empty string");
|
|
394
|
+
}
|
|
395
|
+
var expectedHostname = verifyOpts.expectedHostname !== undefined
|
|
396
|
+
? verifyOpts.expectedHostname : null;
|
|
397
|
+
if (expectedHostname !== null && (typeof expectedHostname !== "string" ||
|
|
398
|
+
expectedHostname.length === 0)) {
|
|
399
|
+
throw new BotChallengeError("bot-challenge/bad-opt",
|
|
400
|
+
"expectedHostname: expected non-empty string");
|
|
401
|
+
}
|
|
402
|
+
if (verifyOpts.remoteIp !== undefined && verifyOpts.remoteIp !== null &&
|
|
403
|
+
(typeof verifyOpts.remoteIp !== "string" || verifyOpts.remoteIp.length === 0)) {
|
|
404
|
+
throw new BotChallengeError("bot-challenge/bad-opt",
|
|
405
|
+
"remoteIp: expected non-empty string when set");
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Compose the application/x-www-form-urlencoded body. The secret
|
|
409
|
+
// is in the body — never the URL/query/headers/audit metadata.
|
|
410
|
+
var bodyFields = { secret: secret, response: token };
|
|
411
|
+
if (verifyOpts.remoteIp) bodyFields.remoteip = verifyOpts.remoteIp;
|
|
412
|
+
var body = nodeQuerystring.stringify(bodyFields);
|
|
413
|
+
|
|
414
|
+
var res;
|
|
415
|
+
try {
|
|
416
|
+
res = await client.request({
|
|
417
|
+
method: "POST",
|
|
418
|
+
url: providerSpec.endpoint,
|
|
419
|
+
body: body,
|
|
420
|
+
headers: { "Content-Type": providerSpec.contentTypeBody },
|
|
421
|
+
timeoutMs: timeoutMs,
|
|
422
|
+
maxBytes: MAX_RESPONSE_BYTES,
|
|
423
|
+
});
|
|
424
|
+
} catch (e) {
|
|
425
|
+
if (_isTimeoutError(e)) {
|
|
426
|
+
_safeAudit(safeEmit, "auth.bot_challenge.verify", "failure", {
|
|
427
|
+
provider: providerKey, ok: false, reason: "timeout",
|
|
428
|
+
prefix: tokenPrefix,
|
|
429
|
+
});
|
|
430
|
+
throw new BotChallengeError("bot-challenge/timeout",
|
|
431
|
+
"siteverify timed out after " + timeoutMs + " ms");
|
|
432
|
+
}
|
|
433
|
+
_safeAudit(safeEmit, "auth.bot_challenge.verify", "failure", {
|
|
434
|
+
provider: providerKey, ok: false, reason: "transport",
|
|
435
|
+
prefix: tokenPrefix,
|
|
436
|
+
// Surface the underlying message but not the secret/token bytes.
|
|
437
|
+
message: (e && e.message) || String(e),
|
|
438
|
+
});
|
|
439
|
+
throw new BotChallengeError("bot-challenge/transport-error",
|
|
440
|
+
"siteverify transport failure: " + ((e && e.message) || String(e)));
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (res.statusCode < 200 || res.statusCode >= 300) { // allow:raw-byte-literal — HTTP 2xx range bounds
|
|
444
|
+
_safeAudit(safeEmit, "auth.bot_challenge.verify", "failure", {
|
|
445
|
+
provider: providerKey, ok: false, reason: "non-2xx",
|
|
446
|
+
statusCode: res.statusCode, prefix: tokenPrefix,
|
|
447
|
+
});
|
|
448
|
+
throw new BotChallengeError("bot-challenge/provider-error",
|
|
449
|
+
"siteverify returned non-2xx status " + res.statusCode);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Defensive Content-Type guard — providers return JSON.
|
|
453
|
+
var ctHeader = (res.headers && (res.headers["content-type"] ||
|
|
454
|
+
res.headers["Content-Type"])) || "";
|
|
455
|
+
if (typeof ctHeader !== "string" ||
|
|
456
|
+
ctHeader.toLowerCase().indexOf(EXPECTED_CONTENT_TYPE_PREFIX) !== 0) {
|
|
457
|
+
_safeAudit(safeEmit, "auth.bot_challenge.verify", "failure", {
|
|
458
|
+
provider: providerKey, ok: false, reason: "bad-content-type",
|
|
459
|
+
contentType: ctHeader, prefix: tokenPrefix,
|
|
460
|
+
});
|
|
461
|
+
throw new BotChallengeError("bot-challenge/provider-error",
|
|
462
|
+
"siteverify returned non-JSON Content-Type: " + ctHeader);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
var raw;
|
|
466
|
+
try {
|
|
467
|
+
var bodyText = Buffer.isBuffer(res.body)
|
|
468
|
+
? res.body.toString("utf8")
|
|
469
|
+
: String(res.body || "");
|
|
470
|
+
raw = safeJson.parse(bodyText, { maxBytes: MAX_RESPONSE_BYTES });
|
|
471
|
+
} catch (e) {
|
|
472
|
+
_safeAudit(safeEmit, "auth.bot_challenge.verify", "failure", {
|
|
473
|
+
provider: providerKey, ok: false, reason: "parse-error",
|
|
474
|
+
prefix: tokenPrefix,
|
|
475
|
+
});
|
|
476
|
+
throw new BotChallengeError("bot-challenge/provider-error",
|
|
477
|
+
"siteverify response parse failed: " + ((e && e.message) || String(e)));
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
var parsed = providerSpec.parseResponse(raw, res);
|
|
481
|
+
|
|
482
|
+
if (!parsed.ok) {
|
|
483
|
+
_safeAudit(safeEmit, "auth.bot_challenge.verify", "failure", {
|
|
484
|
+
provider: providerKey, ok: false, reason: "provider-rejected",
|
|
485
|
+
errorCodes: parsed.errorCodes, hostname: parsed.hostname,
|
|
486
|
+
prefix: tokenPrefix,
|
|
487
|
+
});
|
|
488
|
+
var err = new BotChallengeError("bot-challenge/invalid-token",
|
|
489
|
+
"siteverify rejected token: " +
|
|
490
|
+
(parsed.errorCodes.length ? parsed.errorCodes.join(",") : "(no error-codes)"));
|
|
491
|
+
err.errorCodes = parsed.errorCodes;
|
|
492
|
+
throw err;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Hostname allowlist — factory-configured allowlist OR per-call
|
|
496
|
+
// expectedHostname (the per-call value overrides the allowlist for
|
|
497
|
+
// exact-match in the same call but does not relax the allowlist).
|
|
498
|
+
if (allowedHostnames && (!parsed.hostname ||
|
|
499
|
+
allowedHostnames.indexOf(parsed.hostname) === -1)) {
|
|
500
|
+
_safeAudit(safeEmit, "auth.bot_challenge.verify", "failure", {
|
|
501
|
+
provider: providerKey, ok: false, reason: "hostname-mismatch",
|
|
502
|
+
hostname: parsed.hostname, prefix: tokenPrefix,
|
|
503
|
+
});
|
|
504
|
+
throw new BotChallengeError("bot-challenge/hostname-mismatch",
|
|
505
|
+
"hostname '" + parsed.hostname + "' not in allowedHostnames");
|
|
506
|
+
}
|
|
507
|
+
if (expectedHostname !== null && parsed.hostname !== expectedHostname) {
|
|
508
|
+
_safeAudit(safeEmit, "auth.bot_challenge.verify", "failure", {
|
|
509
|
+
provider: providerKey, ok: false, reason: "hostname-mismatch",
|
|
510
|
+
hostname: parsed.hostname, expectedHostname: expectedHostname,
|
|
511
|
+
prefix: tokenPrefix,
|
|
512
|
+
});
|
|
513
|
+
throw new BotChallengeError("bot-challenge/hostname-mismatch",
|
|
514
|
+
"hostname '" + parsed.hostname + "' does not match expectedHostname '" +
|
|
515
|
+
expectedHostname + "'");
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Action allowlist — same shape as hostname.
|
|
519
|
+
if (allowedActions && (!parsed.action ||
|
|
520
|
+
allowedActions.indexOf(parsed.action) === -1)) {
|
|
521
|
+
_safeAudit(safeEmit, "auth.bot_challenge.verify", "failure", {
|
|
522
|
+
provider: providerKey, ok: false, reason: "action-mismatch",
|
|
523
|
+
action: parsed.action, hostname: parsed.hostname,
|
|
524
|
+
prefix: tokenPrefix,
|
|
525
|
+
});
|
|
526
|
+
throw new BotChallengeError("bot-challenge/action-mismatch",
|
|
527
|
+
"action '" + parsed.action + "' not in allowedActions");
|
|
528
|
+
}
|
|
529
|
+
if (expectedAction !== null && parsed.action !== expectedAction) {
|
|
530
|
+
_safeAudit(safeEmit, "auth.bot_challenge.verify", "failure", {
|
|
531
|
+
provider: providerKey, ok: false, reason: "action-mismatch",
|
|
532
|
+
action: parsed.action, expectedAction: expectedAction,
|
|
533
|
+
hostname: parsed.hostname, prefix: tokenPrefix,
|
|
534
|
+
});
|
|
535
|
+
throw new BotChallengeError("bot-challenge/action-mismatch",
|
|
536
|
+
"action '" + parsed.action + "' does not match expectedAction '" +
|
|
537
|
+
expectedAction + "'");
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
var successMeta = {
|
|
541
|
+
provider: providerKey, ok: true,
|
|
542
|
+
hostname: parsed.hostname, action: parsed.action,
|
|
543
|
+
prefix: tokenPrefix,
|
|
544
|
+
};
|
|
545
|
+
if (parsed.score !== null) successMeta.score = parsed.score;
|
|
546
|
+
_safeAudit(safeEmit, "auth.bot_challenge.verify", "success", successMeta);
|
|
547
|
+
|
|
548
|
+
var result = {
|
|
549
|
+
ok: true,
|
|
550
|
+
provider: providerKey,
|
|
551
|
+
hostname: parsed.hostname,
|
|
552
|
+
action: parsed.action,
|
|
553
|
+
challengeTs: parsed.challengeTs,
|
|
554
|
+
raw: raw,
|
|
555
|
+
};
|
|
556
|
+
if (parsed.score !== null) result.score = parsed.score;
|
|
557
|
+
return result;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
return { verify: verify };
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
module.exports = {
|
|
564
|
+
create: create,
|
|
565
|
+
PROVIDERS: PROVIDERS,
|
|
566
|
+
BotChallengeError: BotChallengeError,
|
|
567
|
+
DEFAULTS: Object.freeze({
|
|
568
|
+
provider: DEFAULT_PROVIDER,
|
|
569
|
+
timeoutMs: DEFAULT_TIMEOUT_MS,
|
|
570
|
+
minTimeoutMs: MIN_TIMEOUT_MS,
|
|
571
|
+
maxTokenBytes: MAX_TOKEN_BYTES,
|
|
572
|
+
}),
|
|
573
|
+
};
|
package/lib/framework-error.js
CHANGED
|
@@ -473,6 +473,11 @@ var DlpError = defineClass("DlpError", { alwaysPermane
|
|
|
473
473
|
// b.authBotChallenge when the operator-supplied challengeFn is
|
|
474
474
|
// missing, returns a non-boolean verdict, or throws. Permanent.
|
|
475
475
|
var AuthBotChallengeError = defineClass("AuthBotChallengeError", { alwaysPermanent: true });
|
|
476
|
+
// BotChallengeError — verifier-side errors raised by b.auth.botChallenge
|
|
477
|
+
// (Cloudflare Turnstile / hCaptcha / reCAPTCHA-v3 token siteverify):
|
|
478
|
+
// invalid token shape, timeout, hostname / action allowlist mismatch,
|
|
479
|
+
// provider reported success=false, malformed response body. Permanent.
|
|
480
|
+
var BotChallengeError = defineClass("BotChallengeError", { alwaysPermanent: true });
|
|
476
481
|
// SessionDeviceBindingError — fingerprint-drift refusal raised by
|
|
477
482
|
// b.sessionDeviceBinding when create-time opts are malformed or the
|
|
478
483
|
// boundKeyResolver returns a non-Buffer. Permanent.
|
|
@@ -696,6 +701,7 @@ module.exports = {
|
|
|
696
701
|
SandboxError: SandboxError,
|
|
697
702
|
DlpError: DlpError,
|
|
698
703
|
AuthBotChallengeError: AuthBotChallengeError,
|
|
704
|
+
BotChallengeError: BotChallengeError,
|
|
699
705
|
SessionDeviceBindingError: SessionDeviceBindingError,
|
|
700
706
|
AcmeError: AcmeError,
|
|
701
707
|
HpkeError: HpkeError,
|