@@ -35,6 +35,20 @@ const CAPTCHA_UNAVAILABLE_RATE_LIMIT: TokenBucketConfig = {
3535const SUCCESS_RESPONSE = { success : true , message : "Thanks — we'll be in touch soon." }
3636const TOO_MANY_REQUESTS_RESPONSE = { error : 'Too many requests. Please try again later.' }
3737
38+ /**
39+ * Public contact-form endpoint: per-IP rate limit, honeypot drop, captcha, then a
40+ * help-inbox notification plus a best-effort visitor confirmation.
41+ *
42+ * Captcha is server-authoritative — a valid Turnstile token is the only way past
43+ * the stricter fallback bucket, so a caller cannot opt out of the challenge. A
44+ * missing token (widget could not load) or a Cloudflare transport error falls
45+ * back to the tighter no-captcha bucket rather than a free pass; an outright
46+ * invalid token is rejected. That backstop is enforced `failClosed`, so an
47+ * unavailable limiter rejects token-less submits instead of admitting them. No
48+ * `expectedHostname` is pinned: the site key is already domain-bound in
49+ * Cloudflare, and a single-host pin would reject valid self-hosted/preview/apex
50+ * tokens.
51+ */
3852export const POST = withRouteHandler ( async ( req : NextRequest ) => {
3953 const requestId = generateRequestId ( )
4054
@@ -69,20 +83,12 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
6983 return NextResponse . json ( SUCCESS_RESPONSE , { status : 201 } )
7084 }
7185
72- // Captcha is server-authoritative: a valid Turnstile token is the only way to
73- // skip the stricter fallback bucket. A missing token (widget could not load) or
74- // a Cloudflare transport error falls back to the tighter no-captcha rate limit
75- // rather than a free pass, so callers cannot opt out of the challenge. An
76- // outright invalid token is rejected.
7786 if ( isTurnstileConfigured ( ) ) {
7887 let captchaVerified = false
7988 const token =
8089 typeof captchaToken === 'string' && captchaToken . length > 0 ? captchaToken : null
8190
8291 if ( token ) {
83- // No expectedHostname: the Turnstile site key is already domain-bound in
84- // Cloudflare, and pinning a single hostname here would reject valid tokens
85- // from self-hosted, preview, and apex-vs-www deployments.
8692 const verification = await verifyTurnstileToken ( { token, remoteIp : ip } )
8793 if ( verification . success ) {
8894 captchaVerified = true
@@ -104,9 +110,6 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
104110 }
105111
106112 if ( ! captchaVerified ) {
107- // Fail closed: this bucket is the only throttle on token-less submits, so
108- // if the limiter storage is unavailable we reject rather than admit an
109- // uncaptcha'd request to the email path.
110113 const nocaptchaKey = `public:contact:nocaptcha:${ ip } `
111114 const { allowed : nocaptchaAllowed } = await rateLimiter . checkRateLimitDirect (
112115 nocaptchaKey ,
0 commit comments