Skip to content

feat(careers): spam defences, applicant emails, and sitemap coverage - #187

Merged
ralyodio merged 1 commit into
masterfrom
feature/careers-hardening
Aug 4, 2026
Merged

ralyodio merged 1 commit into
masterfrom
feature/careers-hardening

Conversation

@ralyodio

@ralyodio ralyodio commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Closes the three gaps left open when the careers widget shipped (#183, #185).

1. Spam defences on the public apply endpoint

/api/careers/apply is an unauthenticated POST on the open internet. The (job_id, email) unique constraint only stops an honest double-submit — a script that varies the address walks straight past it.

  • Honeypot. The widget renders a company field off-screen (left:-9999px) rather than display:none, which some bots skip. aria-hidden + tabindex="-1" keep it out of the tab order and away from screen readers, so no real applicant can reach it. A tripped honeypot returns exactly what success returns and writes nothing — whatever filled it gets no signal to adapt.
  • Per-source hourly cap (APPLY_HOURLY_CAP = 8), counted off a salted IP hash via the existing hashIp. A person applying to several roles at one company stays well under it; a flood does not. Checked before the posting lookup, so abuse costs one indexed count() rather than the full write path.

We store the hash, never the address — enough for a rate limit without turning the applications table into a log of who visited from where.

2. The owner actually hears about applications

They were landing in the dashboard silently, so the employer had to think to go and look — for a hiring inbox, that means good candidates go stale.

Every field in the email is applicant-controlled and arrives from a public form, so all of it goes through escapeHtml. The portfolio link renders as text, not an anchor: it's normalized to http(s) server-side, but there's no reason to put a stranger's URL one click away inside the owner's mail client. Mail failure is swallowed — the application is already recorded and the applicant is already done, so a Resend outage must not surface to them as a failed submission.

3. /c/ pages in the sitemap

Those pages exist so a client-rendered board still has crawlable HTML behind it — which only pays off if crawlers can find them. robots.ts already allows them; nothing advertised them. Gated on both feature flags, same as the serving RPC, or the sitemap would advertise boards that 404.

Migration

20260803170000_careers_applicant_ip.sql adds ip_hash + a (ip_hash, created_at desc) index. I've already applied it to ywcizjsgrcmhgyplldac, along with the two from #183/#185 — so the DB is ready and this merges clean. The column is nullable and additive, so master is safe either way.

Checks

  • tsc --noEmit clean
  • vitest run — 1326 passed (13 new), 1 file skipped
  • next build compiles

Mutation-checked rather than trusting a green run: disabling the honeypot fails 1 test, disabling the rate limit fails 1 test.

Note for review

The security advisor flags public_job_postings as anon-callable SECURITY DEFINER. That's intentional — the widget has no session — and it sits alongside 6 pre-existing functions with the same flag. Separately, three unrelated migrations are still unapplied on that project: ad_impression_short_codes, ad_rpc_revoke_public, revoke_public_secdef_rest. The last two are security fixes. Not mine to apply silently, but you probably want them.

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.
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

ThreatCrush Security Scan

52 finding(s)

HIGH/CRITICAL: 10 | MEDIUM: 42

Severity Rule Location
HIGH secret-generic-credential app/(marketing)/docs/autoblog-webhook/page.tsx:145
HIGH secret-generic-credential lib/sp/platforms/facebook.ts:32
HIGH js-ssrf-outbound-request lib/sp/platforms/facebook.ts:115
HIGH secret-generic-credential lib/sp/platforms/linkedin.ts:25
HIGH js-ssrf-outbound-request lib/sp/platforms/telegram.ts:63
HIGH js-ssrf-outbound-request lib/sp/platforms/threads.ts:138
HIGH manifest-typosquat package.json:59
HIGH secret-generic-credential tests/contract/coinpay.test.ts:4
HIGH secret-generic-credential tests/contract/posthog-integration.test.ts:13
HIGH secret-generic-credential tests/lead-campaign.test.ts:16
MEDIUM js-unescaped-html-sink app/(app)/admin/email-broadcast/EmailBroadcastForm.tsx:125
MEDIUM sql-template-interpolation app/(app)/projects/[id]/autoblog/actions.tsx:96
MEDIUM js-unescaped-html-sink app/(app)/projects/[id]/autoblog/articles/[articleId]/page.tsx:214
MEDIUM sql-template-interpolation app/(app)/projects/[id]/autoblog/setup/form.tsx:504
MEDIUM sql-template-interpolation app/(app)/projects/[id]/uptime/monitor-actions.tsx:28
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:67
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:97
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:104
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:110
MEDIUM js-unescaped-html-sink app/(marketing)/recent/page.tsx:186
MEDIUM js-unescaped-html-sink app/(marketing)/recent/page.tsx:190
MEDIUM sql-template-interpolation app/actions/admin.ts:114
MEDIUM sql-template-interpolation app/actions/orgs.ts:328
MEDIUM sql-template-interpolation app/api/lx/keywords/regenerate/route.ts:59
MEDIUM js-unescaped-html-sink app/c/[project]/[slug]/page.tsx:77
MEDIUM js-unescaped-html-sink app/c/[project]/page.tsx:57
MEDIUM js-unescaped-html-sink app/careers.js/route.ts:201
MEDIUM js-unescaped-html-sink app/careers.js/route.ts:228
MEDIUM js-unescaped-html-sink app/careers.js/route.ts:285
MEDIUM js-unescaped-html-sink app/layout.tsx:129
MEDIUM js-open-redirect app/login/form.tsx:39
MEDIUM js-unescaped-html-sink app/r/[token]/page.tsx:176
MEDIUM js-open-redirect app/signup/form.tsx:43
MEDIUM js-open-redirect components/billing/buy-credits-modal.tsx:98
MEDIUM js-unescaped-html-sink components/json-ld.tsx:8
MEDIUM js-unescaped-html-sink components/report/markdown-view.tsx:15
MEDIUM sql-template-interpolation lib/audit/checks/security.ts:48
MEDIUM redos-nested-quantifier lib/careers/jobs.ts:139
MEDIUM redos-nested-quantifier lib/emailMarkdown.ts:130
MEDIUM redos-nested-quantifier lib/lx/articleGen.ts:93
MEDIUM sql-template-interpolation lib/lx/articleGen.ts:367
MEDIUM sql-template-interpolation lib/lx/articleGen.ts:379
MEDIUM sql-template-interpolation lib/lx/articleGen.ts:380
MEDIUM sql-template-interpolation lib/lx/articleGen.ts:1340
MEDIUM sql-template-interpolation lib/lx/articleGen.ts:1362
MEDIUM sql-template-interpolation lib/lx/guestPostGen.ts:109
MEDIUM tls-verification-disabled lib/onion.ts:47
MEDIUM sql-template-interpolation lib/sp/platforms/linkedin.ts:177
MEDIUM sql-template-interpolation scripts/delete-archived-projects.mjs:97
MEDIUM sql-template-interpolation scripts/delete-archived-projects.mjs:102

…and 2 more. Full results in the Security tab.

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio marked this pull request as ready for review August 4, 2026 01:09
@ralyodio
ralyodio merged commit f6894b1 into master Aug 4, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant