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
37 changes: 37 additions & 0 deletions docs/adr/0001-form-spam-protection-policy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# ADR-0001: Form spam protection is reactive and per-form

## Status

Accepted — 2026-05-06

## Context

The site has two public form intake endpoints: the contact form and the
doula-match form. Both share a baseline of reCAPTCHA verification and
score-threshold rejection. The contact form additionally runs a honeypot
field check, gibberish detection on name and message, and a "submitted
too fast" timing check. The doula-match form runs none of these extras.

## Decision

We apply spam-protection layers reactively, per form, based on observed
spam volume. The contact form has accumulated honeypot/gibberish/timing
checks because we have observed real spam against it. The doula-match
form has not, because we have not observed spam against it — likely due
to its larger field surface (phone, due date, dropdowns) being unattractive
to common spam scripts.

A future increase in match-form spam should be answered by adding the
relevant check(s) to that form, not by uniformly applying every check
to every form.

## Consequences

- The doula-match form is a softer target than the contact form. We
accept this risk in exchange for lower friction for legitimate
submissions.
- The form-intake substrate exposes spam policy as a declarative input
per form, so adding/removing a check on one form does not affect the
other.
- Architecture reviews should not "fix" the asymmetry by unifying
policies without checking this ADR.
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export function createAdminMatchRequestsPlugin(services?: PartialServices) {
logger,
set,
}) => {
const typedBody = body as { sent: boolean };
const typedBody = body;
return updateMatchRequestLogic({
requestId: params.requestId,
sent: typedBody.sent,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { Elysia } from "elysia";
import { logger as firebaseLogger } from "firebase-functions/v2";
import type { ProfileDataBody } from "../../profiles-api/schemas/profile-schemas.js";
import { ProfileDataBodySchema } from "../../profiles-api/schemas/profile-schemas.js";
import { AuthService } from "../../shared-api/services/auth/index.js";
import { EmailService } from "../../shared-api/services/email/index.js";
Expand Down Expand Up @@ -254,7 +253,7 @@ export function createAdminMembersPlugin(services?: PartialServices) {
logger,
set,
}) => {
const typedBody = body as ProfileDataBody;
const typedBody = body;
return updateProfileLogic({
memberId: params.memberId,
profileData: typedBody,
Expand All @@ -280,7 +279,7 @@ export function createAdminMembersPlugin(services?: PartialServices) {
logger,
set,
}) => {
const typedBody = body as { slug: string };
const typedBody = body;
return linkProfileLogic({
memberId: params.memberId,
slug: typedBody.slug,
Expand Down Expand Up @@ -366,7 +365,7 @@ export function createAdminMembersPlugin(services?: PartialServices) {
logger,
set,
}) => {
const typedBody = body as { newExpirationDate: string };
const typedBody = body;
return extendMembershipLogic({
memberId: params.memberId,
newExpirationDate: typedBody.newExpirationDate,
Expand Down Expand Up @@ -423,7 +422,7 @@ export function createAdminMembersPlugin(services?: PartialServices) {
logger,
set,
}) => {
const typedBody = body as { admin?: boolean };
const typedBody = body;
return updateClaimsLogic({
uid: params.memberId,
claims: typedBody,
Expand Down
2 changes: 1 addition & 1 deletion functions/src/admin-members-api/routes/approve-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,6 @@ export async function approveProfileLogic({
logger,
set,
context: { memberId, adminUid },
}) as ApproveProfileApiResponse;
});
}
}
2 changes: 1 addition & 1 deletion functions/src/admin-members-api/services/update-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,6 @@ function isFirestoreNotFoundError(error: unknown): boolean {
typeof error === "object" &&
error !== null &&
"code" in error &&
(error as { code: unknown }).code === NOT_FOUND_CODE
error.code === NOT_FOUND_CODE
);
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { mock } from "bun:test";
import type { DecodedIdToken } from "firebase-admin/auth";
import type { ProfileDocument } from "../../collections/index.js";
import type { AuthService } from "../../shared-api/services/auth/interface.js";
import type { EmailServiceInterface } from "../../shared-api/services/email/index.js";
import type { Logger } from "../../shared-api/types/logger.js";
Expand Down Expand Up @@ -95,7 +94,7 @@ export function createAdminTestPlugin(overrides?: {
draft: false,
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
} as ProfileDocument,
},
}),
),
updateProfile: mock(() =>
Expand All @@ -107,7 +106,7 @@ export function createAdminTestPlugin(overrides?: {
draft: false,
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-02-01T00:00:00.000Z",
} as ProfileDocument,
},
}),
),
listUnlinkedProfiles: mock(() =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export function createAdminMessagesPlugin(services?: PartialServices) {
logger,
set,
}) => {
const typedBody = body as { sent: boolean };
const typedBody = body;
return updateMessageLogic({
messageId: params.messageId,
sent: typedBody.sent,
Expand Down
18 changes: 18 additions & 0 deletions functions/src/forms-api/routes/handle-contact-form.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,24 @@ describe("POST /contact-us", () => {
expect(body.error).toBe("Invalid form submission");
});

it("should return 400 when both contact name and message look like gibberish", async () => {
const { plugin, request } = setup({
body: {
contactName: "AisqdChFmjmacpKsLgjGNo",
email: "john@example.com",
message: "XzqkfBpwLmNvCxRtYuIoAsDfGhJk",
recaptchaToken: "valid-token",
formLoadedAt: Date.now() - 5000,
},
});

const response = await handleRequest(plugin, request);

expect(response.status).toBe(400);
const body = (await response.json()) as { error?: string };
expect(body.error).toBe("Invalid form submission");
});

it("should return 400 when submitted too quickly", async () => {
const { plugin, request } = setup({
body: {
Expand Down
107 changes: 29 additions & 78 deletions functions/src/forms-api/routes/handle-contact-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import { buildContactFormNotification } from "../services/build-contact-form-not
import type { FormStorageService } from "../services/form-storage/interface.js";
import type { ContactFormData } from "../services/form-storage/types.js";
import type { RecaptchaService } from "../services/recaptcha/interface.js";
import { checkRecaptchaScore } from "../utils/check-recaptcha-score.js";
import { detectGibberish } from "../utils/detect-gibberish.js";
import { runSpamChecks } from "../utils/run-spam-checks.js";
import { sendAndPersist } from "../utils/send-and-persist.js";

export async function handleContactFormLogic({
formData,
Expand All @@ -34,7 +34,6 @@ export async function handleContactFormLogic({
set: { status?: number | string };
}): Promise<FormResponse> {
try {
// Verify reCAPTCHA token
const verification = await recaptchaService.verifyToken({
token: recaptchaToken,
secretKey: recaptchaSecretKey,
Expand All @@ -50,93 +49,45 @@ export async function handleContactFormLogic({
return { success: false, error: "reCAPTCHA verification failed" };
}

// Check reCAPTCHA score threshold to block bots
const scoreRejection = checkRecaptchaScore({
score: verification.score,
const rejection = runSpamChecks({
policy: {
recaptcha: { score: verification.score },
honeypot: { value: honeypotValue },
gibberish: [
{ fieldName: "contactName", text: formData.contactName },
{ fieldName: "message", text: formData.message },
],
timing: { formLoadedAt, minMillis: 3000 },
},
submitterEmail: formData.email,
submitterName: formData.contactName,
formType: "contact form",
errorId: ERROR_IDS.CONTACT_FORM_PROCESSING_FAILED,
logger,
set,
});
if (scoreRejection !== undefined) {
return scoreRejection;
if (rejection !== undefined) {
return rejection;
}

if (honeypotValue !== undefined && honeypotValue.trim() !== "") {
logger.warn("Contact form submission rejected by honeypot", {
errorId: ERROR_IDS.CONTACT_FORM_PROCESSING_FAILED,
reason: "honeypot_filled",
submitterEmail: formData.email,
submitterName: formData.contactName,
});
set.status = 400;
return { success: false, error: "Invalid form submission" };
}

const nameLooksLikeGibberish = detectGibberish({
text: formData.contactName,
});
const messageLooksLikeGibberish = detectGibberish({
text: formData.message,
});

if (nameLooksLikeGibberish || messageLooksLikeGibberish) {
logger.warn("Contact form submission rejected as gibberish", {
errorId: ERROR_IDS.CONTACT_FORM_PROCESSING_FAILED,
reason: "gibberish_detected",
submitterEmail: formData.email,
submitterName: formData.contactName,
nameLooksLikeGibberish,
messageLooksLikeGibberish,
});
set.status = 400;
return { success: false, error: "Invalid form submission" };
}

if (formLoadedAt !== undefined && Date.now() - formLoadedAt < 3000) {
logger.warn("Contact form submission rejected as too fast", {
errorId: ERROR_IDS.CONTACT_FORM_PROCESSING_FAILED,
reason: "submitted_too_fast",
submitterEmail: formData.email,
submitterName: formData.contactName,
formLoadedAt,
});
set.status = 400;
return { success: false, error: "Invalid form submission" };
}

// Try to send notification email first
let emailSent = false;
let warning: string | undefined;

try {
const emailMessage = buildContactFormNotification(formData);
await emailService.sendEmail({ message: emailMessage }, logger);
emailSent = true;
} catch (emailError: unknown) {
logger.error("CRITICAL: Failed to send contact form notification email", {
const { emailSent, warning } = await sendAndPersist({
buildEmail: () => buildContactFormNotification(formData),
persist: ({ emailSent: sent }) =>
formStorageService.saveContactForm({
data: formData,
recaptchaScore: verification.score,
emailSent: sent,
}),
emailService,
logger,
formContext: {
formType: "contact form",
formTypeKey: "contact_form",
errorId: ERROR_IDS.CONTACT_FORM_PROCESSING_FAILED,
severity: "CRITICAL",
error: emailError,
errorMessage:
emailError instanceof Error ? emailError.message : "Unknown error",
errorStack: emailError instanceof Error ? emailError.stack : undefined,
formType: "contact_form",
submitterEmail: formData.email,
submitterName: formData.contactName,
recaptchaScore: verification.score,
timestamp: new Date().toISOString(),
});

warning = "Form saved but notification email failed to send";
}

// Save form to Firestore with email send status
await formStorageService.saveContactForm({
data: formData,
recaptchaScore: verification.score,
emailSent,
},
});

logger.info("Contact form submitted successfully", {
Expand Down
57 changes: 23 additions & 34 deletions functions/src/forms-api/routes/handle-match-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import { buildDoulaMatchNotification } from "../services/build-doula-match-notif
import type { FormStorageService } from "../services/form-storage/interface.js";
import type { DoulaMatchData } from "../services/form-storage/types.js";
import type { RecaptchaService } from "../services/recaptcha/interface.js";
import { checkRecaptchaScore } from "../utils/check-recaptcha-score.js";
import { runSpamChecks } from "../utils/run-spam-checks.js";
import { sendAndPersist } from "../utils/send-and-persist.js";

export async function handleMatchFormLogic({
formData,
Expand All @@ -29,7 +30,6 @@ export async function handleMatchFormLogic({
set: { status?: number | string };
}): Promise<FormResponse> {
try {
// Verify reCAPTCHA token
const verification = await recaptchaService.verifyToken({
token: recaptchaToken,
secretKey: recaptchaSecretKey,
Expand All @@ -45,50 +45,39 @@ export async function handleMatchFormLogic({
return { success: false, error: "reCAPTCHA verification failed" };
}

// Check reCAPTCHA score threshold to block bots
const scoreRejection = checkRecaptchaScore({
score: verification.score,
const rejection = runSpamChecks({
policy: {
recaptcha: { score: verification.score },
},
submitterEmail: formData.email,
submitterName: formData.name,
formType: "doula match form",
errorId: ERROR_IDS.DOULA_MATCH_FORM_PROCESSING_FAILED,
logger,
set,
});
if (scoreRejection !== undefined) {
return scoreRejection;
if (rejection !== undefined) {
return rejection;
}

// Try to send notification email first
let emailSent = false;
let warning: string | undefined;

try {
const emailMessage = buildDoulaMatchNotification(formData);
await emailService.sendEmail({ message: emailMessage }, logger);
emailSent = true;
} catch (emailError: unknown) {
logger.error("CRITICAL: Failed to send doula match notification email", {
const { emailSent, warning } = await sendAndPersist({
buildEmail: () => buildDoulaMatchNotification(formData),
persist: ({ emailSent: sent }) =>
formStorageService.saveMatchRequest({
data: formData,
recaptchaScore: verification.score,
emailSent: sent,
}),
emailService,
logger,
formContext: {
formType: "doula match form",
formTypeKey: "doula_match",
errorId: ERROR_IDS.DOULA_MATCH_FORM_PROCESSING_FAILED,
severity: "CRITICAL",
error: emailError,
errorMessage:
emailError instanceof Error ? emailError.message : "Unknown error",
errorStack: emailError instanceof Error ? emailError.stack : undefined,
formType: "doula_match",
submitterEmail: formData.email,
submitterName: formData.name,
recaptchaScore: verification.score,
timestamp: new Date().toISOString(),
});

warning = "Form saved but notification email failed to send";
}

// Save form to Firestore with email send status
await formStorageService.saveMatchRequest({
data: formData,
recaptchaScore: verification.score,
emailSent,
},
});

logger.info("Doula match form submitted successfully", {
Expand Down
Loading