From 0572cebdff17cbd736072951c24327c610663b26 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 6 Sep 2026 11:11:34 +0000 Subject: [PATCH] feat(contact): require proof of render, and escape email HTML The contact route had no anti-spam of any kind, and no honeypot would have helped: contact-form spam POSTs straight at /api/contact without rendering the page, so a hidden field is absent from the body rather than filled and the check passes. Adds @profullstack/form-guard. The page mints a signed token at render time and the route requires it back, so a request that never loaded the form has nothing to present. The token carries its issue time, giving a fill-time floor, and the guard adds a honeypot and a per-IP rate limit. Guard checks run before field validation on purpose: a bot that gets "Name must be at least 2 characters" back has learned what to send next time, where one that gets a plain success has learned nothing. Separately, sendContactEmail interpolated name, email, subject and message straight into the notification HTML. A submitter could inject markup into the mail we read. All four are escaped now, and the mailto: href is encoded. Content scoring only tags: a suspicious message still arrives, with [spam? N] in the subject and a provenance block naming the sender's IP, user-agent, fill time and the signals that fired. It can never drop one. /contact becomes force-dynamic, since a cached page would hand every visitor the same dead token. Every other route keeps its current mode. pnpm-workspace.yaml gains a minimumReleaseAgeExclude entry: form-guard is our own package and is newer than the repo's minimum release age. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01H5K2AxX2QZ98JW1Exe5wup --- apps/web/package.json | 1 + apps/web/src/app/api/contact/route.ts | 71 +++++- apps/web/src/app/contact/page.tsx | 16 +- apps/web/src/components/ContactForm.tsx | 37 ++- apps/web/src/lib/contact-guard.ts | 44 ++++ pnpm-lock.yaml | 309 +++++++++++++++--------- pnpm-workspace.yaml | 12 + 7 files changed, 361 insertions(+), 129 deletions(-) create mode 100644 apps/web/src/lib/contact-guard.ts diff --git a/apps/web/package.json b/apps/web/package.json index 3b0fe2e..a877165 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,6 +12,7 @@ "test:coverage": "vitest --coverage" }, "dependencies": { + "@profullstack/form-guard": "^0.1.1", "@supabase/supabase-js": "^2.47.0", "bcrypt": "^6.0.0", "fluent-ffmpeg": "^2.1.3", diff --git a/apps/web/src/app/api/contact/route.ts b/apps/web/src/app/api/contact/route.ts index a520908..f1e6620 100644 --- a/apps/web/src/app/api/contact/route.ts +++ b/apps/web/src/app/api/contact/route.ts @@ -1,4 +1,19 @@ import { NextRequest, NextResponse } from 'next/server' +import { provenanceBlock, type Verdict } from '@profullstack/form-guard' +import { contactGuard } from '@/lib/contact-guard' + +/** + * User input is interpolated into the notification email's HTML. Without + * escaping, a submitter can inject markup into the mail we read. + */ +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} const SUBJECT_LABELS: Record = { bug: 'Bug Report', @@ -18,6 +33,33 @@ export async function POST(request: NextRequest) { const { name, email, subject, message } = body + // Spam checks run before validation on purpose: a bot that gets + // "Name must be at least 2 characters" back has learned what to send + // next time, where one that gets a plain success has learned nothing. + let verdict: Verdict | null = null + if (contactGuard) { + verdict = await contactGuard.check({ + fields: body as unknown as Record, + headers: request.headers, + }) + if (!verdict.allow) { + if (verdict.action === 'drop') { + console.warn(`contact: dropped submission (${verdict.reason}) ip=${verdict.ip ?? '?'}`) + return NextResponse.json({ success: true }, { status: 200 }) + } + if (verdict.action === 'limited') { + return NextResponse.json( + { error: 'Too many messages from this connection. Please try again later.' }, + { status: 429 } + ) + } + return NextResponse.json( + { error: 'That took too long, or came through too quickly. Please send it again.' }, + { status: 400 } + ) + } + } + // Validate name if (!name || typeof name !== 'string' || name.trim().length < 2) { return NextResponse.json({ error: 'Name must be at least 2 characters' }, { status: 400 }) @@ -49,7 +91,9 @@ export async function POST(request: NextRequest) { } // Send email (async, don't wait) - sendContactEmail(name.trim(), email.trim(), subject, message.trim()).catch(console.error) + sendContactEmail(name.trim(), email.trim(), subject, message.trim(), verdict).catch( + console.error + ) return NextResponse.json({ success: true }, { status: 200 }) } @@ -58,7 +102,8 @@ async function sendContactEmail( name: string, email: string, subject: string, - message: string + message: string, + verdict: Verdict | null ) { const adminEmail = process.env.ADMIN_EMAIL const smtpHost = process.env.SMTP_HOST @@ -87,12 +132,21 @@ async function sendContactEmail( const subjectLabel = SUBJECT_LABELS[subject] || subject const truncatedMessage = message.length > 50 ? message.substring(0, 50) + '...' : message + // A flagged message still arrives; the tag is only so an inbox rule + // can sort it. Scoring never drops anything. + const spamTag = verdict?.suspicious ? ` [spam? ${verdict.score}]` : '' + // Where it came from and why it scored as it did. None of this is in + // the headers: the notification is sent by us to us, so it + // authenticates identically whoever filled the form in. + const provenance = verdict + ? `\n\n${provenanceBlock({ ip: verdict.ip, userAgent: verdict.userAgent, verdict })}` + : '' await transporter.sendMail({ from: smtpFrom || smtpUser, to: adminEmail, replyTo: email, - subject: `[icemap Contact] ${subjectLabel}: ${truncatedMessage}`, + subject: `[icemap Contact] ${subjectLabel}: ${truncatedMessage}${spamTag}`, text: ` New contact form submission from icemap. @@ -101,7 +155,7 @@ Email: ${email} Subject: ${subjectLabel} Message: -${message} +${message}${provenance} `.trim(), html: `
@@ -109,20 +163,21 @@ ${message} - + - + - +
From:${name}${escapeHtml(name)}
Email:${email}${escapeHtml(email)}
Subject:${subjectLabel}${escapeHtml(subjectLabel)}

Message:

-
${message}
+
${escapeHtml(message)}
+ ${provenance ? `
${escapeHtml(provenance.trim())}
` : ''}

This message was sent from the icemap contact form. Reply directly to respond to the sender. diff --git a/apps/web/src/app/contact/page.tsx b/apps/web/src/app/contact/page.tsx index 9666ac3..fe8574b 100644 --- a/apps/web/src/app/contact/page.tsx +++ b/apps/web/src/app/contact/page.tsx @@ -1,13 +1,21 @@ import type { Metadata } from 'next' import Footer from '@/components/Footer' import ContactForm from '@/components/ContactForm' +import { contactGuard } from '@/lib/contact-guard' export const metadata: Metadata = { title: 'Contact - icemap', description: 'Get in touch with the icemap team. Report bugs, ask questions, or share feedback.', } -export default function ContactPage() { +// The form carries a token minted at render time, so this page must not +// be cached — a stale page would hand every visitor the same dead token. +export const dynamic = 'force-dynamic' + +export default async function ContactPage() { + const token = contactGuard ? await contactGuard.issue() : null + const guardFields = token ? contactGuard!.fields(token) : null + return (

@@ -24,7 +32,11 @@ export default function ContactPage() {

- +