From 826de1bd3a7d27eef3ed9f7dda95171bf5097f41 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 3 Aug 2026 16:51:07 +0000 Subject: [PATCH] feat(careers): spam defences, applicant emails, and sitemap coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three gaps left open when the widget shipped. Spam. /api/careers/apply is an unauthenticated POST on the open internet, and the (job_id, email) unique constraint only stops an honest double-submit — a script that varies the address walks straight past it. Two defences now: a honeypot field the widget renders off-screen (not display:none, which some bots skip; aria-hidden and tabindex=-1 keep it out of the tab order and away from screen readers), and a per-source hourly cap counted off a salted IP hash. A tripped honeypot answers exactly like success so whatever filled it gets no signal to adapt. Notifications. Applications were landing in the dashboard silently, so the employer had to think to go and look — which for a hiring inbox means good candidates go stale. The owner now gets an email. Every field in it is applicant-controlled and arrives from a public form, so all of it is escaped, and the portfolio link renders as text rather than an anchor: it is normalized to http(s) server-side, but there is no reason to put a stranger's URL one click away in the owner's mail client. Mail failure is swallowed — the applicant is already done. Sitemap. /c/ pages exist so a client-rendered board still has crawlable HTML behind it, which only pays off if crawlers can find them. Gated on both feature flags, same as the serving RPC, or the sitemap would advertise boards that 404. We store a hash of the IP, never the address — enough for a rate limit without turning the applications table into a log of who visited from where. --- app/api/careers/apply/route.ts | 47 +++- app/careers.js/route.ts | 8 +- app/sitemap.ts | 53 ++++- lib/careers/notify.ts | 58 +++++ lib/email.ts | 66 ++++++ .../20260803170000_careers_applicant_ip.sql | 15 ++ tests/careers-apply-hardening.test.ts | 202 ++++++++++++++++++ tests/careers-widget-script.test.ts | 12 ++ 8 files changed, 458 insertions(+), 3 deletions(-) create mode 100644 lib/careers/notify.ts create mode 100644 supabase/migrations/20260803170000_careers_applicant_ip.sql create mode 100644 tests/careers-apply-hardening.test.ts diff --git a/app/api/careers/apply/route.ts b/app/api/careers/apply/route.ts index c03dcd62..48fe99b4 100644 --- a/app/api/careers/apply/route.ts +++ b/app/api/careers/apply/route.ts @@ -3,14 +3,27 @@ // // Three fields and a link — no file upload, so we never take custody of a // resume. Writes with the service role because applicants have no session. +// +// Being unauthenticated and on the open internet, this endpoint carries two +// spam defences. The (job_id, email) unique constraint only stops an honest +// double-submit; a script that varies the address walks straight past it. +// 1. A honeypot field the widget renders hidden. Humans never fill it. +// 2. A per-source hourly cap, counted off a salted hash of the client IP. import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { serviceClient } from "@/lib/supabase/service"; import { isValidEmail, normalizeLink } from "@/lib/careers/jobs"; +import { notifyNewApplication } from "@/lib/careers/notify"; +import { clientIpFromHeaders } from "@/lib/tracker/geo"; +import { hashIp } from "@/lib/rateLimit"; export const runtime = "nodejs"; +// Applications allowed from one source per hour. A real person applying to +// several roles at one company stays well under it; a scripted flood does not. +export const APPLY_HOURLY_CAP = 8; + const bodySchema = z.object({ site: z.string().uuid(), job: z.string().uuid(), @@ -19,6 +32,8 @@ const bodySchema = z.object({ link: z.string().max(500).nullable().optional(), note: z.string().max(2000).nullable().optional(), url: z.string().max(2048).nullable().optional(), + // Honeypot. Named to look worth filling in to a bot scanning field names. + company: z.string().max(200).nullable().optional(), }); function corsHeaders(request: Request) { @@ -55,6 +70,12 @@ export async function POST(request: NextRequest) { if (!parsed.success) return fail(request, "Check the form and try again."); const body = parsed.data; + // Honeypot tripped: answer exactly as we would on success, so whatever is + // filling it gets no signal to adapt. Nothing is written. + if (body.company && body.company.trim()) { + return NextResponse.json({ ok: true }, { headers: corsHeaders(request) }); + } + const fullName = body.fullName.trim().replace(/\s+/g, " "); const email = body.email.trim().toLowerCase(); if (!fullName) return fail(request, "Enter your name."); @@ -69,13 +90,26 @@ export async function POST(request: NextRequest) { } const supabase = serviceClient(); + const ipHash = hashIp(clientIpFromHeaders(request.headers)); + + // Per-source hourly cap. Counted before the posting lookup so a flood costs + // one indexed count() rather than the full write path. + const since = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + const { count } = await supabase + .from("job_applications") + .select("id", { count: "exact", head: true }) + .eq("ip_hash", ipHash) + .gte("created_at", since); + if ((count ?? 0) >= APPLY_HOURLY_CAP) { + return fail(request, "Too many applications from here. Try again later.", 429); + } // The posting must be open and belong to a project with the module on — // otherwise a stale widget could keep posting to a closed role. const [{ data: job }, { data: project }] = await Promise.all([ supabase .from("job_postings") - .select("id, status") + .select("id, status, title") .eq("id", body.job) .eq("project_id", body.site) .maybeSingle(), @@ -104,6 +138,7 @@ export async function POST(request: NextRequest) { link, note: body.note?.trim().slice(0, 2000) || null, source_url: body.url?.slice(0, 2048) ?? null, + ip_hash: ipHash, referrer: request.headers.get("referer")?.slice(0, 2048) ?? null, user_agent: request.headers.get("user-agent")?.slice(0, 500) ?? null, updated_at: new Date().toISOString(), @@ -117,5 +152,15 @@ export async function POST(request: NextRequest) { return fail(request, "Could not submit right now. Try again shortly.", 500); } + // The applicant is done either way — a mail failure must not surface to them + // as a failed application, so this is awaited but never throws. + await notifyNewApplication({ + projectId: body.site, + jobTitle: (job as { title?: string }).title ?? "a role", + fullName, + email, + link, + }); + return NextResponse.json({ ok: true }, { headers: corsHeaders(request) }); } diff --git a/app/careers.js/route.ts b/app/careers.js/route.ts index e771cc4d..f063b4d3 100644 --- a/app/careers.js/route.ts +++ b/app/careers.js/route.ts @@ -89,7 +89,8 @@ const snippet = `(function(){ '.cp-careers-msg{font-size:.85em}', '.cp-careers-empty{padding:20px 0;opacity:.7}', '.cp-careers-credit{margin-top:14px;font-size:.75em;opacity:.55}', - '.cp-careers-credit a{color:inherit}' + '.cp-careers-credit a{color:inherit}', + '.cp-hp{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden}' ].join(''); function injectStyle() { @@ -167,6 +168,10 @@ const snippet = `(function(){ html += ''; html += ''; html += ''; + // Honeypot. Positioned off-screen rather than display:none, which some + // bots skip; aria-hidden and tabindex=-1 keep it away from screen + // readers and the tab order so no real applicant can reach it. + html += ''; html += '
'; html += ''; } @@ -262,6 +267,7 @@ const snippet = `(function(){ fullName: form.fullName.value, email: form.email.value, link: form.link.value, + company: form.company ? form.company.value : '', url: location.origin + location.pathname }; if (!payloadBody.fullName.trim()) { msg.textContent = 'Enter your name.'; return; } diff --git a/app/sitemap.ts b/app/sitemap.ts index 441b10bb..83f6e69a 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -65,5 +65,56 @@ export default async function sitemap(): Promise { // Don't 500 the sitemap if Supabase is briefly unreachable. } - return [...staticEntries, ...reportEntries]; + // Hosted job boards and the individual postings under them. These exist so + // that a client-rendered careers widget still has crawlable HTML behind it — + // which only pays off if crawlers can find the pages, so they belong here. + let careerEntries: MetadataRoute.Sitemap = []; + try { + const svc = serviceClient(); + // Two queries rather than an embedded join: the serving RPC gates on both + // flags, so the sitemap has to gate on them too or it advertises boards + // that 404 — and resolving the enabled set first says that plainly. + const { data: enabled } = await svc + .from("projects") + .select("id") + .eq("careers_enabled", true) + .eq("tracker_enabled", true); + const enabledIds = ((enabled ?? []) as Array<{ id: string }>).map((p) => p.id); + + if (enabledIds.length > 0) { + const { data } = await svc + .from("job_postings") + .select("slug, project_id, published_at, updated_at") + .eq("status", "open") + .in("project_id", enabledIds) + .order("published_at", { ascending: false }) + .limit(500); + + const rows = (data ?? []) as Array<{ + slug: string; + project_id: string; + published_at: string | null; + updated_at: string | null; + }>; + + const boards = new Set(rows.map((r) => r.project_id)); + careerEntries = [ + ...Array.from(boards).map((projectId) => ({ + url: `${base}/c/${projectId}`, + changeFrequency: "daily" as const, + priority: 0.7, + })), + ...rows.map((row) => ({ + url: `${base}/c/${row.project_id}/${row.slug}`, + lastModified: new Date(row.updated_at ?? row.published_at ?? Date.now()), + changeFrequency: "weekly" as const, + priority: 0.6, + })), + ]; + } + } catch { + // Same rule as above — a sitemap missing job pages beats a 500. + } + + return [...staticEntries, ...reportEntries, ...careerEntries]; } diff --git a/lib/careers/notify.ts b/lib/careers/notify.ts new file mode 100644 index 00000000..6520634d --- /dev/null +++ b/lib/careers/notify.ts @@ -0,0 +1,58 @@ +// Notification for a new job application. +// +// Without this, applications land in the dashboard silently and the employer +// has to think to go and look — which, for a hiring inbox, means good +// candidates go stale. Best-effort throughout: a mail failure must never turn +// a successfully-recorded application into an error for the applicant. + +import { env } from "@/lib/env"; +import { sendCareersApplicationEmail } from "@/lib/email"; +import { serviceClient } from "@/lib/supabase/service"; + +export interface ApplicationNotice { + projectId: string; + jobTitle: string; + fullName: string; + email: string; + link: string | null; +} + +/** + * Email the project owner that someone applied. + * + * Resolves the recipient from the project owner's profile. Returns quietly if + * mail isn't configured, the owner has no address, or the send fails — the + * caller has already written the row and the applicant is already done. + */ +export async function notifyNewApplication(notice: ApplicationNotice): Promise { + try { + const supabase = serviceClient(); + const { data: project } = await supabase + .from("projects") + .select("name, owner_id") + .eq("id", notice.projectId) + .maybeSingle(); + const ownerId = (project as { owner_id?: string } | null)?.owner_id; + if (!ownerId) return; + + const { data: profile } = await supabase + .from("profiles") + .select("email") + .eq("id", ownerId) + .maybeSingle(); + const to = (profile as { email?: string | null } | null)?.email; + if (!to) return; + + await sendCareersApplicationEmail({ + to, + projectName: (project as { name?: string } | null)?.name ?? "your site", + jobTitle: notice.jobTitle, + applicantName: notice.fullName, + applicantEmail: notice.email, + link: notice.link, + inboxUrl: `${env.siteUrl.replace(/\/+$/, "")}/projects/${notice.projectId}/stats/careers`, + }); + } catch { + // Swallowed on purpose — see the module comment. + } +} diff --git a/lib/email.ts b/lib/email.ts index 4a97c56a..bed8d756 100644 --- a/lib/email.ts +++ b/lib/email.ts @@ -1247,3 +1247,69 @@ export function broadcastEmailHtml(input: { footerNote: "You're receiving this because you have a CrawlProof account.", }); } + +// New job application landed in a project's careers inbox. +// +// Every field here is applicant-controlled and arrives from an unauthenticated +// public form, so all of it goes through escapeHtml. The portfolio link is +// rendered as text rather than an anchor: it has been normalized to http(s) +// server-side, but there is no reason to make a stranger's URL one click away +// inside the owner's mail client. +export async function sendCareersApplicationEmail(input: { + to: string; + projectName: string; + jobTitle: string; + applicantName: string; + applicantEmail: string; + link: string | null; + inboxUrl: string; +}): Promise<{ sent: boolean; error?: string }> { + const c = client(); + if (!c) return { sent: false, error: "RESEND_API_KEY not set" }; + + const linkRow = input.link + ? `

+ Portfolio / LinkedIn / GitHub: + ${escapeHtml(input.link)} +

` + : ""; + + const innerHtml = ` + + +

New application

+

+ ${escapeHtml(input.applicantName)} applied for + ${escapeHtml(input.jobTitle)} + at ${escapeHtml(input.projectName)}. +

+

+ Email: ${escapeHtml(input.applicantEmail)} +

+ ${linkRow} + + + + + + Review applicant + + + + `; + + const res = await c.send({ + from: env.resendFrom, + to: input.to, + subject: `New application: ${escapeHtml(input.jobTitle)}`, + html: emailShell({ + title: `New application for ${input.jobTitle}`, + innerHtml, + footerNote: "You're receiving this because you own this CrawlProof project.", + }), + }); + if (!res.sent) return { sent: false, error: res.error }; + return { sent: true }; +} diff --git a/supabase/migrations/20260803170000_careers_applicant_ip.sql b/supabase/migrations/20260803170000_careers_applicant_ip.sql new file mode 100644 index 00000000..bcfbc89b --- /dev/null +++ b/supabase/migrations/20260803170000_careers_applicant_ip.sql @@ -0,0 +1,15 @@ +-- Spam control for the public application form. +-- +-- /api/careers/apply is an unauthenticated POST on the open internet. The +-- (job_id, email) unique constraint stops an honest double-submit but does +-- nothing against a script that varies the address, so we need a per-source +-- counter. Storing a salted hash rather than the address itself keeps the +-- rate limit workable without turning the applications table into a log of +-- who visited from where. +alter table public.job_applications + add column if not exists ip_hash text; + +-- The rate-limit query is "how many applications from this source recently", +-- so the index leads on ip_hash and orders by time. +create index if not exists job_applications_ip_recent_idx + on public.job_applications(ip_hash, created_at desc); diff --git a/tests/careers-apply-hardening.test.ts b/tests/careers-apply-hardening.test.ts new file mode 100644 index 00000000..9f1f43fc --- /dev/null +++ b/tests/careers-apply-hardening.test.ts @@ -0,0 +1,202 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// /api/careers/apply is an unauthenticated POST on the open internet. These +// cover the two spam defences and the owner notification, plus the rule that +// neither may ever turn a good application into a failure for the applicant. + +const state = vi.hoisted(() => ({ + recentFromIp: 0, + job: { id: "job-1", status: "open", title: "HPC Engineer" } as + | { id: string; status: string; title: string } + | null, + project: { careers_enabled: true, tracker_enabled: true } as + | { careers_enabled: boolean; tracker_enabled: boolean } + | null, + upserts: [] as Record[], + upsertError: null as { message: string } | null, + notified: [] as Record[], + notifyThrows: false, +})); + +function jobApplications() { + const op: { kind?: string; payload?: unknown } = {}; + const b: Record = { + select: () => b, + eq: () => b, + gte: async () => ({ count: state.recentFromIp, error: null }), + upsert: async (payload: Record) => { + state.upserts.push(payload); + return { error: state.upsertError }; + }, + then: (res: (v: unknown) => unknown) => Promise.resolve({ count: state.recentFromIp }).then(res), + }; + void op; + return b; +} + +vi.mock("@/lib/supabase/service", () => ({ + serviceClient: () => ({ + from(table: string) { + if (table === "job_applications") return jobApplications(); + if (table === "job_postings") { + return { + select: () => ({ + eq: () => ({ + eq: () => ({ maybeSingle: async () => ({ data: state.job, error: null }) }), + }), + }), + }; + } + if (table === "projects") { + return { + select: () => ({ + eq: () => ({ maybeSingle: async () => ({ data: state.project, error: null }) }), + }), + }; + } + return { select: () => ({ eq: () => ({ maybeSingle: async () => ({ data: null }) }) }) }; + }, + }), +})); + +vi.mock("@/lib/careers/notify", () => ({ + notifyNewApplication: async (n: Record) => { + if (state.notifyThrows) throw new Error("resend exploded"); + state.notified.push(n); + }, +})); + +vi.mock("@/lib/tracker/geo", () => ({ clientIpFromHeaders: () => "203.0.113.9" })); + +async function post(body: unknown) { + const { POST } = await import("@/app/api/careers/apply/route"); + const req = new Request("https://crawlproof.com/api/careers/apply", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + // The route reads request.nextUrl only on GET; a plain Request is enough here. + const res = await POST(req as never); + return { status: res.status, json: await res.json() }; +} + +const GOOD = { + site: "11111111-1111-1111-1111-111111111111", + job: "22222222-2222-2222-2222-222222222222", + fullName: "Jane Doe", + email: "Jane@Example.com", + link: "github.com/jane", +}; + +beforeEach(() => { + vi.resetModules(); + state.recentFromIp = 0; + state.job = { id: "job-1", status: "open", title: "HPC Engineer" }; + state.project = { careers_enabled: true, tracker_enabled: true }; + state.upserts = []; + state.upsertError = null; + state.notified = []; + state.notifyThrows = false; +}); + +describe("happy path", () => { + it("accepts an application and normalizes what it stores", async () => { + const res = await post(GOOD); + expect(res.json.ok).toBe(true); + expect(state.upserts).toHaveLength(1); + expect(state.upserts[0]).toMatchObject({ + full_name: "Jane Doe", + email: "jane@example.com", + link: "https://github.com/jane", + }); + }); + + it("stamps a hashed source, never the raw address", async () => { + await post(GOOD); + const stored = state.upserts[0].ip_hash as string; + expect(stored).toBeTruthy(); + expect(stored).not.toContain("203.0.113.9"); + }); + + it("notifies the owner", async () => { + await post(GOOD); + expect(state.notified).toHaveLength(1); + expect(state.notified[0]).toMatchObject({ jobTitle: "HPC Engineer", fullName: "Jane Doe" }); + }); +}); + +describe("honeypot", () => { + it("drops the submission without writing", async () => { + const res = await post({ ...GOOD, company: "Acme Corp" }); + expect(state.upserts).toEqual([]); + expect(state.notified).toEqual([]); + // Answers exactly like success so whatever filled it gets no signal. + expect(res.status).toBe(200); + expect(res.json.ok).toBe(true); + }); + + it("ignores an empty or whitespace honeypot, which is what browsers send", async () => { + await post({ ...GOOD, company: "" }); + expect(state.upserts).toHaveLength(1); + state.upserts = []; + await post({ ...GOOD, company: " " }); + expect(state.upserts).toHaveLength(1); + }); +}); + +describe("per-source rate limit", () => { + it("rejects once the hourly cap is reached", async () => { + const { APPLY_HOURLY_CAP } = await import("@/app/api/careers/apply/route"); + state.recentFromIp = APPLY_HOURLY_CAP; + const res = await post(GOOD); + expect(res.status).toBe(429); + expect(state.upserts).toEqual([]); + }); + + it("still allows the application one below the cap", async () => { + const { APPLY_HOURLY_CAP } = await import("@/app/api/careers/apply/route"); + state.recentFromIp = APPLY_HOURLY_CAP - 1; + const res = await post(GOOD); + expect(res.json.ok).toBe(true); + expect(state.upserts).toHaveLength(1); + }); +}); + +describe("closed and disabled roles", () => { + it("refuses a closed posting", async () => { + state.job = { id: "job-1", status: "closed", title: "HPC Engineer" }; + const res = await post(GOOD); + expect(res.status).toBe(410); + expect(state.upserts).toEqual([]); + }); + + it("refuses when the project switched the module off", async () => { + state.project = { careers_enabled: false, tracker_enabled: true }; + const res = await post(GOOD); + expect(res.status).toBe(410); + }); +}); + +describe("validation", () => { + it("rejects a bad email before writing", async () => { + const res = await post({ ...GOOD, email: "not-an-email" }); + expect(res.json.ok).toBe(false); + expect(state.upserts).toEqual([]); + }); + + // The dashboard renders the link as an href, so a script URL must not land. + it("rejects a javascript: link", async () => { + const res = await post({ ...GOOD, link: "javascript:alert(1)" }); + expect(res.json.ok).toBe(false); + expect(state.upserts).toEqual([]); + }); +}); + +describe("failure isolation", () => { + it("reports a write failure to the applicant", async () => { + state.upsertError = { message: "boom" }; + const res = await post(GOOD); + expect(res.status).toBe(500); + expect(state.notified).toEqual([]); + }); +}); diff --git a/tests/careers-widget-script.test.ts b/tests/careers-widget-script.test.ts index f9fec48e..893d653d 100644 --- a/tests/careers-widget-script.test.ts +++ b/tests/careers-widget-script.test.ts @@ -45,6 +45,18 @@ describe("/careers.js", () => { expect(text).toContain("esc(j.title)"); }); + // The honeypot only works if the widget actually renders it and posts it + // back, and only stays invisible if it is off-screen rather than removed. + it("renders the honeypot off-screen and submits it", async () => { + const { text } = await body(careersScript); + expect(text).toContain('name="company"'); + expect(text).toContain("cp-hp"); + expect(text).toContain("left:-9999px"); + expect(text).toContain("aria-hidden"); + expect(text).toContain('tabindex="-1"'); + expect(text).toContain("company: form.company"); + }); + it("guards against mounting twice", async () => { const { text } = await body(careersScript); expect(text).toContain("__crawlproofCareersMounted");