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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 · WAF · Edit | **only if** attaching a custom domain — adds a WAF rate limit on the public download beacon (`POST /api/metrics/download`) |
| Zone · Zone Settings · Edit | *optional* — lets setup enable image resizing for you |
Expand Down
44 changes: 43 additions & 1 deletion scripts/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
cfApi
} from './setup-lib.ts';
import { applyDownloadRateLimit, type RateLimitStatus } from './waf-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';
Expand Down Expand Up @@ -114,6 +115,7 @@ const TOKEN_RECIPE =
' • Account · Cloudflare Pages · Edit\n' +
' • Account · D1 · Edit\n' +
' • Account · Workers R2 Storage · Edit\n' +
' • 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 · WAF · Edit (only with a custom domain; adds the download-beacon rate limit)\n' +
' • Zone · Zone Settings · Edit (optional; lets setup enable image resizing for you)';
Expand Down Expand Up @@ -285,6 +287,14 @@ async function main() {
// 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
// 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) {
Expand Down Expand Up @@ -331,6 +341,21 @@ async function main() {
console.log(`✔ Download-beacon rate limit: ${rl.detail}`);
}
}

// 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,'
Expand Down Expand Up @@ -386,7 +411,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',
Expand Down Expand Up @@ -476,6 +507,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,
Expand Down Expand Up @@ -591,6 +625,14 @@ async function main() {
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
// 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).');
Comment on lines +628 to +635

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Do not report Turnstile as enabled before its credentials are actually wired.

turnstileStatus reflects provisioning only (Line 336). A failed Pages PATCH or swallowed putSecret failure still reaches this success message, and the GitHub-secret list omits TURNSTILE_SECRET for the first CI deployment. Track successful sitekey and secret writes separately, include the secret in CI wiring/deploy sync, and print “configured” only when both succeed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/setup.ts` around lines 605 - 612, Update the Turnstile reporting
around turnstileStatus to track successful sitekey and secret writes
independently, rather than treating provisioning status as configuration. Ensure
failed Pages PATCH or putSecret operations keep the integration unconfigured,
add TURNSTILE_SECRET to the initial CI secret wiring/deploy sync, and print the
enabled/configured message only when both credentials were successfully written.

}
}
console.log('\n Your one-time setup token (enter it in the wizard):\n');
Expand Down
267 changes: 267 additions & 0 deletions scripts/turnstile-lib.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
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<string, CfApiResult>) {
const calls: Call[] = [];
const api = async (
token: string,
path: string,
init: { method?: string; body?: unknown } = {}
): Promise<CfApiResult> => {
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 + 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, 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');
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');
});

// 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, domains: ['akito.dog'] }]
},
// 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<string, CfApiResult>[] = [
// 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, domains: ['akito.dog'] }]
},
[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);
}
});
});
Loading
Loading