From dab9335093b5245360d2f5f7f0497119fe4b8c06 Mon Sep 17 00:00:00 2001 From: Sparky Fen Date: Sat, 18 Jul 2026 23:52:22 -0700 Subject: [PATCH 1/2] feat(setup): auto-provision admin-login Turnstile widget (F1) Mirror the F5 WAF provisioning pattern for finding F1 (admin-login brute-force protection): a unit-testable turnstile-lib that idempotently creates (or reuses, by stable name) an account-level Turnstile widget for the fork's custom domain and returns its sitekey + secret. setup.ts wires the public sitekey as the TURNSTILE_SITEKEY Pages var and the secret as the TURNSTILE_SECRET Pages secret, non-fatal when the token lacks Account - Turnstile - Edit and skipped for pages.dev-only forks. Adds the Turnstile scope to the token recipe + README table (WAF scope is added by the F5 branch). The widget secret is never logged. --- README.md | 1 + scripts/setup.ts | 51 ++++++++- scripts/turnstile-lib.test.ts | 207 ++++++++++++++++++++++++++++++++++ scripts/turnstile-lib.ts | 148 ++++++++++++++++++++++++ 4 files changed, 404 insertions(+), 3 deletions(-) create mode 100644 scripts/turnstile-lib.test.ts create mode 100644 scripts/turnstile-lib.ts diff --git a/README.md b/README.md index 70a53f66..787f1e30 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,7 @@ original deployment it grew out of). The project home is | Account · Cloudflare Pages · Edit | create/deploy the Pages project | | Account · D1 · Edit | create + migrate the database | | Account · Workers R2 Storage · Edit | create the image bucket | + | Account · Turnstile · Edit | **only if** attaching a custom domain — provisions the admin-login bot check | | Zone · DNS · Edit | **only if** attaching a custom domain (writes the apex record) | | Zone · Zone Settings · Edit | *optional* — lets setup enable image resizing for you | diff --git a/scripts/setup.ts b/scripts/setup.ts index 715d1163..79758236 100644 --- a/scripts/setup.ts +++ b/scripts/setup.ts @@ -39,6 +39,7 @@ import { imageResizingOutcome, cfApi } from './setup-lib.ts'; +import { provisionTurnstileWidget, type TurnstileStatus } from './turnstile-lib.ts'; // Shared with the admin Settings save so the seeded siteUrl passes the same // https-URL validation (validate.ts has no imports, so tsx loads it directly). import { normalizeHttpsUrl } from '../src/lib/server/validate.ts'; @@ -108,13 +109,16 @@ const token = (bytes = 32) => randomBytes(bytes).toString('hex'); // The friend-facing API-token recipe, printed whenever a scope preflight fails so // the operator knows exactly what to (re)create. Kept in one place so the CLI and // the message stay in sync with README's "API token" section. +// NOTE: `Zone · WAF · Edit` (the download-beacon rate limit, finding F5) is added +// to this recipe by the fix/download-waf-ratelimit change — do NOT re-add it here. const TOKEN_RECIPE = 'Create a Cloudflare API token (dash → My Profile → API Tokens → Create Token → Custom token) with:\n' + ' • Account · Cloudflare Pages · Edit\n' + ' • Account · D1 · Edit\n' + ' • Account · Workers R2 Storage · Edit\n' + - ' • Zone · DNS · Edit (only if you are attaching a custom domain)\n' + - ' • Zone · Zone Settings · Edit (optional; lets setup enable image resizing for you)'; + ' • Account · Turnstile · Edit (only with a custom domain; adds the admin-login bot check)\n' + + ' • Zone · DNS · Edit (only if you are attaching a custom domain)\n' + + ' • Zone · Zone Settings · Edit (optional; lets setup enable image resizing for you)'; async function main() { console.log('— Sona setup —\n'); @@ -279,6 +283,14 @@ async function main() { // before provisioning so a missing DNS scope fails early. `imageResizingOn`: // true = on, false = off (couldn't enable), null = unknown/not checked. let imageResizingOn: boolean | null = null; + // Admin-login Turnstile widget (finding F1). Only meaningful with a custom + // domain — a *.pages.dev-only fork isn't provisioned one. Its sitekey (public) + // is set as a Pages var below and its secret as a Pages secret; the login page + // enforces the challenge only when BOTH are present. null = not attempted + // (no domain / no token); 'error' = token lacked Account · Turnstile · Edit. + let turnstileStatus: TurnstileStatus | null = null; + let turnstileSitekey = ''; + let turnstileSecret = ''; if (domain) { const host = hostFromDomain(domain); if (cfToken && cfAccount) { @@ -314,6 +326,21 @@ async function main() { } imageResizingOn = imageResizingOutcome(ir, patchOk); } + + // Turnstile widget for the admin-login bot check (finding F1). Account- + // scoped, so — unlike the DNS / image-resizing checks above — it does NOT + // need a resolved zone and runs even when the domain's DNS lives elsewhere. + // Non-fatal: a token without Account · Turnstile · Edit just yields an + // 'error' result we warn about in Next steps — setup keeps going regardless. + const ts = await provisionTurnstileWidget(cfToken, cfAccount, host); + turnstileStatus = ts.status; + turnstileSitekey = ts.sitekey ?? ''; + turnstileSecret = ts.secret ?? ''; + if (ts.status === 'error') { + console.warn(`\n⚠ Admin-login protection NOT set — ${ts.detail}`); + } else { + console.log(`✔ Admin-login Turnstile: ${ts.detail}`); + } } else { console.warn( '\nℹ A custom domain was given but CLOUDFLARE_API_TOKEN/ACCOUNT_ID are not in the env,' @@ -369,7 +396,13 @@ async function main() { dbId, r2Binding: 'IMAGES', bucket: r2Missing ? '' : bucket, - envVars: { FURTRACK_MODE: furtrackMode } + // TURNSTILE_SITEKEY is public (rendered into the login page), so it rides + // as a plain Pages var alongside FURTRACK_MODE. Its secret is set separately + // as a Pages secret below. Absent when Turnstile wasn't provisioned. + envVars: { + FURTRACK_MODE: furtrackMode, + ...(turnstileSitekey ? { TURNSTILE_SITEKEY: turnstileSitekey } : {}) + } }); const res = await cfApi(cfToken, `/accounts/${cfAccount}/pages/projects/${project}`, { method: 'PATCH', @@ -459,6 +492,9 @@ async function main() { if (telegramBotToken) putSecret('TELEGRAM_BOT_TOKEN', telegramBotToken); if (resendApiKey) putSecret('RESEND_API_KEY', resendApiKey); if (resendFrom) putSecret('RESEND_FROM', resendFrom); + // Turnstile secret for the admin-login siteverify (finding F1). Server-only, so + // it's a Pages secret (never a plain var); the public sitekey was set above. + if (turnstileSecret) putSecret('TURNSTILE_SECRET', turnstileSecret); // 8. Offer to wire the fork's GitHub Actions secrets/vars so CI deploys work // with no separate manual step. Only when gh is installed + authenticated, @@ -566,6 +602,15 @@ async function main() { console.log(' "Resize images from any origin". Free tier: 5,000 transformations/month.'); console.log(' Until on, gallery thumbnails serve the full-size original (slow) or 404.'); } + // Admin-login Turnstile (finding F1). 'error' = token lacked the scope, so the + // login has no bot check; otherwise the sitekey/secret are wired and enforced. + if (turnstileStatus === 'error') { + console.log(' • Admin-login bot check: NOT set (token lacks Account · Turnstile · Edit).'); + console.log(' Add that permission to the token and re-run setup to protect /admin/login.'); + } else if (turnstileStatus) { + console.log(` • Admin-login bot check: Turnstile ${turnstileStatus} for ${host}`); + console.log(' (TURNSTILE_SITEKEY var + TURNSTILE_SECRET secret set; enforced once deployed).'); + } } console.log('\n Your one-time setup token (enter it in the wizard):\n'); console.log(` SETUP_TOKEN = ${setupToken}`); diff --git a/scripts/turnstile-lib.test.ts b/scripts/turnstile-lib.test.ts new file mode 100644 index 00000000..cd08e18e --- /dev/null +++ b/scripts/turnstile-lib.test.ts @@ -0,0 +1,207 @@ +import { describe, it, expect } from 'vitest'; +import type { CfApiResult } from './setup-lib.ts'; +import { + provisionTurnstileWidget, + buildCreateBody, + WIDGET_NAME, + WIDGET_MODE +} from './turnstile-lib.ts'; + +const TOKEN = 'cf-secret-token-value-should-never-leak'; +const WIDGET_SECRET = 'turnstile-widget-secret-should-never-leak'; +const ACCT = 'acct123'; +const SITEKEY = '0x4AAAAAAAsitekey'; + +interface Call { + token: string; + path: string; + method: string; + body?: unknown; +} + +/** + * Builds a fake `cfApi` that never touches the network: it records every call and + * answers from a path+method → result map. Any path not in the map returns a 500, + * which surfaces as an unexpected-call failure in assertions. + */ +function fakeApi(routes: Record) { + const calls: Call[] = []; + const api = async ( + token: string, + path: string, + init: { method?: string; body?: unknown } = {} + ): Promise => { + const method = init.method ?? 'GET'; + calls.push({ token, path, method, body: init.body }); + return routes[`${method} ${path}`] ?? routes[path] ?? { ok: false, status: 500 }; + }; + return { api, calls }; +} + +const listPath = `GET /accounts/${ACCT}/challenges/widgets?per_page=50`; +const createPath = `POST /accounts/${ACCT}/challenges/widgets`; +const getPath = `GET /accounts/${ACCT}/challenges/widgets/${SITEKEY}`; + +describe('buildCreateBody', () => { + it('encodes the stable name, the domain, and managed mode', () => { + expect(buildCreateBody('akito.dog')).toEqual({ + name: WIDGET_NAME, + domains: ['akito.dog'], + mode: WIDGET_MODE + }); + expect(WIDGET_NAME).toBe('sona-admin-login'); + expect(WIDGET_MODE).toBe('managed'); + }); +}); + +describe('provisionTurnstileWidget — creates when absent', () => { + it('POSTs a new widget when none of ours exists, returning sitekey + secret', async () => { + const { api, calls } = fakeApi({ + [listPath]: { ok: true, status: 200, result: [] }, + [createPath]: { ok: true, status: 200, result: { sitekey: SITEKEY, secret: WIDGET_SECRET } } + }); + const res = await provisionTurnstileWidget(TOKEN, ACCT, 'akito.dog', api); + expect(res.status).toBe('created'); + expect(res.sitekey).toBe(SITEKEY); + expect(res.secret).toBe(WIDGET_SECRET); + + const post = calls.find((c) => c.method === 'POST'); + expect(post?.path).toBe(`/accounts/${ACCT}/challenges/widgets`); + expect(post?.body).toEqual({ name: WIDGET_NAME, domains: ['akito.dog'], mode: WIDGET_MODE }); + // No get-by-sitekey when we just created it. + expect(calls.some((c) => c.path.endsWith(`/widgets/${SITEKEY}`))).toBe(false); + }); + + it('ignores widgets with a different name and creates ours', async () => { + const { api, calls } = fakeApi({ + [listPath]: { + ok: true, + status: 200, + result: [{ name: 'someone-elses-widget', sitekey: 'other-key' }] + }, + [createPath]: { ok: true, status: 200, result: { sitekey: SITEKEY, secret: WIDGET_SECRET } } + }); + const res = await provisionTurnstileWidget(TOKEN, ACCT, 'akito.dog', api); + expect(res.status).toBe('created'); + // Never fetched the unrelated widget's secret. + expect(calls.some((c) => c.path.includes('other-key'))).toBe(false); + }); +}); + +describe('provisionTurnstileWidget — reuses when present (idempotent)', () => { + it('finds our widget by name and reads its secret via the single-widget GET', async () => { + const { api, calls } = fakeApi({ + [listPath]: { + ok: true, + status: 200, + result: [{ name: WIDGET_NAME, sitekey: SITEKEY }] + }, + [getPath]: { ok: true, status: 200, result: { sitekey: SITEKEY, secret: WIDGET_SECRET } } + }); + const res = await provisionTurnstileWidget(TOKEN, ACCT, 'akito.dog', api); + expect(res.status).toBe('exists'); + expect(res.sitekey).toBe(SITEKEY); + expect(res.secret).toBe(WIDGET_SECRET); + // Reuse must NOT create a duplicate. + expect(calls.some((c) => c.method === 'POST')).toBe(false); + const get = calls.find((c) => c.path === `/accounts/${ACCT}/challenges/widgets/${SITEKEY}`); + expect(get?.method).toBe('GET'); + }); + + it('errors (no mutation) when the existing widget’s secret cannot be read', async () => { + const { api, calls } = fakeApi({ + [listPath]: { + ok: true, + status: 200, + result: [{ name: WIDGET_NAME, sitekey: SITEKEY }] + }, + // GET succeeds but returns no secret (e.g. a partial/blank body). + [getPath]: { ok: true, status: 200, result: { sitekey: SITEKEY } } + }); + const res = await provisionTurnstileWidget(TOKEN, ACCT, 'akito.dog', api); + expect(res.status).toBe('error'); + expect(res.secret).toBeUndefined(); + expect(res.detail).toContain('could not read its secret'); + expect(calls.some((c) => c.method === 'POST')).toBe(false); + }); +}); + +describe('provisionTurnstileWidget — clear errors, no mutation', () => { + it('token lacks Turnstile scope (list 403) → error naming the scope, no create', async () => { + const { api, calls } = fakeApi({ + [listPath]: { ok: false, status: 403 } + }); + const res = await provisionTurnstileWidget(TOKEN, ACCT, 'akito.dog', api); + expect(res.status).toBe('error'); + expect(res.detail).toContain('Turnstile: Edit'); + // Only the list GET happened — never proceeded to create. + expect(calls).toHaveLength(1); + expect(calls[0].method).toBe('GET'); + }); + + it('create call fails → scoped error, sitekey/secret absent', async () => { + const { api } = fakeApi({ + [listPath]: { ok: true, status: 200, result: [] }, + [createPath]: { ok: false, status: 403 } + }); + const res = await provisionTurnstileWidget(TOKEN, ACCT, 'akito.dog', api); + expect(res.status).toBe('error'); + expect(res.detail).toContain('failed to create'); + expect(res.sitekey).toBeUndefined(); + expect(res.secret).toBeUndefined(); + }); + + it('create returns ok but a body with no sitekey/secret → error', async () => { + const { api } = fakeApi({ + [listPath]: { ok: true, status: 200, result: [] }, + [createPath]: { ok: true, status: 200, result: {} } + }); + const res = await provisionTurnstileWidget(TOKEN, ACCT, 'akito.dog', api); + expect(res.status).toBe('error'); + }); + + it('empty domain → error before any network call', async () => { + const { api, calls } = fakeApi({}); + const res = await provisionTurnstileWidget(TOKEN, ACCT, ' ', api); + expect(res.status).toBe('error'); + expect(calls).toHaveLength(0); + }); +}); + +describe('provisionTurnstileWidget — never leaks the token or the widget secret', () => { + it('the CF token appears in no returned detail and only ever rides as the first arg', async () => { + const { api, calls } = fakeApi({ + [listPath]: { ok: true, status: 200, result: [] }, + [createPath]: { ok: true, status: 200, result: { sitekey: SITEKEY, secret: WIDGET_SECRET } } + }); + const res = await provisionTurnstileWidget(TOKEN, ACCT, 'akito.dog', api); + expect(res.detail).not.toContain(TOKEN); + for (const c of calls) { + expect(c.token).toBe(TOKEN); + expect(c.path).not.toContain(TOKEN); + expect(JSON.stringify(c.body ?? '')).not.toContain(TOKEN); + } + }); + + it('the widget secret never appears in a detail string, across create and reuse', async () => { + const scenarios: Record[] = [ + // created + { + [listPath]: { ok: true, status: 200, result: [] }, + [createPath]: { ok: true, status: 200, result: { sitekey: SITEKEY, secret: WIDGET_SECRET } } + }, + // reused + { + [listPath]: { ok: true, status: 200, result: [{ name: WIDGET_NAME, sitekey: SITEKEY }] }, + [getPath]: { ok: true, status: 200, result: { sitekey: SITEKEY, secret: WIDGET_SECRET } } + } + ]; + for (const routes of scenarios) { + const { api } = fakeApi(routes); + const res = await provisionTurnstileWidget(TOKEN, ACCT, 'akito.dog', api); + expect(res.detail).not.toContain(WIDGET_SECRET); + // The secret is still returned for wiring — just never in the printable detail. + expect(res.secret).toBe(WIDGET_SECRET); + } + }); +}); diff --git a/scripts/turnstile-lib.ts b/scripts/turnstile-lib.ts new file mode 100644 index 00000000..4b5444ee --- /dev/null +++ b/scripts/turnstile-lib.ts @@ -0,0 +1,148 @@ +/** + * Cloudflare Turnstile widget provisioning for the admin-login bot check + * (security finding F1). The public /admin/login POST is the one unauthenticated + * write that guesses a password; a Turnstile challenge in front of it raises the + * cost of a brute-force loop. This creates (or reuses) an account-level Turnstile + * widget for the fork's domain and hands back its sitekey + secret so setup can + * wire TURNSTILE_SITEKEY (a Pages var, public) and TURNSTILE_SECRET (a Pages + * secret, server-only). The app side — the login form + `verifyTurnstile` — is a + * separate change; enforcement is gated on BOTH keys being set, so a fork with no + * Turnstile scope simply runs on the throttle + password alone. + * + * The core `provisionTurnstileWidget` mirrors `applyDownloadRateLimit` in + * waf-lib.ts: it reuses `cfApi` from setup-lib for token handling + fetch style, + * lives here (not in the self-executing setup.ts) so it is unit-testable, and is + * idempotent — a re-run finds the existing widget by its stable name and reuses + * it rather than minting a duplicate. The Cloudflare token is passed in, used only + * as a Bearer header by `cfApi`, and never logged; the widget SECRET it returns is + * never placed in any `detail` string (setup feeds it to `wrangler pages secret + * put` over stdin, never the console). + * + * Unlike WAF, Turnstile is ACCOUNT-scoped and needs no Cloudflare zone — a widget + * can be issued for any domain, including one whose DNS lives elsewhere — so the + * caller does not gate this on zone resolution, only on having a custom domain. + */ +import { cfApi, hostFromDomain } from './setup-lib.ts'; + +/** + * Stable widget name we match on so re-runs find-and-reuse our widget (idempotent) + * instead of appending a duplicate. Turnstile has no unique key or get-by-name, so + * the name is our reconciliation key — do not change it or old forks' widgets + * become unmatched and a fresh one is created alongside. + */ +export const WIDGET_NAME = 'sona-admin-login'; + +/** + * Managed mode: Cloudflare picks the challenge from the visitor's signals and only + * shows an interaction to suspected bots — the least-friction option for a login a + * real operator hits daily. (Non-Interactive / Invisible are the other modes.) + */ +export const WIDGET_MODE = 'managed'; + +/** The token permission a fork operator must add, quoted verbatim in errors. */ +const SCOPE_HINT = 'Account → Turnstile: Edit'; + +/** A Turnstile widget as returned by the challenges/widgets API. */ +interface Widget { + sitekey?: string; + secret?: string; + name?: string; +} + +/** The create-widget body sent to POST .../challenges/widgets. */ +export function buildCreateBody(host: string): Record { + return { name: WIDGET_NAME, domains: [host], mode: WIDGET_MODE }; +} + +export type TurnstileStatus = 'created' | 'exists' | 'error'; + +export interface TurnstileResult { + status: TurnstileStatus; + /** Human-readable, SECRET-free summary safe to print. */ + detail: string; + /** Public site key — safe to render into the page / set as a plain Pages var. */ + sitekey?: string; + /** Server-only secret — feed to `pages secret put`, never log. Absent on error. */ + secret?: string; +} + +/** + * Idempotently provision the admin-login Turnstile widget for `domain`'s host. + * + * Sequence (all via `cfApi`, Bearer `cfToken`): + * 1. GET /accounts//challenges/widgets → list the account's widgets. A + * non-ok response (401/403 = no Turnstile scope, or a transient error) → a + * clear error naming the missing scope. No mutation. + * 2. Match our widget by its stable `name` (WIDGET_NAME): + * - found → GET .../widgets/ to read its secret authoritatively + * (the single-widget GET returns the secret; the reuse is a no-op create), + * status 'exists'. + * - not found → POST .../widgets with our create body, status 'created'. + * Either way the returned result carries the sitekey + secret; nothing else on + * the account is touched. + * + * `api` is injectable (defaults to the real `cfApi`) so tests exercise every branch + * without network. Never logs the token; the widget secret appears in no `detail`. + * + * Note on matching: the list is read with a generous page size, not paginated. A + * fresh fork's account has at most a handful of widgets, so a single page finds + * ours; the cost of the rare miss is a duplicate widget, never a crash. + */ +export async function provisionTurnstileWidget( + cfToken: string, + accountId: string, + domain: string, + api: typeof cfApi = cfApi +): Promise { + const host = hostFromDomain(domain); + if (!host) return { status: 'error', detail: 'no domain given' }; + + // 1. List existing widgets; reconcile against ours by stable name. + const listRes = await api(cfToken, `/accounts/${accountId}/challenges/widgets?per_page=50`); + if (!listRes.ok) { + return { + status: 'error', + detail: `could not list Turnstile widgets (HTTP ${listRes.status}); token needs ${SCOPE_HINT}` + }; + } + const widgets = (listRes.result as Widget[] | undefined) ?? []; + const mine = widgets.find((w) => w.name === WIDGET_NAME && w.sitekey); + + // 2a. Reuse: fetch the single widget so we read its secret from the authoritative + // GET (matching `wrangler turnstile widget get`, which returns the secret). + if (mine?.sitekey) { + const getRes = await api(cfToken, `/accounts/${accountId}/challenges/widgets/${mine.sitekey}`); + const secret = (getRes.result as Widget | undefined)?.secret; + if (!getRes.ok || !secret) { + return { + status: 'error', + detail: `found the ${WIDGET_NAME} widget for ${host} but could not read its secret (HTTP ${getRes.status}); token needs ${SCOPE_HINT}` + }; + } + return { + status: 'exists', + sitekey: mine.sitekey, + secret, + detail: `reused the existing ${WIDGET_NAME} Turnstile widget for ${host}` + }; + } + + // 2b. Create: no widget of ours yet. + const createRes = await api(cfToken, `/accounts/${accountId}/challenges/widgets`, { + method: 'POST', + body: buildCreateBody(host) + }); + const created = createRes.result as Widget | undefined; + if (!createRes.ok || !created?.sitekey || !created?.secret) { + return { + status: 'error', + detail: `failed to create the ${WIDGET_NAME} Turnstile widget for ${host} (HTTP ${createRes.status}); token needs ${SCOPE_HINT}` + }; + } + return { + status: 'created', + sitekey: created.sitekey, + secret: created.secret, + detail: `created the ${WIDGET_NAME} Turnstile widget for ${host} (${WIDGET_MODE} mode)` + }; +} From 6ddd928926c4cd8df628bdc0697de7476603b67e Mon Sep 17 00:00:00 2001 From: Sparky Fen Date: Wed, 22 Jul 2026 01:57:55 -0700 Subject: [PATCH 2/2] fix(setup): match the Turnstile widget on host, not name alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WIDGET_NAME was the sole reconciliation key, but one Cloudflare account can hold several forks and every fork's widget carries that same name. `.find()` then returns whichever was listed first, so provisioning a second fork adopts the first fork's sitekey — a widget scoped to the wrong domain. Every siteverify fails, and since F1 gates login on both keys being set (fail-closed), the admin login locks rather than falling back to the throttle. Match on name AND `domains` containing the fork's host. A widget with no domains field is treated as not ours, so the failure direction is always a duplicate widget (harmless, already the documented tradeoff) rather than a wrong-domain reuse. Found while provisioning widgets by hand across four forks: sparky.ink and akito.dog live in one account, which is exactly the colliding case. Three regression tests, each verified to fail against the old matcher. Existing reuse fixtures gain the `domains` the real API always returns. --- scripts/turnstile-lib.test.ts | 68 ++++++++++++++++++++++++++++++++--- scripts/turnstile-lib.ts | 22 +++++++++--- 2 files changed, 81 insertions(+), 9 deletions(-) diff --git a/scripts/turnstile-lib.test.ts b/scripts/turnstile-lib.test.ts index cd08e18e..3401ec30 100644 --- a/scripts/turnstile-lib.test.ts +++ b/scripts/turnstile-lib.test.ts @@ -89,12 +89,12 @@ describe('provisionTurnstileWidget — creates when absent', () => { }); describe('provisionTurnstileWidget — reuses when present (idempotent)', () => { - it('finds our widget by name and reads its secret via the single-widget GET', async () => { + it('finds our widget by name + host and reads its secret via the single-widget GET', async () => { const { api, calls } = fakeApi({ [listPath]: { ok: true, status: 200, - result: [{ name: WIDGET_NAME, sitekey: SITEKEY }] + result: [{ name: WIDGET_NAME, sitekey: SITEKEY, domains: ['akito.dog'] }] }, [getPath]: { ok: true, status: 200, result: { sitekey: SITEKEY, secret: WIDGET_SECRET } } }); @@ -108,12 +108,68 @@ describe('provisionTurnstileWidget — reuses when present (idempotent)', () => expect(get?.method).toBe('GET'); }); + // One Cloudflare account can hold several forks, and every fork's widget carries + // the same stable name — so the host, not the name alone, is what identifies ours. + // Reusing a sibling fork's widget would hand this fork a sitekey scoped to the + // wrong domain: every Turnstile verify then fails and, F1 being fail-closed, the + // admin login locks. A duplicate widget is the acceptable failure; this is not. + it('ignores a same-name widget issued for a SIBLING fork and creates ours', async () => { + const { api, calls } = fakeApi({ + [listPath]: { + ok: true, + status: 200, + result: [{ name: WIDGET_NAME, sitekey: 'sibling-fork-key', domains: ['sparky.ink'] }] + }, + [createPath]: { ok: true, status: 200, result: { sitekey: SITEKEY, secret: WIDGET_SECRET } } + }); + const res = await provisionTurnstileWidget(TOKEN, ACCT, 'akito.dog', api); + expect(res.status).toBe('created'); + expect(res.sitekey).toBe(SITEKEY); + // Never adopted the sibling's sitekey, and never read its secret. + expect(res.sitekey).not.toBe('sibling-fork-key'); + expect(calls.some((c) => c.path.includes('sibling-fork-key'))).toBe(false); + const post = calls.find((c) => c.method === 'POST'); + expect(post?.body).toEqual({ name: WIDGET_NAME, domains: ['akito.dog'], mode: WIDGET_MODE }); + }); + + it('picks OUR host out of a multi-fork account listing several of our widgets', async () => { + const { api } = fakeApi({ + [listPath]: { + ok: true, + status: 200, + result: [ + { name: WIDGET_NAME, sitekey: 'sparky-key', domains: ['sparky.ink'] }, + { name: WIDGET_NAME, sitekey: SITEKEY, domains: ['akito.dog'] } + ] + }, + [getPath]: { ok: true, status: 200, result: { sitekey: SITEKEY, secret: WIDGET_SECRET } } + }); + const res = await provisionTurnstileWidget(TOKEN, ACCT, 'akito.dog', api); + expect(res.status).toBe('exists'); + // The FIRST listed widget is a sibling's — order must not decide the match. + expect(res.sitekey).toBe(SITEKEY); + }); + + it('treats a widget with no domains field as not ours (creates rather than reuses)', async () => { + const { api } = fakeApi({ + [listPath]: { + ok: true, + status: 200, + result: [{ name: WIDGET_NAME, sitekey: 'domainless-key' }] + }, + [createPath]: { ok: true, status: 200, result: { sitekey: SITEKEY, secret: WIDGET_SECRET } } + }); + const res = await provisionTurnstileWidget(TOKEN, ACCT, 'akito.dog', api); + expect(res.status).toBe('created'); + expect(res.sitekey).toBe(SITEKEY); + }); + it('errors (no mutation) when the existing widget’s secret cannot be read', async () => { const { api, calls } = fakeApi({ [listPath]: { ok: true, status: 200, - result: [{ name: WIDGET_NAME, sitekey: SITEKEY }] + result: [{ name: WIDGET_NAME, sitekey: SITEKEY, domains: ['akito.dog'] }] }, // GET succeeds but returns no secret (e.g. a partial/blank body). [getPath]: { ok: true, status: 200, result: { sitekey: SITEKEY } } @@ -192,7 +248,11 @@ describe('provisionTurnstileWidget — never leaks the token or the widget secre }, // reused { - [listPath]: { ok: true, status: 200, result: [{ name: WIDGET_NAME, sitekey: SITEKEY }] }, + [listPath]: { + ok: true, + status: 200, + result: [{ name: WIDGET_NAME, sitekey: SITEKEY, domains: ['akito.dog'] }] + }, [getPath]: { ok: true, status: 200, result: { sitekey: SITEKEY, secret: WIDGET_SECRET } } } ]; diff --git a/scripts/turnstile-lib.ts b/scripts/turnstile-lib.ts index 4b5444ee..0fd669d0 100644 --- a/scripts/turnstile-lib.ts +++ b/scripts/turnstile-lib.ts @@ -27,8 +27,14 @@ import { cfApi, hostFromDomain } from './setup-lib.ts'; /** * Stable widget name we match on so re-runs find-and-reuse our widget (idempotent) * instead of appending a duplicate. Turnstile has no unique key or get-by-name, so - * the name is our reconciliation key — do not change it or old forks' widgets - * become unmatched and a fresh one is created alongside. + * the reconciliation key is this name PLUS the fork's host — do not change the name + * or old forks' widgets become unmatched and a fresh one is created alongside. + * + * The host half is not optional: one Cloudflare account can hold several forks (a + * multi-fork operator), and every fork's widget carries this same name. Matching on + * the name alone hands the SECOND fork the FIRST fork's sitekey — a widget scoped to + * the wrong domain, so every Turnstile verify fails and (F1 being fail-closed) the + * admin login locks. Wrong-domain reuse is strictly worse than a duplicate widget. */ export const WIDGET_NAME = 'sona-admin-login'; @@ -47,6 +53,7 @@ interface Widget { sitekey?: string; secret?: string; name?: string; + domains?: string[]; } /** The create-widget body sent to POST .../challenges/widgets. */ @@ -73,7 +80,8 @@ export interface TurnstileResult { * 1. GET /accounts//challenges/widgets → list the account's widgets. A * non-ok response (401/403 = no Turnstile scope, or a transient error) → a * clear error naming the missing scope. No mutation. - * 2. Match our widget by its stable `name` (WIDGET_NAME): + * 2. Match our widget by its stable `name` (WIDGET_NAME) AND `domains` containing + * this fork's host — see WIDGET_NAME on why the host half is required: * - found → GET .../widgets/ to read its secret authoritatively * (the single-widget GET returns the secret; the reuse is a no-op create), * status 'exists'. @@ -86,7 +94,9 @@ export interface TurnstileResult { * * Note on matching: the list is read with a generous page size, not paginated. A * fresh fork's account has at most a handful of widgets, so a single page finds - * ours; the cost of the rare miss is a duplicate widget, never a crash. + * ours; the cost of the rare miss is a duplicate widget, never a crash. A miss is + * the only acceptable failure direction here — see WIDGET_NAME on why matching must + * never reuse a widget issued for a different host. */ export async function provisionTurnstileWidget( cfToken: string, @@ -106,7 +116,9 @@ export async function provisionTurnstileWidget( }; } const widgets = (listRes.result as Widget[] | undefined) ?? []; - const mine = widgets.find((w) => w.name === WIDGET_NAME && w.sitekey); + const mine = widgets.find( + (w) => w.name === WIDGET_NAME && w.sitekey && w.domains?.includes(host) + ); // 2a. Reuse: fetch the single widget so we read its secret from the authoritative // GET (matching `wrangler turnstile widget get`, which returns the secret).