feat(careers): spam defences, applicant emails, and sitemap coverage - #187
Merged
Merged
Conversation
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.
ThreatCrush Security Scan52 finding(s) HIGH/CRITICAL: 10 | MEDIUM: 42
…and 2 more. Full results in the Security tab. Snippets are redacted; ThreatCrush never prints matched credential material. |
ralyodio
marked this pull request as ready for review
August 4, 2026 01:09
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the three gaps left open when the careers widget shipped (#183, #185).
1. Spam defences on the public apply endpoint
/api/careers/applyis 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.companyfield off-screen (left:-9999px) rather thandisplay: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.APPLY_HOURLY_CAP = 8), counted off a salted IP hash via the existinghashIp. 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 indexedcount()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 sitemapThose pages exist so a client-rendered board still has crawlable HTML behind it — which only pays off if crawlers can find them.
robots.tsalready 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.sqladdsip_hash+ a(ip_hash, created_at desc)index. I've already applied it toywcizjsgrcmhgyplldac, 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 --noEmitcleanvitest run— 1326 passed (13 new), 1 file skippednext buildcompilesMutation-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_postingsas anon-callableSECURITY 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.