feat(user-data-export): require an emailed code to download an export - #5204
Conversation
A data export is a single archive of everything stored with an account, so a held web session should not be enough to exfiltrate it. Downloading now takes two steps: request a code, which is emailed to the account address, then redeem it for one signed URL. That means an attacker riding a stolen session also needs the inbox. The code is a 6-digit value stored only as an HMAC keyed by the server secret, over the purpose, email, export id, and code. Binding the export id means a code minted for one export cannot authorize another. Each code lives 10 minutes, has its own 5-attempt budget keyed by challenge id, and authorizes exactly one URL mint, because `createDownload` can otherwise be called repeatedly to re-sign. A failure to sign releases the code instead of consuming it, so an outage does not cost the user a code. Repeat sends are throttled to one per minute so a held session cannot be used to mail-bomb the owner. Codes use their own `purpose` rather than reusing `sign_in_code`, which matters for two reasons: `createSignInCode` deletes unconsumed rows for an email and would silently invalidate an in-flight mobile sign-in, and /api/auth/native/token accepts any live `sign_in_code` for an email as a full sign-in credential, so a shared purpose would make a download code redeemable for a session. `purpose` is a plain text column with no constraint, so the TypeScript union was unenforced. Since the value now decides what a token may authorize, add a CHECK constraint for the known set. Every existing row is `magic_link` or `sign_in_code`, so it validates without a backfill. Note that forced-SSO accounts receive the emailed code like everyone else, which bypasses the mandatory-SSO path that `checkDomainSignInEligibility` otherwise enforces on every sign-in surface. This is deliberate for now: running that check would leave those accounts unable to download their own data at all.
Code Review SummaryStatus: 1 Issue Found | Recommendation: Address before merge Executive SummaryThe follow-up commit correctly fixes the undelivered-code cleanup (a Mailgun throw now drops the code row instead of tripping the resend cooldown, with test coverage) and widens codes to 8 digits; the one remaining issue is the fail-open Vercel Firewall rate limit, which is not enforced until the Overview
Issue Details (click to expand)WARNING
Fix these issues in Kilo Cloud Files Reviewed (7 files)
Previous Review Summaries (2 snapshots, latest commit c655eb4)Current summary above is authoritative. Previous snapshots are kept for context only. Previous review (commit c655eb4)Status: 2 Issues Found | Recommendation: Address before merge Executive SummaryThe emailed-code step-up is well designed (HMAC bound to purpose+email+export, single-use consume-after-sign, release-on-signing-failure, purpose isolation from sign-in codes), but two failure paths remain: a Mailgun exception in Overview
Issue Details (click to expand)WARNING
Notes on areas checked with no findings: the incremental commit's rate-limit wiring is otherwise sound — keyed by user id (the held session is the threat model), checked before code creation, fail-open only on missing rule with a Sentry signal, and covered by a new router test asserting TOO_MANY_REQUESTS and no email; the Fix these issues in Kilo Cloud Files Reviewed (16 files)
Previous review (commit 8b60d95)Status: 1 Issue Found | Recommendation: Address before merge Executive SummaryThe emailed-code step-up is well designed (HMAC bound to purpose+email+export, single-use consume-after-sign, release-on-signing-failure, purpose isolation from sign-in codes verified both directions), but a Mailgun exception in Overview
Issue Details (click to expand)WARNING
Notes on areas checked with no findings: the Fix these issues in Kilo Cloud Files Reviewed (15 files)
Reviewed by kimi-k3 · Input: 59.9K · Output: 9.3K · Cached: 461.8K Review guidance: REVIEW.md from base branch |
The 60-second resend cooldown only spaces consecutive codes, and each new code starts a fresh five-attempt budget, so a held web session could keep guessing the six-digit code and keep mailing the account owner. Adds a Vercel Firewall rate limit keyed by user id, mirroring `magic-link-email` on the sign-in path. The `data-export-download-code` rule lives in the firewall configuration of both web projects. `checkRateLimit` reports an unknown id as "not limited", so a missing rule is reported to Sentry.
| * is an opaque internal identifier, not PII like the email keying | ||
| * `magic-link-email`. | ||
| */ | ||
| export async function isDataExportDownloadCodeRateLimited(kiloUserId: string): Promise<boolean> { |
There was a problem hiding this comment.
WARNING: This new firewall rate limit is fail-open and is not enforced until a matching rule is created in the Vercel dashboard
The file's own comment says an unknown rule id is reported as "not rate limited", and checkRateLimit never throws on a missing rule — it only surfaces error === 'not-found', which is logged to Sentry but does not block the request. Nothing in this repo creates the data-export-download-code rule (the Vercel Firewall UI/API owns it; existing magic-link-email and device-auth-deny rules live there too), so as soon as this merges the check is a no-op and the commit's stated goal — bounding a held session's total guesses and mail volume beyond the 60-second cooldown — is not actually in effect, with only a Sentry message as the signal. Please create/verify the rule in the Vercel firewall config before merging, or gate deployment on it; otherwise a rule that is absent or later removed fails silently open.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
There was a problem hiding this comment.
The data-export-download-code rule exists on kilocode-app: fixed window, 5 requests / 3600 seconds, API-controlled keys (so it buckets on the rateLimitKey the code passes, data-export-download-code:), action 429.
"Nothing in this repo provisions it" is accurate but is the intended state.
Separately, codes are now 8 digits rather than 6.
…code space
Two fixes to the emailed download code.
sendDataExportDownloadCodeEmail reports a refused address as { sent: false }
but throws on an API or network failure, and only the first shape was handled.
A throw escaped before deleteDataExportDownloadCode ran, so the code row
survived and its created_at tripped the 60-second resend cooldown -- the
immediate retry told the user a code had just been sent when none had. Both
shapes now drop the code and return the intended error, with the cleanup
delete caught separately so it cannot mask that message.
The per-challenge attempt budget resets whenever a new code is issued, so it
caps the guess rate rather than the total; the search space is what bounds a
held session's odds over time. Widen the code from 6 to 8 digits and hoist the
length into a single shared constant, replacing six hardcoded occurrences.
Resolves against the two-step emailed-code download from #5204: the organization branch moves into requireDownloadableExport, the shared gate for both download steps, so neither can be reached on stale authority. Migration renumbered to 0213 behind main's 0212.
Why
CleanShot.2026-08-11.at.16.53.36.mp4
A data export is a single archive of everything stored with an account, which makes it a far more attractive target than clicking around the UI. Today a live web session is sufficient to download one, so anyone riding a stolen session (XSS, malware, an unlocked laptop) can walk off with the whole thing.
This adds an email step-up on the download. It's worth being precise about what that buys: it defends against session theft, not account compromise. The ceiling is "does the attacker have the inbox", and the inbox is already a full auth factor here via the magic-link provider. It does not substitute for MFA, which this stack doesn't have.
What changed
Downloading is now two steps:
userExports.requestDownloadCode— verifies ownership/freshness, emails a 6-digit code to the account address, returns only achallengeId.userExports.createDownload— reserves the code, calls the export Worker, and consumes the code once a signed URL exists.