Security Guide
Last Updated
July 16, 2026
This document covers the security architecture, known risks, applied hardening measures, accepted tradeoffs, and penetration testing procedures for The Pool crowdfunding platform. Encrypted backup boundaries, quarantined session/rate-limit state, off-device handling, and production restore approvals are defined in BACKUP_RESTORE.md.
Use this alongside ETHICAL_RISK.md when a change creates new data use, supporter messaging, admin power, public sharing, automation, or engagement pressure. Security review should cover not only credential compromise and code injection, but also realistic misuse by spammers, harassers, fraudsters, careless admins, and overly aggressive growth workflows.
Security Architecture
Authentication Mechanisms
| Mechanism | Endpoints | Description |
|---|---|---|
| Magic Link Tokens | /pledge*, /pledges, /votes |
HMAC-SHA256 signed tokens with 90-day expiry |
| Launch Reminder Unsubscribe Tokens | GET /launch-reminders/unsubscribe |
Scoped HMAC token that suppresses one campaign/email reminder signup |
| Stripe Webhook Signature | /webhooks/stripe |
HMAC-SHA256 verification per Stripe spec |
| Admin Dashboard Sessions | Browser dashboard /admin/* APIs |
Email magic-link sign-in, signed session cookie, CSRF header on mutations, role/campaign scoping |
| Campaign Preview Reviewer Tokens | /campaigns/:slug/preview/ via /admin/campaign-preview/:slug |
Short-lived signed reviewer tokens scoped to campaign slug and reviewer email, backed by a 24-hour KV allowlist |
| Admin Sign-In Challenge | POST /admin/auth/start |
Optional Cloudflare Turnstile verification before admin magic-link issuance |
| Launch Reminder Challenge | POST /launch-reminders |
Optional/expected Cloudflare Turnstile verification before reminder signup writes |
| Admin Recovery Secret | Automation and recovery /admin/* endpoints |
Authorization: Bearer <secret> or x-admin-key header for script-driven operations |
| Scoped Admin Secrets | Settlement and broadcast automation endpoints | Optional ADMIN_SETTLEMENT_SECRET and ADMIN_BROADCAST_SECRET; when configured, the scoped route rejects the broader ADMIN_SECRET |
| Test Mode Guard | /test/* |
APP_MODE === 'test' environment check |
Data Storage (Cloudflare KV)
| Key Pattern | Namespace | Data | Sensitivity |
|---|---|---|---|
pledge:{orderId} |
PLEDGES | Email, amount, Stripe IDs, status | High - PII + payment data |
email:{email} |
PLEDGES | Array of order IDs | Medium - links email to pledges |
stats:{slug} |
PLEDGES | Aggregate totals | Low - public |
tier-inventory:{slug} |
PLEDGES | Tier claim counts | Low - public |
stripe-event:{id} |
PLEDGES | “processed” flag | Low - idempotency |
processor-event:v1:{time}:{id} |
PLEDGES | Redacted Stripe request/webhook IDs, status, intent, timing, idempotency, reconciliation state; 400-day TTL | Medium - payment operations metadata |
campaign-pledges:{slug} |
PLEDGES | Array of order IDs per campaign | Low - index |
campaign-charged:{slug} |
PLEDGES | Settlement completion timestamp | Low - flag |
settlement-job:{slug} |
PLEDGES | Settlement batch progress | Low - ephemeral |
settlement-group:v1:{slug}:{hash} |
PLEDGES | Durable pre-charge/submitted/result state and processor ID; 400-day TTL | Medium - payment operations metadata |
reconciliation-break:v1:{slug}:{kind}:{hash} |
PLEDGES | Open/resolved processor-vs-pledge differences and object/order IDs; 400-day TTL | Medium - payment operations metadata |
pending-extras:{orderId} |
PLEDGES | Temporary support item / custom amount checkout extras | Low - ephemeral |
pending-tiers:{orderId} |
PLEDGES | Temporary overflow tier metadata during checkout | Low - ephemeral |
cron:lastRun |
PLEDGES | Last persisted hourly cron execution timestamp | Low - monitoring |
admin-login:{hash} |
PLEDGES | One-time admin login nonce and email | Medium - ephemeral admin auth |
admin-session:{hash} |
PLEDGES | Admin email, role, campaign scope, CSRF token, expiry | High - admin auth |
admin-users:v1 |
PLEDGES | Runtime admin users and campaign scopes | High - access control |
admin-marketing-referrals:{slug} |
PLEDGES | Saved referral code and QR source metadata | Low - admin-authored marketing data |
admin-marketing-draft:{slug}:{surface} |
PLEDGES | Explicit shared Marketing/Blast draft with short retention | Medium - admin-authored campaign email/link content |
campaign-preview-reviewers:{slug} |
PLEDGES | Normalized reviewer email allowlist for protected campaign previews, with 24-hour TTL | Medium - campaign-scoped email access list |
admin-audit:{date}:{action}:{id} |
PLEDGES | Recent admin mutation audit events | Medium - admin identity + operational metadata |
launch-reminder:{slug}:{emailHash} |
PLEDGES | Upcoming-campaign reminder email and opt-in metadata | Medium - campaign-scoped email |
launch-reminder-suppressed:{slug}:{emailHash} |
PLEDGES | Reminder suppression marker | Medium - campaign-scoped email hash |
launch-reminder-sent:{slug}:{emailHash} |
PLEDGES | Reminder send idempotency marker | Low - send state |
launch-reminder-dispatch:{slug} |
PLEDGES | Bounded reminder dispatch job cursor/progress | Low - operational state |
launch-reminder-dispatch-queue:v1 |
PLEDGES | Reminder dispatch queue idle/pending marker | Low - operational state |
abandoned-cart:{orderId} |
PLEDGES | Explicitly opted-in checkout reminder email and campaign snapshot | Medium - campaign-scoped email |
abandoned-cart-resume:{orderId} |
PLEDGES | Short-lived signed-link checkout resume snapshot after a reminder sends | Medium - campaign-scoped email and sanitized cart snapshot |
abandoned-cart-sent:{emailHash}:{campaignSetHash} |
PLEDGES | Checkout reminder send idempotency marker | Low - send state |
abandoned-cart-suppressed:{emailHash} |
PLEDGES | Checkout reminder unsubscribe marker | Medium - supporter email hash |
abandoned-cart-suppressed-campaign:{slug}:{emailHash} |
PLEDGES | Admin-managed campaign-scoped checkout reminder suppression marker | Medium - supporter email hash |
abandoned-cart-queue:v1 |
PLEDGES | Checkout reminder queue idle/pending marker | Low - operational state |
abandoned-cart-health:v1 |
PLEDGES | Aggregate checkout reminder queue/outcome health counters | Low - operational aggregate |
supporter-email-retry:{orderId} |
PLEDGES | Queued supporter confirmation email retry payload | Medium - supporter email payload |
supporter-email-retry-queue:v1 |
PLEDGES | Supporter email retry idle/pending and next-attempt marker | Low - operational state |
email-outbox:v1:{hash} |
PLEDGES | Frozen provider payload and recipient while delivery is pending; 30-day TTL | High - transient email content + PII |
email-delivery:v1:{hash} |
PLEDGES | Minimal provider ID, content hash, category, status, timing; 400-day TTL | Low - delivery evidence |
email-suppression:v1:{emailHash} |
PLEDGES | Hashed permanent-bounce/complaint/provider suppression; 400-day TTL | Medium - consent/deliverability metadata |
campaign-email-suppression:v1:{slug}:{emailHash} |
PLEDGES | Hashed one-click campaign update suppression | Medium - consent metadata |
resend-webhook:v1:{svixId} |
PLEDGES | Signed Resend event dedupe marker; 35-day TTL | Low - idempotency |
add-on-inventory-sold:v1 |
PLEDGES | Platform add-on sold-count projection | Low - aggregate inventory state |
vote:{slug}:{decision}:{email} |
VOTES | Vote choice | Medium - links supporter to vote |
results:{slug}:{decision} |
VOTES | Vote tallies | Low - semi-public |
rl:{endpoint}:{ip} |
RATELIMIT | Request count + reset time | Low - ephemeral |
Scarce limited-tier reservation and committed-count truth is no longer stored in KV. That race-sensitive state now lives in the per-campaign Durable Object coordinator, while KV keeps only the public tier-inventory:{slug} projection.
Settlement serialization is also Durable Object-backed. The SETTLEMENT_COORDINATOR binding owns a short-lived lock per campaign slug so scheduled settlement, direct settlement, dispatch, and batch endpoints cannot charge the same campaign concurrently. Multi-campaign carts still work because checkout persistence creates separate campaign-scoped pledge records, and settlement locks are keyed by the campaign being charged.
Vulnerability Summary
Critical / High Priority
| ID | Issue | Severity | Status |
|---|---|---|---|
| SEC-001 | Dev-token bypass on /votes in production |
High | ✅ Fixed |
| SEC-002 | Stripe webhook fails open if secret not set | High | ✅ Fixed |
| SEC-003 | Test endpoints may be accessible in production | High | ✅ Fixed |
Medium Priority
| ID | Issue | Severity | Status |
|---|---|---|---|
| SEC-004 | CORS Access-Control-Allow-Origin: * on all endpoints |
Medium | ✅ Fixed |
| SEC-005 | No rate limiting on expensive endpoints | Medium | ✅ Fixed |
| SEC-006 | Admin secret not timing-safe compared | Medium | ✅ Fixed |
| SEC-007 | Legacy hosted-cart webhook surface remained reachable | Medium | ✅ Fixed |
Low Priority
| ID | Issue | Severity | Status |
|---|---|---|---|
| SEC-008 | Magic link tokens long-lived (90 days) | Low | Acceptable |
| SEC-009 | Input validation on votes could be stricter | Low | ✅ Fixed |
| SEC-010 | Tokens in query strings (Referer leakage risk) | Low | Acceptable |
| SEC-011 | Input validation on checkout-start payloads | Low | ✅ Fixed |
| SEC-012 | Missing security response headers | Low | ✅ Fixed |
| SEC-013 | Admin dashboard stored input normalization gaps | Low | ✅ Fixed |
Applied Hardening Notes
Ethical Abuse And Misuse Review
The Pool’s highest-impact abuse cases often cross product, security, privacy, and trust boundaries. Run the Ethical Risk review before shipping features that change:
- public discoverability, embeds, social previews, SEO metadata, referral links, or QR codes
- supporter email, reminders, Blast, diary/milestone broadcasts, preview invitations, or report delivery
- checkout totals, tips, taxes, shipping, inventory scarcity, settlement, or pledge modification
- admin roles, campaign scope, protected previews, campaign creation/archive, media upload, or GitHub-backed publishing
- analytics, provider plan usage, exports, backups, restore behavior, or new third-party data flows
Security sign-off should answer the same practical questions each time:
- What data becomes easier to collect, infer, export, or expose?
- Which private/tokenized state could accidentally become indexed, prefetched, shared, or emailed?
- How could a malicious actor use this surface for spam, harassment, fraud, doxxing, payment abuse, or misleading public claims?
- What explicit consent, scoping, rate limiting, audit logging, no-store/noindex behavior, dry-run validation, or recovery path keeps the risk bounded?
Secret Storage Boundaries
Runtime credentials are intentionally separated from editable site configuration:
- Non-secret settings belong in
_config.yml,_config.local.yml, or admin setting drafts. - Local development secrets belong in ignored
worker/.dev.vars; runnpm run secrets:devornpm run setup:deploy -- --mode=localto create/update that file safely. Use separate local-only values, not production backups. - Production Worker credentials belong in Cloudflare Worker secrets through
wrangler secret put. - Deploy credentials such as
CLOUDFLARE_API_TOKEN,CLOUDFLARE_ACCOUNT_ID,CLOUDFLARE_CACHE_PURGE_TOKEN,ADMIN_BROADCAST_SECRET, andDIARY_CHECK_BYPASS_SECRETbelong in GitHub repository secrets only when GitHub Actions or operator scripts need to call those routes. AddADMIN_SETTLEMENT_SECRETthere only when a workflow actually calls settlement endpoints. - The admin plan usage tracker must use
CLOUDFLARE_USAGE_API_TOKENorCLOUDFLARE_ANALYTICS_API_TOKENwith read-only GraphQL Analytics scope, plus Billing Read if Workers plan auto-detection is enabled. Do not reuse the broader Wrangler deploy token for dashboard usage reads. CLOUDFLARE_ACCOUNT_IDis not sensitive by itself, but Settings -> Plan usage still needs it in the Worker runtime environment as a variable or secret. A GitHub repository secret with the same name does not automatically become a deployed Worker binding.- Wrangler deploys require
CLOUDFLARE_API_TOKENto be a Cloudflare user API token created from My Profile -> API Tokens with the Edit Cloudflare Workers template. Account-owned API tokens are not sufficient because Wrangler still calls user-scoped endpoints during deploy. - GitHub repository secrets are not Worker runtime secrets. Scoped admin route enforcement requires the matching
ADMIN_BROADCAST_SECRETorADMIN_SETTLEMENT_SECRETto be present in Cloudflare Worker secrets too. - The admin dashboard may show Configured/Missing status for runtime credentials, but it must not expose, edit, serialize, or publish secret values.
This boundary prevents the admin dashboard from becoming a credential store and keeps forks from accidentally committing Stripe, Resend, USPS, ZIP.TAX, or Cloudflare tokens while still making missing setup visible to operators. See PAYMENT_PROCESSOR.md for Stripe and settlement setup, and EMAIL.md for Resend setup.
Admin Dashboard Input Security Model
The browser admin dashboard has a single server-side normalization boundary before data is written to GitHub-backed YAML or Worker KV. Client-side controls exist for usability only; the Worker remains authoritative.
Admin mutations use these common protections:
- Browser dashboard mutations require a valid admin session cookie and
x-pool-admin-csrfheader. - When
TURNSTILE_SECRET_KEYis configured, admin email sign-in requires a server-verified Cloudflare Turnstile token before rate-limit writes, login nonce writes, or magic-link email sends.ADMIN_TURNSTILE_BYPASS=trueis accepted only in local/test mode or local URLs for automated testing. - Launch reminder signups use the same shared Turnstile verifier with public-reminder-specific env gates.
LAUNCH_REMINDER_TURNSTILE_BYPASS=trueis accepted only in local/test mode or local URLs for automated testing. - Campaign users can mutate only campaigns in their assigned scope; super admins can mutate platform settings and all campaigns.
- GitHub-backed settings are allowlisted through
ADMIN_PLATFORM_SETTING_SCHEMAandADMIN_CAMPAIGN_SETTING_SCHEMA. Unknown paths are rejected, and pseudo UI rows such as the campaign content editor cannot be mass-assigned through settings publishing. - Admin media uploads are scoped server-side by upload kind. Campaign media uploads require a valid campaign slug plus
campaign:edit_content; platform/default media uploads require the super-adminsettings:publishpath. The Worker validates file type, size, destination directory, and filename before committing an asset path. - Publish-time media cleanup is derived server-side from the previously loaded campaign data and the normalized campaign draft being committed. It only deletes safe root-relative dashboard-owned files under the same campaign’s
assets/images,assets/videos, orassets/audiodirectories, and it preserves external URLs, shared/default assets, and files still referenced elsewhere in the campaign. - Runtime-only admin users are saved only to KV at
admin-users:v1; they are not serialized into_config.yml. - Admin dashboard tab/subtab restoration stores only browser-local UI identifiers for the last allowed workspace. It is not sent to the Worker, does not write KV or GitHub state, and role/campaign authorization still controls what can be restored after sign-in.
- Marketing referral codes are saved only on explicit user action and are scoped to the campaign URL origin/path the admin account can access.
- Shared Marketing/Blast drafts are saved only on explicit user action, scoped to one campaign and surface, expire after 7 days, and use revision tokens so stale saves do not overwrite another admin’s work.
- Analytics attribution reporting and abandoned-checkout health use campaign pledge indexes or aggregate health state instead of KV namespace scans; reminder health responses expose counters and recent outcomes, not reminder recipient lists.
- Campaign-scoped abandoned-checkout suppression controls require CSRF, store hashed email identifiers, and do not expose a retry-this-specific-cart action.
- Campaigns -> Blast sends are scoped to campaigns the admin account can edit. Blast dry runs require the campaign pledge index and add no KV writes or list operations; live sends require a matching dry-run hash and write one audit event after dispatch.
- New campaign creation is super-admin-only, writes a preview-only campaign Markdown file locally in dev or through the existing GitHub path in production, and keeps that campaign out of public route generation until launched. Creating new campaign users during that flow saves to
admin-users:v1and emails assigned users through the shared admin email path when users are assigned. - Protected preview publication is scoped to super admins and assigned campaign users. It commits only preview flags to campaign Markdown, stores the publishing admin plus optional reviewer emails in a short-lived
campaign-preview-reviewers:{slug}KV allowlist, returns a signed 24-hour dashboard link for the publishing admin, sends signed 24-hour reviewer links when optional reviewers are added, and records an audit event. Previewer emails must not be persisted in GitHub-backed campaign source, public campaign JSON, sitemap output, or generated metadata. - Campaign archiving is super-admin-only and unavailable for currently live campaigns. The Worker validates the CSRF token, role, slug, campaign existence, and effective state before moving files locally in dev or dispatching
.github/workflows/archive-campaign.ymlin production. Both archive paths validate the slug, move campaign source and campaign-owned media intoarchive/campaigns/<slug>/, skip media still referenced by other active campaigns, and write anarchive-manifest.json. - The static admin shell uses a restrictive meta CSP with no inline scripts, limited Worker/API connections, and sandboxed preview iframes that receive only Worker-rendered preview HTML. Preview iframes allow scripts but intentionally do not use
allow-same-origin, avoiding the browser warning and escape risk that comes from combining both sandbox tokens. Admin editing and protected-preview surfaces render remote YouTube/Vimeo media as facades instead of loading live players on page load; the CSP allows static YouTube thumbnail images for those facades but still does not load YouTube player scripts in editor previews. Public campaign pages and copied public embeds can still render approved players. Framing protection must be delivered as an HTTP header, such asContent-Security-Policy: frame-ancestors 'none'orX-Frame-Options: DENY; browsers ignoreframe-ancestorsinside meta CSP. - Admin magic-link emails use internally generated login URLs and strip email-header control characters from admin-configurable sender/subject values before sending.
Protected Campaign Preview Boundary
Protected previews are private review surfaces for editable campaigns, not public campaign pages.
- Static preview shells live under
/campaigns/:slug/preview/and localized equivalents for every campaign slug so preview links do not race a static-site rebuild. They usenoindex,nofollow,noarchive, strict-origin referrer behavior for embedded media compatibility, no public social metadata, and no public JSON-LD. - The shell is generic and does not embed campaign title, payload data, or preview access data at build time. It fetches a no-store full campaign page preview payload from
/admin/campaign-preview/:slug, with pledge controls rendered read-only. - Authenticated admins can fetch the payload only through the existing admin session, CSRF/origin protections where applicable, and role/campaign scope checks.
- Explicit reviewers use signed
ttokens scoped to token type, campaign slug, reviewer email, and expiry. The Worker also checks the email against the 24-hour KV allowlist before returning a preview payload. - Preview publish requests carry a GitHub base revision when available. Stale publishes return a conflict instead of overwriting another user’s changes.
- Public campaign filters treat preview-only/unlaunched campaigns as invisible for public pages, localized routes,
/api/campaigns.json, add-on catalogs, share cards, sitemap output, robots crawl intent, embeds, and public prefetch eligibility.
Public Prefetch And Share-Link Boundaries
The public intent-prefetch runtime is deliberately narrow so speculative navigation cannot turn private flows into background traffic.
- Prefetching is loaded only on public page layouts.
- Eligible URLs must be same-origin public document routes from the allowlist.
- Admin, checkout, Manage Pledge, pledge-result, supporter-community, campaign preview, API, Worker, tokenized, and sensitive-query routes are rejected.
- The runtime respects explicit
data-no-prefetch,download,target,nofollow, save-data, slow-network, and per-page limit guards.
Campaign share links follow the same privacy boundary. The client preserves only safe UTM/referral query params for public campaign URLs, leaves token/order/email/session params behind, and lets Open Graph metadata supply preview images instead of serializing image URLs into share intents.
Launch reminder forms are public but bounded: signups require explicit consent, are rate-limited by IP, write one deduped campaign/email-hash record, and can be reactivated only by another explicit signup. Reminder dispatch checks suppression and sent markers immediately before email delivery.
Admin field classes are normalized consistently:
- Plain text strips control characters, enforces length limits, and rejects raw HTML.
- Inline rich text allows Markdown plus a small HTML subset (
<br>,<em>,<strong>,<i>,<b>,<u>), rejects scripts, iframes, inline event handlers, inline styles, unsafe Markdown links, and parent-relative links such as../admin. - URLs and media references must be safe root-relative paths or absolute
http/httpsURLs. Canonical site/Worker URLs and external API bases must be absolutehttp/httpsURLs. Embedded credentials, unsafe schemes such asjavascript:anddata:, path traversal, literal whitespace, and raw markup characters are rejected. - CSS design inputs are narrowed to hex colors, simple font stacks, and simple length tokens so settings cannot smuggle CSS declarations or
url(...)values. - Numbers, booleans, enums, IDs, slugs, dates, shipping dimensions, and package weights are parsed into canonical types with per-field bounds.
- Structured collections such as tiers, add-ons, diary entries, decisions, and content blocks are normalized item-by-item instead of trusting raw JSON from the browser.
SQL injection is not a primary threat for the current Worker because the runtime does not use SQL. The relevant injection classes are stored XSS, YAML/front-matter injection, KV key/path manipulation, URL/CSS injection, and privilege escalation through mass assignment; the admin normalizers are designed around those risks.
SEC-001: Lock Down Dev-Token Bypass (✅ FIXED)
File: worker/src/routes/votes.js
Historical vulnerable pattern:
if (token.startsWith('dev-token-')) {
campaignSlug = token.replace('dev-token-', '');
orderId = 'dev-order-1';
}
Fixed:
if (token.startsWith('dev-token-')) {
if (env.APP_MODE !== 'test') {
return jsonResponse({ error: 'Invalid token' }, 401);
}
campaignSlug = token.replace('dev-token-', '');
orderId = 'dev-order-1';
email = '[email protected]';
}
Note: Votes are keyed by email (not orderId) to prevent supporters with multiple pledges from voting multiple times. The Worker also resolves campaign decisions server-side, rejects unknown/closed decisions, and only accepts option values from the campaign’s published allowlist.
Campaign-authored titles, descriptions, and support labels are also escaped by default in supporter-facing cart, manage, and community surfaces so forks with creator-editable content do not inherit a stored-XSS footgun by default. Long-form campaign and diary blocks now accept Markdown plus a very small inline HTML subset (<br>, <em>, <strong>, <i>, <b>, <u>); other raw tags are escaped at render time and rejected by the content audit. Markdown links are rewritten unless they use an allowlisted destination scheme (http:, https:, mailto:, or internal links), and structured embeds must use exact approved https:// provider URLs instead of passing a substring check.
Community pages no longer persist the raw supporter bearer token in a long-lived cookie; the token now stays in browser session storage while a non-sensitive verification cookie handles lightweight UX state.
Limited-tier inventory mutations now flow through a per-campaign Durable Object coordinator from checkout start onward. Scarce tiers are reserved before redirecting into Stripe, confirmed at successful persistence time, and only projected back into KV for public reads. That keeps race-sensitive inventory truth out of client-visible KV while preserving efficient public /inventory/:slug reads.
The newer on-site Stripe checkout and Update Card flows now also fail more privately by default: Worker responses that carry Stripe session bootstrap data or order-specific completion state are served with Cache-Control: private, no-store, cross-site browser POSTs to checkout-start / checkout-complete / payment-method-start are rejected unless they originate from SITE_BASE, and the browser keeps only short-lived in-flight checkout markers for reservation recovery instead of leaving them in long-lived storage indefinitely. Long-lived cart persistence now keeps only cart structure and pricing inputs; contact and address drafts are downgraded to session-scoped storage, and /checkout-intent/complete has its own retry budget so local recovery can’t be spammed indefinitely. After successful pledge persistence, the checkout flow now also invalidates live stats/inventory caches immediately and leaves a short-lived refresh marker so restored campaign pages do not keep showing stale totals from pre-pledge browser state.
Release cache evidence is centralized in config/performance-budgets.json and runs with npm run test:cache-policy. It verifies that public pages/assets meet minimum cache lifetimes while admin/session targets remain private, no-store; a performance change that weakens a private route is a release failure. Workers Cache remains disabled unless representative evidence clears the configured p95 improvement threshold.
Add-on pricing is bounded at the same $1,000,000 ceiling as canonical checkout amounts. Admin product and variant normalization rejects larger values before GitHub publish, Worker catalog resolution rechecks the resulting cents, and an out-of-range saved historical unitPrice is never trusted as a price-preservation override.
Release dependency review runs both npm audit --omit=dev --audit-level=moderate and the full npm audit --audit-level=moderate. Production findings are blockers. Dev-only findings in build or release tooling must be removed, pinned to a clean supported version, or explicitly accepted with scope and rationale. Pool pins Lighthouse 12.6.1 because the later transitive Sentry/OpenTelemetry chain carried a moderate allocation advisory while this compatible release audits cleanly.
Abandoned-checkout reminders are opt-in only. The browser sends abandonedCartConsent only when the supporter checks the reminder box, and the Worker queues a reminder only after Stripe creates a valid first-party Checkout Session. Reminder records are short-lived, use signed unsubscribe links, delete on successful pledge persistence for that order, and check campaign pledge indexes before sending so a later completed pledge suppresses stale abandoned-checkout email. After a reminder sends, the Worker stores a separate short-lived abandoned-cart-resume:{orderId} snapshot for signed resume links; that snapshot contains only the sanitized cart/contact fields needed to rebuild a fresh checkout session and never puts Stripe secrets in the URL.
SEC-002: Do Not Process Missing Stripe Webhook Secret (✅ FIXED)
File: worker/src/index.js (handleStripeWebhook)
Historical vulnerable pattern:
const webhookSecret = getStripeWebhookSecret(env);
if (webhookSecret) {
// Only verifies if secret exists
}
Fixed:
const webhookSecret = getStripeWebhookSecret(env);
if (!webhookSecret) {
console.warn('Stripe webhook secret not configured for this mode, acknowledging receipt');
return jsonResponse({ received: true, skipped: 'webhook secret not configured' }, 200);
}
const { valid, error } = await verifyStripeSignature(body, sig, webhookSecret);
if (!valid) {
return jsonResponse({ error: 'Invalid signature' }, 401);
}
The Worker acknowledges missing-secret webhooks to avoid infinite Stripe retries for the wrong mode, but it does not parse or apply the event. Production readiness should still treat a missing live webhook secret as a deployment defect.
SEC-003: Guard Test Endpoints (✅ FIXED)
File: worker/src/index.js (router)
The Worker now blocks test endpoints outside APP_MODE === 'test' before those handlers run:
// Block test endpoints in production
if (path.startsWith('/test/') && env.APP_MODE !== 'test') {
return jsonResponse({ error: 'Not found' }, 404);
}
Each handler also verifies the environment as defense in depth:
async function handleTestSetup(request, env) {
if (env.APP_MODE !== 'test') {
return jsonResponse({ error: 'Not found' }, 404);
}
// ...
}
SEC-004: Restrict CORS Origins (✅ FIXED)
File: worker/src/index.js
CORS is now restricted based on endpoint type:
- Public endpoints (
/stats/*,/inventory/*): Allow* - Protected endpoints: Use a normalized
env.CORS_ALLOWED_ORIGIN, normalizedenv.SITE_BASE, or the canonical production site origin
function getAllowedOrigin(env, isPublic = false) {
if (isPublic) return '*';
return normalizeOrigin(env.CORS_ALLOWED_ORIGIN) ||
normalizeOrigin(env.SITE_BASE) ||
'https://site.example.com';
}
// Public endpoints pass isPublic=true:
return jsonResponse(data, 200, env, true);
// Protected endpoints use default:
return jsonResponse(data, 200, env);
SEC-005: Rate Limiting (✅ FIXED)
File: worker/src/index.js
In-Worker rate limiting is now implemented using KV storage with per-IP tracking.
Write-Path Rate Limits:
| Endpoint | Limit | Window | Notes |
|---|---|---|---|
/checkout-intent/start |
40 requests | 1 minute | Checkout starts; tuned higher so shared NATs and legitimate spikes still fit |
/shipping/quote |
90 requests | 1 minute | Shipping quote refreshes stay roomy during cart edits |
/checkout-intent/complete |
12 requests | 1 minute | Keyed by orderId instead of only IP to avoid punishing real retries |
/checkout-intent/abandon |
12 requests | 1 minute | Keyed by orderId so reservation cleanup retries stay friendly to shared IPs |
/pledge + /pledges |
120 requests | 1 minute | Manage-pledge reads stay generous because they are user-facing reads |
/pledge/cancel, /pledge/modify, /pledge/payment-method/start |
30 requests | 1 minute | Manage-pledge writes |
/votes |
45 requests | 1 minute | Voting endpoints |
/admin/* |
5 requests | 1 minute | Admin operations |
How It Works:
- Rate limits are tracked per IP address using
CF-Connecting-IPheader - Each IP gets its own bucket, so 100 different users won’t interfere with each other
- Public read endpoints like
/live/:slug,/stats/:slug, and/inventory/:slugstay uncapped so a legitimately viral campaign does not trip a DoS defense just for being popular - The checkout and Manage Pledge write paths keep higher ceilings than a typical brute-force limit so shared NAT environments still have breathing room
/checkout-intent/completeis keyed byorderId, which is friendlier to legitimate recovery retries than a pure per-IP bucket/checkout-intent/abandonis also keyed byorderId, so cleanup/release retries do not punish supporters behind the same NAT during a busy launch- Stripe webhooks are protected with signature verification, idempotency, and a request-body size cap instead of a tight per-IP limit that could interfere with normal Stripe delivery
- Once a client is already over limit for the current window, repeated blocked requests fail closed without rewriting the same KV counter on every hit. That keeps abuse pressure from turning into unnecessary free-plan KV writes.
- Expensive POST routes now also reject obviously oversized request bodies before parsing JSON or touching Stripe/KV-heavy flows.
- Deployed Standard/Paid Workers now also declare
limits.cpu_ms = 100inwrangler.toml. That is a denial-of-wallet guardrail, not a claim that normal requests are anywhere near that expensive. - Admin-only observability endpoints now expose webhook delivery summaries and sampled mutation timings so operators can tune DoS defenses without relying only on raw log tails.
Setup:
- Create the KV namespace:
wrangler kv:namespace create "RATELIMIT" wrangler kv:namespace create "RATELIMIT" --preview - Add to
wrangler.toml(both production and dev sections):# Production [[kv_namespaces]] binding = "RATELIMIT" id = "YOUR_RATELIMIT_KV_ID" preview_id = "YOUR_RATELIMIT_PREVIEW_ID" # Development (in [env.dev] section) [[env.dev.kv_namespaces]] binding = "RATELIMIT" id = "YOUR_RATELIMIT_KV_ID" preview_id = "YOUR_RATELIMIT_PREVIEW_ID"
Note: RATELIMIT is now a hard requirement. If the binding is missing, the Worker fails closed with 503 instead of serving traffic without abuse protection. That change increases the importance of having real KV headroom, but it does not mean the Workers Free plan is suddenly incompatible with the project’s intended low-to-moderate crowdfunding scale.
CPU cap note: Cloudflare’s configurable limits block is only enforced on the Standard Usage Model and only on deployed Workers, not in local development. The current cpu_ms = 100 value was chosen as a conservative backstop after representative unit-harness requests landed around 6 ms, 15 ms, and 28 ms wall-clock time for admin light, checkout recovery, and checkout abandon flows respectively. That is only a proxy measurement, but it is enough to justify a low ceiling with headroom instead of leaving the paid default at 30 seconds.
Observability note: Use GET /admin/observability/webhooks to inspect webhook volume, duplicate deliveries, signature failures, and recent outcomes, and GET /admin/observability/performance to inspect sampled wall-clock timings for the key mutation routes. The helper script scripts/check-observability.sh wraps both endpoints for local or deployed checks.
Response when rate limited:
{
"error": "Too many requests",
"retryAfter": 45
}
Status: 429 Too Many Requests with headers:
Retry-After: Seconds until limit resetsX-RateLimit-Limit: Maximum requests allowedX-RateLimit-Remaining: Requests remaining in windowX-RateLimit-Reset: Unix timestamp when window resets
Local Testing:
Restart the Worker to reset rate limit counters (local KV is simulated and resets on restart):
lsof -ti:8787 | xargs kill -9
cd worker && npx wrangler dev --port 8787
SEC-006: Timing-Safe Admin Secret Comparison (✅ FIXED)
File: worker/src/index.js
The Worker now uses a timing-safe comparison helper for admin secrets:
function timingSafeEqual(a, b) {
if (!a || !b || a.length !== b.length) return false;
let result = 0;
for (let i = 0; i < a.length; i++) {
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return result === 0;
}
function requireAdmin(request, env, scope = 'default') {
const authHeader = request.headers.get('Authorization') || '';
const provided = authHeader.startsWith('Bearer ')
? authHeader.slice('Bearer '.length)
: request.headers.get('x-admin-key') || '';
const credential = getAdminSecretForScope(env, scope);
if (!credential) {
console.error('admin secret not configured');
return { ok: false, status: 500, error: 'Admin not configured' };
}
if (!timingSafeEqual(provided, credential.secret)) {
return { ok: false, status: 401, error: 'Unauthorized' };
}
return { ok: true };
}
Accepted Tradeoffs / Follow-Up Candidates
These are the currently known items that are not treated as active vulnerabilities requiring immediate code changes:
SEC-008: Magic Link Tokens Long-Lived (90 days)
Status: Accepted tradeoff
Why it remains:
- magic links are intentionally accountless and need to stay usable across longer campaign timelines
- each token is scoped to a specific order/campaign path rather than granting broad account access
If this ever changes, the likely follow-up would be shortening token lifetime and pairing it with easier reissue/recovery UX.
SEC-010: Tokens in Query Strings (Referer Leakage Risk)
Status: Accepted tradeoff
Why it remains:
- magic-link entry currently depends on emailed URLs with query parameters
- the platform already limits referrer leakage with stricter response headers and scoped access behavior
If this becomes a higher-priority concern, the likely follow-up would be a one-time token exchange flow that strips the raw token from the visible URL after first load.
SEC-007: Remove Legacy Hosted-Cart Webhook Surface (✅ FIXED)
File: worker/src/index.js
The Worker no longer exposes the removed third-party checkout webhook path at all, which eliminates an unnecessary callback surface the live flow no longer needs.
SEC-009: Stricter Input Validation on Votes (✅ FIXED)
File: worker/src/routes/votes.js, worker/src/validation.js
Voting endpoints now validate:
- Decision IDs: max 100 chars, alphanumeric + hyphens only
- Vote options: max 50 chars
- Max 20 decision IDs per request
// Validation rules
const MAX_VOTE_OPTION_LENGTH = 50;
const MAX_DECISION_ID_LENGTH = 100;
const VALID_SLUG_REGEX = /^[a-z0-9-]+$/;
// Validated before processing
if (!isValidDecisionId(decisionId)) {
return jsonResponse({ error: 'Invalid decision ID format' }, 400, env);
}
if (!isValidVoteOption(option)) {
return jsonResponse({ error: 'Invalid vote option format' }, 400, env);
}
SEC-011: Input Validation on Checkout Start (✅ FIXED)
File: worker/src/index.js, worker/src/validation.js
The /checkout-intent/start path now validates:
- Campaign slugs: max 100 chars, alphanumeric + hyphens only (prevents injection/traversal)
- Email addresses: RFC-compliant format, max 254 chars
- Cart item IDs and quantities
- Support/custom amount inputs through canonical contribution rebuilding
if (!isValidSlug(campaignSlug)) {
return jsonResponse({ error: 'Invalid campaign slug format' }, 400);
}
if (email && !isValidEmail(email)) {
return jsonResponse({ error: 'Invalid email format' }, 400);
}
if (!parsedCart.valid) {
return jsonResponse({ error: parsedCart.error }, 400);
}
SEC-012: Security Response Headers (✅ FIXED)
File: worker/src/validation.js
All API responses now include security headers:
const SECURITY_HEADERS = {
'X-Content-Type-Options': 'nosniff', // Prevents MIME-type sniffing
'X-Frame-Options': 'DENY', // Prevents clickjacking
'X-XSS-Protection': '1; mode=block', // Legacy XSS protection
'Referrer-Policy': 'strict-origin-when-cross-origin' // Limits referer leakage
};
SEC-013: Admin Dashboard Stored Input Normalization (✅ FIXED)
File: worker/src/index.js
The admin dashboard now validates every GitHub-backed and KV-backed dashboard write through shared admin normalization helpers before persistence.
Covered write paths:
/admin/settings/previewand/admin/settings/publish/admin/content/previewand/admin/content/publish/admin/settings/logo-upload,/admin/settings/image-upload,/admin/settings/audio-upload, and/admin/settings/video-upload/admin/users/admin/campaigns/create,/admin/campaigns/archive, and/admin/campaign-preview/publish/admin/marketing/referrals/admin/marketing/announcement
The hardening rejects stored-XSS primitives such as raw <script>, event-handler attributes, unsafe Markdown links, parent-relative Markdown links, javascript:/data: URLs, CSS function/declaration injection, and unsafe asset paths. It also rejects settings mass assignment for dashboard-only rows and normalizes structured arrays for platform add-ons, campaign add-ons, tiers, support items, diary entries, stretch goals, ongoing items, and decisions. Media uploads are role-scoped, content-type allowlisted, size-limited, and written only to canonical dashboard asset directories. Blast email rendering includes only site-hosted image paths and email-safe video links rather than arbitrary remote image hotlinks or iframe embeds.
The browser dashboard also has defense-in-depth hardening around the editing shell: the admin page meta CSP avoids inline scripts, limits Worker/API connections, and keeps content previews in sandboxed iframes. Deployments should add framing protection through HTTP headers, such as Content-Security-Policy: frame-ancestors 'none' or X-Frame-Options: DENY, because meta CSP cannot enforce that directive. Magic-link email payloads strip CRLF/control characters from configurable header values before calling Resend so platform names or sender settings cannot create header-injection payloads.
Secrets Checklist
Before deploying to production, verify these secrets are set:
Payment-specific setup is documented in PAYMENT_PROCESSOR.md. Email-specific setup is documented in EMAIL.md.
| Secret | Environment Variable | Min Length |
|---|---|---|
| Stripe API Key | STRIPE_SECRET_KEY_LIVE |
N/A |
| Stripe Webhook Secret | STRIPE_WEBHOOK_SECRET_LIVE |
32+ chars |
| Checkout Intent Secret | CHECKOUT_INTENT_SECRET |
32+ chars |
| Magic Link Secret | MAGIC_LINK_SECRET |
32+ chars |
| Launch Reminder Token Secret | LAUNCH_REMINDER_TOKEN_SECRET or MAGIC_LINK_SECRET fallback |
32+ chars |
| Abandoned Checkout Token Secret | ABANDONED_CART_TOKEN_SECRET or MAGIC_LINK_SECRET fallback for reminder unsubscribe/resume links |
32+ chars |
| Admin Session Secret | ADMIN_SESSION_SECRET |
32+ chars |
| Admin Secret | ADMIN_SECRET |
32+ chars |
| Settlement Admin Secret | ADMIN_SETTLEMENT_SECRET (optional, scoped) |
32+ chars |
| Broadcast Admin Secret | ADMIN_BROADCAST_SECRET (optional, scoped) |
32+ chars |
| Turnstile Secret | TURNSTILE_SECRET_KEY, ADMIN_TURNSTILE_SECRET_KEY, or LAUNCH_REMINDER_TURNSTILE_SECRET_KEY |
N/A |
| Resend API Key | RESEND_API_KEY |
N/A |
| Cloudflare Usage Analytics Token | CLOUDFLARE_USAGE_API_TOKEN or CLOUDFLARE_ANALYTICS_API_TOKEN |
GraphQL Analytics Read; optional Billing Read for plan detection |
When GitHub Actions or an operator script calls protected admin endpoints, add only the needed matching secret to GitHub repository secrets. The default deploy workflow uses ADMIN_BROADCAST_SECRET for the post-deploy diary check when it is configured; future settlement automation should use ADMIN_SETTLEMENT_SECRET rather than the broader fallback secret.
Generate secure secrets:
openssl rand -base64 32
Penetration Testing
See tests/security/README.md for the pen test suite.
For product-abuse review, pair the security suite with the Ethical Risk checklist. Red-team at least one malicious or careless-operator scenario for any feature that can send messages, change money, expose data, publish public content, or alter admin permissions.
Run security tests:
npm run test:secrets # Audit local secret exposure in files + history
npm run test:security # Against local Worker
npm run test:security:staging # Against a staging worker, if you maintain one
npm audit --omit=dev --audit-level=moderate
npm audit --audit-level=moderate
npm run test:premerge now includes the secret audit automatically, so local merge gating checks both security behavior and accidental credential exposure.
For local runs, keep CHECKOUT_INTENT_SECRET configured if you want the live-worker checkout-start suite to exercise the real first-party signing path.
Incident Response
Token Compromise
If a magic link token is compromised:
- The token is tied to a specific orderId/email/campaign
- It can only access/modify that one authorized order
- To invalidate: delete the pledge from KV (
GET /pledgewill then return404for that token) - Optionally: regenerate MAGIC_LINK_SECRET (invalidates ALL tokens)
Admin Session Or Secret Compromise
- Immediately rotate
ADMIN_SESSION_SECRETandADMIN_SECRETviawrangler secret put - Clear active
admin-session:*keys from the Worker KV namespace - Review
admin-audit:*events and GitHub commits for unauthorized admin actions - Re-check campaign stats, pledge data, settings, and admin user scopes
Stripe Webhook Secret Compromise
- Rotate the webhook secret in Stripe Dashboard → Webhooks
- Update
STRIPE_WEBHOOK_SECRET_*in Worker - Check for any suspicious pledges created during exposure window
Missed Stripe Webhook (Development)
If the on-site payment step completes but the pledge doesn’t appear yet (common in local dev when webhook forwarding is delayed or broken):
- Check Stripe CLI output for webhook delivery status
- The client will first try
/checkout-intent/completeautomatically for local recovery, but if the pledge still does not appear, use the admin recovery endpoint to manually create it:curl -X POST http://localhost:8787/admin/recover-checkout \ -H 'Authorization: Bearer YOUR_ADMIN_SECRET' \ -H 'Content-Type: application/json' \ -d '{"sessionId": "cs_test_..."}' - The endpoint fetches the checkout session from Stripe and creates the pledge if it doesn’t exist
See PAYMENT_PROCESSOR.md for the fuller webhook recovery and reconciliation runbook.
Prevention:
- Use
scripts/dev.shwhich runs the Worker with local KV simulation scripts/dev.shstarts a single Stripe listener, forwards events to127.0.0.1:8787/webhooks/stripe, writes that same listener’swhsec_...secret intoworker/.dev.vars, and clears stale local processes on the standard dev ports before startup- If you start Stripe manually, use the same listener instance for forwarding and for the secret you copy into local config
./scripts/dev.sh --podmanis the easiest way to keep the local site/Worker boundary production-like without relying on host Ruby/Wrangler setup- For testing with seeded data, run
./scripts/seed-all-campaigns.shafter starting the worker
Security Contacts
- Stripe Security: stripe.com/docs/security
- Cloudflare Status: cloudflarestatus.com