Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion scripts/apply-download-ratelimit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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=<token> npm run apply-download-ratelimit -- <domain>
Expand Down
17 changes: 9 additions & 8 deletions scripts/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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=<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).');
Expand Down
3 changes: 2 additions & 1 deletion scripts/turnstile-lib.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
18 changes: 8 additions & 10 deletions scripts/turnstile-lib.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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';
Expand Down
8 changes: 3 additions & 5 deletions scripts/waf-lib.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/csp-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? {};

Expand Down Expand Up @@ -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');
});
Expand Down
2 changes: 1 addition & 1 deletion src/hooks.server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
2 changes: 1 addition & 1 deletion src/turnstile-deploy-sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading