diff --git a/scripts/apply-download-ratelimit.ts b/scripts/apply-download-ratelimit.ts index ef00a40a..e69f37be 100644 --- a/scripts/apply-download-ratelimit.ts +++ b/scripts/apply-download-ratelimit.ts @@ -2,7 +2,7 @@ /** * Sona apply-download-ratelimit — standalone runner that applies the WAF * rate-limit rule protecting POST /api/metrics/download to an EXISTING fork's - * zone (finding F5). New forks get the rule automatically during `npm run setup`; + * zone. New forks get the rule automatically during `npm run setup`; * this is the one-off for forks that were already deployed. * * CLOUDFLARE_API_TOKEN= npm run apply-download-ratelimit -- diff --git a/scripts/setup.ts b/scripts/setup.ts index 09964478..3a88f3d0 100644 --- a/scripts/setup.ts +++ b/scripts/setup.ts @@ -283,11 +283,11 @@ 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; - // Download-beacon WAF rate limit (finding F5). Only meaningful when the fork + // Download-beacon WAF rate limit. Only meaningful when the fork // runs on a zone the operator controls — a *.pages.dev-only fork has no zone to // attach it to. Null = not attempted (no domain / no zone / no token). let downloadRateLimit: RateLimitStatus | null = null; - // Admin-login Turnstile widget (finding F1). Only meaningful with a custom + // Admin-login Turnstile widget. 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 @@ -330,7 +330,7 @@ async function main() { } imageResizingOn = imageResizingOutcome(ir, patchOk); - // WAF rate limit for the public download beacon (finding F5). Non-fatal: + // WAF rate limit for the public download beacon. Non-fatal: // a token without Zone · WAF · Edit just yields an 'error' // result we warn about in Next steps — setup keeps going regardless. const rl = await applyDownloadRateLimit(cfToken, host); @@ -342,7 +342,7 @@ async function main() { } } - // Turnstile widget for the admin-login bot check (finding F1). Account- + // Turnstile widget for the admin-login bot check. 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 @@ -507,7 +507,7 @@ 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 + // Turnstile secret for the admin-login siteverify. Server-only, so // it's a Pages secret (never a plain var); the public sitekey was set above. if (turnstileSecret) putSecret('TURNSTILE_SECRET', turnstileSecret); @@ -617,15 +617,16 @@ 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.'); } - // Download-beacon rate limit (finding F5). null = not attempted (no zone); - // 'error' = token lacked Zone · WAF · Edit — tell them to add it. + // Download-beacon rate limit. null = not attempted (no domain / no zone / no + // token — same contract as the declaration above); 'error' = the token lacked + // Zone · WAF · Edit, which is the one case worth telling them how to fix. if (downloadRateLimit === 'error') { console.log(' • Download-beacon rate limit: NOT set (token lacks Zone · WAF · Edit).'); console.log(' Add that permission to the token, then run:'); console.log(` CLOUDFLARE_API_TOKEN= npm run apply-download-ratelimit -- ${host}`); } else if (downloadRateLimit && downloadRateLimit !== 'exists') { console.log(` • Download-beacon rate limit: applied to the ${host} zone (blocks POST floods).`); - // Admin-login Turnstile (finding F1). 'error' = token lacked the scope, so the + // Admin-login Turnstile. '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).'); diff --git a/scripts/turnstile-lib.test.ts b/scripts/turnstile-lib.test.ts index 3401ec30..c41f9249 100644 --- a/scripts/turnstile-lib.test.ts +++ b/scripts/turnstile-lib.test.ts @@ -111,7 +111,8 @@ describe('provisionTurnstileWidget — reuses when present (idempotent)', () => // 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 + // wrong domain: every Turnstile verify then fails and, the check 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({ diff --git a/scripts/turnstile-lib.ts b/scripts/turnstile-lib.ts index 0fd669d0..4cd66443 100644 --- a/scripts/turnstile-lib.ts +++ b/scripts/turnstile-lib.ts @@ -1,13 +1,10 @@ /** - * 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. + * Cloudflare Turnstile widget provisioning for the optional admin-login bot + * check. 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; the check is enforced only when BOTH keys are set. * * The core `provisionTurnstileWidget` mirrors `applyDownloadRateLimit` in * waf-lib.ts: it reuses `cfApi` from setup-lib for token handling + fetch style, @@ -33,7 +30,8 @@ import { cfApi, hostFromDomain } from './setup-lib.ts'; * 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 + * the wrong domain, so every Turnstile verify fails and (the check being + * fail-closed) the * admin login locks. Wrong-domain reuse is strictly worse than a duplicate widget. */ export const WIDGET_NAME = 'sona-admin-login'; diff --git a/scripts/waf-lib.ts b/scripts/waf-lib.ts index a6f55e65..d17b0418 100644 --- a/scripts/waf-lib.ts +++ b/scripts/waf-lib.ts @@ -1,10 +1,8 @@ /** * Cloudflare WAF rate-limit provisioning for the download-metrics beacon - * (security finding F5). The public POST /api/metrics/download endpoint is an - * open, unauthenticated write: harmless per hit (one bounded UPSERT), but it can - * be looped to run up requests and D1 writes. This applies a zone-level rate-limit - * rule that blocks a single IP that pounds the beacon, without touching any other - * WAF rule on the zone. + * (POST /api/metrics/download). Applies a zone-level rate-limit rule capping how + * often a single IP can hit the beacon, so the counter stays cheap to run, + * without touching any other WAF rule on the zone. * * The core `applyDownloadRateLimit` is shared by two callers: the fork setup CLI * (scripts/setup.ts, for future forks) and the standalone runner diff --git a/src/csp-config.test.ts b/src/csp-config.test.ts index c985c960..a569da6a 100644 --- a/src/csp-config.test.ts +++ b/src/csp-config.test.ts @@ -5,7 +5,7 @@ import config from '../svelte.config.js'; // can't silently gut it. The real emitted header + zero-violation rendering is // covered end-to-end in tests/e2e/csp-check.spec.ts; this is the cheap unit gate // on the directives that matter for XSS containment. -describe('kit.csp directives (F3)', () => { +describe('kit.csp directives', () => { const csp = config.kit?.csp; const d = csp?.directives ?? {}; @@ -54,7 +54,7 @@ describe('kit.csp directives (F3)', () => { }); it('permits the Turnstile widget (script + iframe) on admin login', () => { - // challenges.cloudflare.com must be reachable or /admin/login breaks (F1). + // challenges.cloudflare.com must be reachable or /admin/login breaks. expect(d['script-src']).toContain('https://challenges.cloudflare.com'); expect(d['frame-src']).toContain('https://challenges.cloudflare.com'); }); diff --git a/src/hooks.server.test.ts b/src/hooks.server.test.ts index c0b1aa25..530bccc0 100644 --- a/src/hooks.server.test.ts +++ b/src/hooks.server.test.ts @@ -361,7 +361,7 @@ describe('authHandle — 5xx counts toward the error rate (issue #6)', () => { }); }); -describe('authHandle — security response headers (F3)', () => { +describe('authHandle — security response headers', () => { beforeEach(() => { vi.mocked(isSetupComplete).mockResolvedValue(true); }); diff --git a/src/turnstile-deploy-sync.test.ts b/src/turnstile-deploy-sync.test.ts index 41dcb0fd..5f0e3e49 100644 --- a/src/turnstile-deploy-sync.test.ts +++ b/src/turnstile-deploy-sync.test.ts @@ -25,7 +25,7 @@ const step = (() => { return end === -1 ? rest : rest.slice(0, end); })(); -describe('deploy.yml — Turnstile keys are bound onto Pages (F1)', () => { +describe('deploy.yml — Turnstile keys are bound onto Pages', () => { it('has the sync step at all', () => { expect(step).not.toBe(''); expect(step).toContain('Sync TURNSTILE keys to Pages project');