fix: harden signup against bots and undeliverable recipients - #267
Merged
Conversation
…ening # Conflicts: # bun.lock # middleware.ts
…ening # Conflicts: # .github/actions/deploy-worker/action.yml # .github/workflows/deploy-dev.yml
…ening # Conflicts: # .github/actions/deploy-worker/action.yml # .github/workflows/deploy-dev.yml
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.
Summary
Task Reference: [PYZ-91]
Three defences on the sign-up path.
Turnstile bot protection. Registers the Better Auth
captchaplugin on/sign-up/email,/sign-in/email,/send-verification-email, and/request-password-reset. Widget modemanagedwithappearance: "interaction-only", so ordinary visitors see nothing and only flagged visitors get an interactive challenge. Boot-gated onTURNSTILE_SECRET_KEY: unset, no plugin is registered and no widget renders, so self-host is unchanged.Recipient deliverability gate (PYZ-364). A DNS-over-HTTPS probe runs on
/sign-up/emailbefore the user row is written and rejects domains that cannot receive mail: MX exchanges resolving only to unreachable address space, RFC 7505 null MX, and NXDOMAIN. No MX with a routable A/AAAA is accepted per the RFC 5321 §5.1 implicit-MX fallback. Exchanges are probed in MX preference order under a 4s whole-probe deadline, and only a fully probed exchange list can be asserted undeliverable. Resolver errors, SERVFAIL, timeouts, and unparseable MX answers fail open, and the probe is skipped entirely when signups are disabled.Routability is decided by
lib/auth/ip-routability.ts, which parses an address to bytes once and tests CIDR containment against an IANA-derived table covering both families. Numeric containment is what catches the same address written a different way:::ffff:7f00:1,64:ff9b::7f00:1, and2002:7f00:1::all embed 127.0.0.1 and all read as routable under textual prefix matching. The table covers loopback, RFC 1918, link-local, CGNAT, the TEST-NET blocks,240/4, multicast,198.18/15,192.88.99/24,192.0.0/24, and IPv6 unique-local, discard, Teredo, benchmarking, 6to4, NAT64, and both documentation blocks.Both address families for an exchange resolve concurrently. Serialised, one exchange cost two round trips and a domain whose first exchange is dead cost five, which overran the whole-probe deadline and returned
unknownfor domains that were merely slow. Settled verdicts are memoized per domain in-process, longer fordeliverablethanundeliverable;unknownis never cached.Per-recipient email budget (PYZ-365). Caps auth email per recipient per template per hour, enforced at
deliverAuthEmail, the single chokepoint that also covers the server-action senders which bypass Better Auth rate limiting. The budget counts delivered mail: a slot is claimed before dispatch and committed only once the provider accepts, so a provider outage cannot exhaust a recipient's allowance and leave them unable to verify. Caps are per template, with a higher allowance for team invitations so someone joining several teams in an hour is not silently dropped. Keys are SHA-256 digests, so no address reaches KV. Over-budget sends are dropped and logged with a hashed recipient handle. KV outage fails open in both directions.Rate limiting is unchanged.
Type of change
Testing
bun run devbun run lint)bun run typecheck)bun run build:cf && bun run smoke:cf)bun test: 2215 pass, 0 fail, ~45s.New test files:
tests/auth/ip-routability.test.ts(every range in the table, the CGNAT and 172/12 boundaries on both sides, and the hex-form embeddings),tests/auth/recipient-domain.test.ts(probe against canned DoH zones, including null MX versus unparseable MX, preference-order probing, concurrent address-family resolution, and the verdict cache),tests/auth/signup-deliverability.test.ts(gate wiring),tests/auth/turnstile-config.test.ts(plugin gating and order, the endpoint list read from the exported constant, CSP, a handler-level tokenless 400 on every protected endpoint, and the verification arms driven through a stubbed siteverify: valid token, hostname mismatch, failed verification, and transport outage),tests/ui/turnstile-state.test.ts(submit gating, the captcha header name, and the copy pairing),tests/security/turnstile-symmetry.test.ts(both directions of the deploy gate),tests/security/middleware.test.ts(CSP scoping),tests/email/budget.test.tsandtests/email/budget-workers.test.ts(caps, key scoping, the read/commit split, TTL floor, fail-open).Client half verified in a browser against Cloudflare's published testing keys on a local dev run: the widget loads under the production CSP shape, stays invisible in
interaction-only, delivers its token viax-captcha-response, and the form recovers after a captcha failure. The forced-interactive key confirms the escalated challenge renders below the submit control and leaves it in place, and that the challenge spans the auth column rather than sitting short of it. The deliverability gate was exercised against live DNS: an NXDOMAIN domain is rejected with the friendly message before any row is written, and a deliverable domain completes sign-up.Not yet exercised: a provisioned production widget, so managed-mode escalation behaviour remains unobserved; the Workers build, so the KV budget store and the DoH probe have not run on workerd; and a real sub-350px viewport, where the challenge is expected to scroll within its own strip rather than widen the page.
Notes for reviewer
The captcha plugin fails closed. A siteverify outage returns 500, and sign-in is protected, so login fails with it for the whole deployment, with a 10s timeout bounding each blocked request. A Turnstile outage also stops the client widget from minting tokens, so rollback drops both halves: delete the
TURNSTILE_SECRET_KEYWorker secret, clear the environment'sTURNSTILE_SITE_KEY_*variable, and redeploy. That symmetry is gated on both heads:assert-deploy-readycovers production, andscripts/turnstile-symmetry.ts --env devruns on the dev deploy, which previously had no gate at all.The deliverability probe rejects an exchange that resolves only to unreachable address space, and also one that resolves to nothing at all; neither is a delivery target. Parked and anti-spam domains publish MX records pointing at loopback, which resolve normally and are what Cloudflare reports as
transport_none.Role-address prefixes (
admin@,info@) are not blocked; prefix matching produces false positives on legitimate customer-domain addresses.setRecipientDomainResolverexists so the suite stays hermetic. Without it the probe makes live DNS calls on every auth test and rejects the suite's@test.localaddresses as NXDOMAIN.tests/setup/preload.tsinstalls a permissive default through a dynamic import, which must remain dynamic because a static import hoists above theserver-onlyneutralization.Better Auth 1.6.23 matches captcha endpoints by substring rather than exact path, so the protected list is explicit and exported. Of the four entries,
/sign-in/emailcollides with/sign-in/email-otpand/request-password-resetcollides with theemailOTPandphoneNumbervariants; Better Auth exempts only the first internally and neither plugin is enabled here. v1.7 switches to exact-and-wildcard matching and drops that exemption, which is a no-op for a list of exact paths.The plugin forwards the caller's address to siteverify as
remoteip, taken fromadvanced.ipAddress, which masks IPv6 to a /64. IPv6 visitors therefore send a network rather than an address. Cloudflare documentsremoteipas optional with no mismatch handling, and 1.6.23 offers no per-call opt-out short of disabling IP tracking globally.Under
appearance: "interaction-only"the widget library styles its own wrapperwidth: fit-content; display: flex, which preventssize: "flexible"from expanding. The gate replaces that wrapper, and centres the iframe with auto inline margins because Tailwind's preflight makes iframes block-level and immune totext-align.KV read-modify-write is not atomic and propagates in roughly 60s, so a caller spread across POPs can exceed the nominal budget. A dropped send is silent to the caller by design, so
auth_email_budget_exceededis the only signal that a real user is being capped.Follow-ups filed separately: PYZ-366, PYZ-367, PYZ-368.
Docs impact
TURNSTILE_SECRET_KEY: per-env Worker secret. Not unconditionally required: the deploy gates enforce that it and the build-time site key are present together or absent together, on both production and dev.TURNSTILE_SITE_KEY: build-time, surfaced to the client asNEXT_PUBLIC_TURNSTILE_SITE_KEY, supplied per environment from theTURNSTILE_SITE_KEY_PRODandTURNSTILE_SITE_KEY_DEVActions variables. It is part of the build-artefact cache key, so rotating the widget cannot restore a bundle carrying the previous value..env.local.exampledocuments the pair as commented entries, including that the dashboard widget must be Managed mode for the invisible-until-needed behaviour.Compliance impact
Adds a third-party client-side service on the auth pages. Turnstile loads a script and iframe from
challenges.cloudflare.comand processes client IP, TLS fingerprint, User-Agent, and sitekey with origin.The privacy policy's cookie claims were reviewed and hold. Pre-clearance is not enabled, which is the default, so Turnstile issues a one-time token and no
cf_clearancecookie is set on this domain; no first-party cookie is set either. Cloudflare separately documentscf_chl_rc_i/cf_chl_rc_ni/cf_chl_rc_mas Challenge Platform cookies, so this is not a zero-cookie integration and the claim is scoped to what is verifiable. The policy's technical-data categories, the security purpose under Art. 6(1)(f), and the Art. 22 statement cover Turnstile as written, socontent/legal/privacy.mdandlib/legal/versions.tsare untouched and no re-consent is triggered. Enabling pre-clearance later would setcf_clearanceand require this section to be revisited.content/legal/subprocessors.mdnow names bot protection (Turnstile) in the existing Cloudflare entry. Same entity, same data categories, so this is a list refresh, not a new sub-processor under the Art. 28(2) notice process.