From 775543c4e611db7e42f52059d045bfb719ccd49b Mon Sep 17 00:00:00 2001 From: Misbah Khursheed Date: Sat, 4 Jul 2026 00:49:15 +0530 Subject: [PATCH] fix(www): harden volunteer application API endpoint Reject non-POST requests, validate email and JSON input, escape Airtable formula values, and return 502 when lookup or create operations fail. --- .../www/src/@types/sane-email-validation.d.ts | 4 +++ apps/www/src/pages/api/applyAsVolunteer.ts | 31 ++++++++++++++++--- 2 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 apps/www/src/@types/sane-email-validation.d.ts diff --git a/apps/www/src/@types/sane-email-validation.d.ts b/apps/www/src/@types/sane-email-validation.d.ts new file mode 100644 index 0000000..9089579 --- /dev/null +++ b/apps/www/src/@types/sane-email-validation.d.ts @@ -0,0 +1,4 @@ +declare module "sane-email-validation" { + function isEmail(email: string): boolean; + export = isEmail; +} diff --git a/apps/www/src/pages/api/applyAsVolunteer.ts b/apps/www/src/pages/api/applyAsVolunteer.ts index ceb2199..77fa3f9 100644 --- a/apps/www/src/pages/api/applyAsVolunteer.ts +++ b/apps/www/src/pages/api/applyAsVolunteer.ts @@ -3,6 +3,7 @@ import Airtable from "airtable"; import { checkBotId } from "botid/server"; import { NextApiRequest, NextApiResponse } from "next"; import { ServerClient } from "postmark"; +import isEmail from "sane-email-validation"; import { renderBannedVolunteer, @@ -17,16 +18,36 @@ import { ApplyAsVolunteerQuery } from "./applyAsVolunteer.gql"; const postmark = new ServerClient(process.env.POSTMARK_SERVER_TOKEN!); const base = new Airtable({ apiKey: process.env.AIRTABLE_TOKEN }).base(process.env.AIRTABLE_BASE!); +const escapeAirtableFormulaValue = (value: string) => value.replace(/[\\"]/g, (c) => `\\${c}`); + async function ApplyAsVolunteer(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "POST") { + res.setHeader("Allow", "POST"); + return res.status(405).json({ error: "Method not allowed" }); + } + const verification = await checkBotId(); if (verification.isBot) { return res.status(403).json({ error: "Access denied" }); } - const { email, firstName, lastName, linkedin, region, isOrganize, background } = JSON.parse( - req.body, - ); + let body; + if (typeof req.body === "string") { + try { + body = JSON.parse(req.body); + } catch { + return res.status(400).json({ error: "Invalid JSON" }); + } + } else { + body = req.body ?? {}; + } + + const { email, firstName, lastName, linkedin, region, isOrganize, background } = body; + if (!email || !isEmail(String(email))) { + return res.status(400).json({ error: "Invalid email" }); + } + let banned = false; try { @@ -35,7 +56,7 @@ async function ApplyAsVolunteer(req: NextApiRequest, res: NextApiResponse) { maxRecords: 100, fields: ["Flags"], - filterByFormula: `TRIM(LOWER({Email})) = "${email.toString().toLowerCase().trim()}"`, + filterByFormula: `TRIM(LOWER({Email})) = "${escapeAirtableFormulaValue(email.toString().toLowerCase().trim())}"`, }) .firstPage(); airtableRes.forEach((record: any) => { @@ -43,6 +64,7 @@ async function ApplyAsVolunteer(req: NextApiRequest, res: NextApiResponse) { }); } catch (ex) { console.error(ex); + return res.status(502).json({ error: "Failed to look up volunteer record" }); } try { await base("Volunteers").create([ @@ -58,6 +80,7 @@ async function ApplyAsVolunteer(req: NextApiRequest, res: NextApiResponse) { ]); } catch (ex) { console.error(ex); + return res.status(502).json({ error: "Failed to create volunteer record" }); } let emailText: string | undefined; // if(background === 'industry') {