diff --git a/.env.example b/.env.example index 60fc8d7..dfd3426 100644 --- a/.env.example +++ b/.env.example @@ -6,3 +6,7 @@ CONTACT_TO_EMAIL=you@example.com # Contact form sender (optional - defaults to onboarding@resend.dev) # CONTACT_FROM_EMAIL=contact@yourdomain.com + +# Cloudflare Turnstile (https://dash.cloudflare.com -> Turnstile) +# Leave both unset to disable the widget entirely; the form still works. +TURNSTILE_SECRET_KEY=0x4AAAAAAAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx diff --git a/src/app.d.ts b/src/app.d.ts index 4a8431f..e667aae 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -5,6 +5,8 @@ declare global { // Workers rate limiting binding. Absent locally and in tests, where the // in-memory limiter in lib/server/contact.ts is used instead. CONTACT_RATE_LIMIT?: { limit(options: { key: string }): Promise<{ success: boolean }> }; + // Public Turnstile site key, set as a var in wrangler.jsonc. + PUBLIC_TURNSTILE_SITE_KEY?: string; }; } } diff --git a/src/lib/server/contact.test.ts b/src/lib/server/contact.test.ts index d8facbc..5185e10 100644 --- a/src/lib/server/contact.test.ts +++ b/src/lib/server/contact.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { checkRateLimit, + verifyTurnstile, consumeRateLimit, sendContactEmail, validateContactSubmission, @@ -104,3 +105,62 @@ describe('rate limiting on Workers', () => { expect((await checkRateLimit(client, limiter, 1_000)).allowed).toBe(false); }); }); + +describe('turnstile verification', () => { + const ok = async () => new Response(JSON.stringify({ success: true }), { status: 200 }); + + it('accepts a token the siteverify endpoint approves', async () => { + let sent: URLSearchParams | undefined; + const fetcher = async (_u: string | URL | Request, init?: RequestInit) => { + sent = new URLSearchParams(String(init?.body)); + return ok(); + }; + const r = await verifyTurnstile('tok-123', 'sec', '1.2.3.4', fetcher as typeof fetch); + expect(r.ok).toBe(true); + expect(sent?.get('response')).toBe('tok-123'); + expect(sent?.get('secret')).toBe('sec'); + expect(sent?.get('remoteip')).toBe('1.2.3.4'); + }); + + it('rejects a token the endpoint refuses, and surfaces the reason', async () => { + const fetcher = async () => + new Response(JSON.stringify({ success: false, 'error-codes': ['invalid-input-response'] }), { + status: 200, + }); + const r = await verifyTurnstile('bad', 'sec', undefined, fetcher as typeof fetch); + expect(r.ok).toBe(false); + expect(r.reason).toContain('invalid-input-response'); + }); + + it('rejects an empty token without calling the network', async () => { + let called = false; + const fetcher = async () => { + called = true; + return ok(); + }; + const r = await verifyTurnstile('', 'sec', undefined, fetcher as typeof fetch); + expect(r.ok).toBe(false); + expect(called).toBe(false); + }); + + it('skips verification when no secret is configured', async () => { + let called = false; + const fetcher = async () => { + called = true; + return ok(); + }; + const r = await verifyTurnstile('', '', undefined, fetcher as typeof fetch); + expect(r.ok).toBe(true); + expect(called).toBe(false); + }); + + it('lets a submission through if siteverify itself is unreachable', async () => { + const fetcher = async () => { + throw new Error('network down'); + }; + // A Cloudflare outage should not silently kill the contact form; the rate + // limiter is still in front of this. + const r = await verifyTurnstile('tok', 'sec', undefined, fetcher as typeof fetch); + expect(r.ok).toBe(true); + }); +}); diff --git a/src/lib/server/contact.ts b/src/lib/server/contact.ts index 2ef2003..7812518 100644 --- a/src/lib/server/contact.ts +++ b/src/lib/server/contact.ts @@ -1,7 +1,40 @@ const RESEND_API_URL = 'https://api.resend.com/emails'; +const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; const WINDOW_MS = 10 * 60 * 1000; const MAX_REQUESTS = 5; +/** + * Check a Turnstile token with Cloudflare. + * + * Two deliberate escape hatches. With no secret configured this returns ok, so + * local development and tests are not blocked by a widget that isn't set up. + * And if siteverify itself cannot be reached we let the submission through + * rather than let a Cloudflare outage silently kill the contact form; the rate + * limiter still sits in front of this. + */ +export async function verifyTurnstile( + token: string, + secret: string, + remoteip: string | undefined, + fetcher: typeof fetch, +): Promise<{ ok: boolean; reason?: string }> { + if (!secret) return { ok: true }; + if (!token) return { ok: false, reason: 'missing turnstile token' }; + + const body = new URLSearchParams({ secret, response: token }); + if (remoteip) body.set('remoteip', remoteip); + + try { + const res = await fetcher(TURNSTILE_VERIFY_URL, { method: 'POST', body }); + const data = (await res.json()) as { success?: boolean; 'error-codes'?: string[] }; + if (data.success) return { ok: true }; + return { ok: false, reason: (data['error-codes'] ?? ['verification failed']).join(', ') }; + } catch (error) { + console.error('Turnstile siteverify unreachable, allowing submission', error); + return { ok: true }; + } +} + export interface ContactSubmission { name: string; email: string; diff --git a/src/routes/contact/+page.server.ts b/src/routes/contact/+page.server.ts index 8ba2fd2..72964cb 100644 --- a/src/routes/contact/+page.server.ts +++ b/src/routes/contact/+page.server.ts @@ -1,12 +1,26 @@ import { env } from '$env/dynamic/private'; import { fail } from '@sveltejs/kit'; import type { Actions } from './$types'; -import { checkRateLimit, sendContactEmail, validateContactSubmission } from '$lib/server/contact'; +import { + checkRateLimit, + sendContactEmail, + validateContactSubmission, + verifyTurnstile, +} from '$lib/server/contact'; // Form actions need a live handler, so this route opts out of the site-wide // prerender set in +layout.ts. export const prerender = false; +// The site key is public and only needed so the widget can render. Read at +// runtime from the Worker binding rather than $env/static/public, because a +// static import is baked in at build time and the CI builder has no .env.local. +// Absent (plain vite dev) the widget simply does not render, and verifyTurnstile +// skips too, so the form still works locally. +export const load = ({ platform }) => ({ + turnstileSiteKey: platform?.env?.PUBLIC_TURNSTILE_SITE_KEY ?? '', +}); + export const actions = { default: async ({ request, getClientAddress, fetch, platform }) => { const form = await request.formData(); @@ -23,6 +37,20 @@ export const actions = { // Some local adapters do not expose a client address. } + const turnstile = await verifyTurnstile( + String(form.get('cf-turnstile-response') ?? ''), + env.TURNSTILE_SECRET_KEY ?? '', + clientId === 'unknown' ? undefined : clientId, + fetch, + ); + if (!turnstile.ok) { + console.warn('Turnstile rejected a submission:', turnstile.reason); + return fail(400, { + success: false, + message: 'Could not verify you are human. Please try again.', + }); + } + const rateLimit = await checkRateLimit(clientId, platform?.env?.CONTACT_RATE_LIMIT); if (!rateLimit.allowed) { return fail(429, { diff --git a/src/routes/contact/+page.svelte b/src/routes/contact/+page.svelte index 5d95993..ae26357 100644 --- a/src/routes/contact/+page.svelte +++ b/src/routes/contact/+page.svelte @@ -5,7 +5,7 @@ import SiteNav from '$lib/components/SiteNav.svelte'; import { siteNav, socials } from '$lib/home-data'; - let { form }: PageProps = $props(); + let { form, data }: PageProps = $props(); let sending = $state(false); @@ -16,6 +16,9 @@ content="Get in touch with CJ Dyas about design work, collaboration, or roles." /> + {#if data.turnstileSiteKey} + + {/if}
@@ -81,6 +84,9 @@ rows="6" placeholder="A few details about what you have in mind"> + {#if data.turnstileSiteKey} +
+ {/if}
+// Turnstile loads from an external CDN and logs its own noise to console.error, +// and it will not issue a token to an automated browser anyway, which is rather +// the point of it. Stub it so the assertion below can stay strict and still +// catch errors from our own code. +async function stubThirdParty(page: Page) { + await page.route(/challenges\.cloudflare\.com/, (route) => route.fulfill({ status: 200, contentType: 'application/javascript', body: '' }), ); } @@ -33,7 +34,7 @@ function watchErrors(page: Page) { } test('every nav destination is reachable by clicking, from every page', async ({ page }) => { - await stubAnalytics(page); + await stubThirdParty(page); const errors = watchErrors(page); const starts = [ '/', diff --git a/wrangler.jsonc b/wrangler.jsonc index d73e263..499d600 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -6,6 +6,11 @@ // Worker throws at runtime rather than at build time. "compatibility_flags": ["nodejs_compat"], "main": ".svelte-kit/cloudflare/_worker.js", + // Turnstile site keys are public by design; only the secret is a secret, and + // that lives in `wrangler secret`. Kept here so the CI builder has it too. + "vars": { + "PUBLIC_TURNSTILE_SITE_KEY": "0x4AAAAAAEDA2TbnR8IMZEeU", + }, // Keep the workers.dev hostname alive as a fallback that does not depend on // the zone's DNS being correct. "workers_dev": true,