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");