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 @@ -86,6 +86,7 @@ original deployment it grew out of). The project home is
| Account · D1 · Edit | create + migrate the database |
| Account · Workers R2 Storage · Edit | create the image bucket |
| 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 |

Without **DNS · Edit**, registering the Pages apex domain succeeds but the DNS
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"setup": "tsx scripts/setup.ts",
"reset-password": "tsx scripts/reset-password.ts",
"connect-domains": "tsx scripts/connect-domains.ts",
"apply-download-ratelimit": "tsx scripts/apply-download-ratelimit.ts",
"predev": "tsx scripts/dev-bootstrap.ts",
"dev": "vite dev",
"build": "vite build",
Expand Down
71 changes: 71 additions & 0 deletions scripts/apply-download-ratelimit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
#!/usr/bin/env tsx
/**
* 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`;
* this is the one-off for forks that were already deployed.
*
* CLOUDFLARE_API_TOKEN=<token> npm run apply-download-ratelimit -- <domain>
*
* The token comes from the environment (never an argv, so it can't land in shell
* history or a process listing) and is read, used as a Bearer header, and never
* printed. The domain is the fork's site domain (e.g. akito.dog); scheme/path are
* stripped. The operation is idempotent — re-running on an already-protected zone
* is a no-op. Exit 0 on created/updated/exists, 1 on error.
*
* Token scope required: Zone → WAF: Edit, on a token whose Zone Resources
* include the fork's domain. (Read-only Zone·Read is enough to resolve the
* zone, but writing the rule needs WAF: Edit.)
*/
import { env, argv, exit } from 'node:process';
import { applyDownloadRateLimit } from './waf-lib.ts';

const TOKEN_RECIPE =
'Set CLOUDFLARE_API_TOKEN to a Cloudflare API token (dash → My Profile → API Tokens →\n' +
'Create Token → Custom token) with:\n' +
' • Zone · WAF · Edit\n' +
' and a Zone Resource that includes the fork domain, then re-run:\n' +
' CLOUDFLARE_API_TOKEN=<token> npm run apply-download-ratelimit -- <domain>';

async function main(): Promise<number> {
console.log('— Sona apply-download-ratelimit —\n');

const cfToken = env.CLOUDFLARE_API_TOKEN;
if (!cfToken) {
console.error('✖ CLOUDFLARE_API_TOKEN is not set in the environment.\n');
console.error(TOKEN_RECIPE);
return 1;
}

const domain = argv.slice(2).find((a) => !a.startsWith('-')) ?? '';
if (!domain) {
console.error('✖ No domain given. Pass the fork domain as an argument:');
console.error(' CLOUDFLARE_API_TOKEN=<token> npm run apply-download-ratelimit -- <domain>');
return 1;
}

const res = await applyDownloadRateLimit(cfToken, domain);
switch (res.status) {
case 'created':
console.log(`✔ ${res.detail}`);
return 0;
case 'updated':
console.log(`✔ ${res.detail}`);
return 0;
case 'exists':
console.log(`✔ ${res.detail}`);
return 0;
default:
console.error(`✖ ${res.detail}\n`);
console.error(TOKEN_RECIPE);
return 1;
}
}

main()
.then((code) => exit(code))
.catch((err) => {
// Never surface the token; print only the error class/message.
console.error('✖ Unexpected error:', err instanceof Error ? err.message : String(err));
exit(1);
});
30 changes: 28 additions & 2 deletions scripts/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
imageResizingOutcome,
cfApi
} from './setup-lib.ts';
import { applyDownloadRateLimit, type RateLimitStatus } from './waf-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 @@ -113,8 +114,9 @@ const TOKEN_RECIPE =
' • 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)';
' • 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)';

async function main() {
console.log('— Sona setup —\n');
Expand Down Expand Up @@ -279,6 +281,10 @@ 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
// 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;
if (domain) {
const host = hostFromDomain(domain);
if (cfToken && cfAccount) {
Expand Down Expand Up @@ -313,6 +319,17 @@ async function main() {
patchOk = enabled.ok;
}
imageResizingOn = imageResizingOutcome(ir, patchOk);

// WAF rate limit for the public download beacon (finding F5). 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);
downloadRateLimit = rl.status;
if (rl.status === 'error') {
console.warn(`\n⚠ Could not attach the download-beacon rate-limit rule: ${rl.detail}`);
} else {
console.log(`✔ Download-beacon rate limit: ${rl.detail}`);
}
}
} else {
console.warn(
Expand Down Expand Up @@ -566,6 +583,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.');
}
// Download-beacon rate limit (finding F5). null = not attempted (no zone);
// 'error' = token lacked Zone · WAF · Edit — tell them to add it.
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).`);
}
}
console.log('\n Your one-time setup token (enter it in the wizard):\n');
console.log(` SETUP_TOKEN = ${setupToken}`);
Expand Down
Loading
Loading