Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 12 additions & 12 deletions app/modules/i18n/locales/en.po
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ msgid "Change your password"
msgstr "Change your password"

#: app/routes/password/reset.tsx:109
#: app/routes/recover/index.tsx:315
#: app/routes/recover/index.tsx:307
#: app/routes/signup/index.tsx:408
#: app/routes/signup/method.tsx:259
#: app/routes/signup/password.tsx:218
Expand Down Expand Up @@ -192,7 +192,7 @@ msgid "Choose your login method"
msgstr "Choose your login method"

#: app/routes/recover/complete.tsx:225
#: app/routes/recover/index.tsx:339
#: app/routes/recover/index.tsx:331
msgid "Code"
msgstr "Code"

Expand Down Expand Up @@ -225,7 +225,7 @@ msgstr "Connected accounts"
#: app/routes/device/index.tsx:73
#: app/routes/login/index.tsx:568
#: app/routes/recover/complete.tsx:248
#: app/routes/recover/index.tsx:354
#: app/routes/recover/index.tsx:346
#: app/routes/signup/index.tsx:459
#: app/routes/signup/index.tsx:605
msgid "Continue"
Expand Down Expand Up @@ -294,7 +294,7 @@ msgstr "Didn't get the email?"

#: app/routes/login/index.tsx:392
#: app/routes/login/index.tsx:407
#: app/routes/recover/index.tsx:390
#: app/routes/recover/index.tsx:382
#: app/routes/signup/index.tsx:541
#: app/routes/signup/index.tsx:577
msgid "Email"
Expand Down Expand Up @@ -346,7 +346,7 @@ msgstr "Enable SMS one-time code"
msgid "Enter a new code"
msgstr "Enter a new code"

#: app/routes/recover/index.tsx:385
#: app/routes/recover/index.tsx:377
msgid "Enter a valid email address."
msgstr "Enter a valid email address."

Expand Down Expand Up @@ -374,7 +374,7 @@ msgstr "Enter the verification code sent to your email address."
msgid "Enter your authenticator code"
msgstr "Enter your authenticator code"

#: app/routes/recover/index.tsx:372
#: app/routes/recover/index.tsx:364
msgid "Enter your email address and we'll send you a link to set up a new passkey."
msgstr "Enter your email address and we'll send you a link to set up a new passkey."

Expand Down Expand Up @@ -612,7 +612,7 @@ msgid "Please finish setting up your passkey before leaving this page. It will b
msgstr "Please finish setting up your passkey before leaving this page. It will be the only way to sign in, and your account won't be usable without it."

#: app/routes/error.tsx:52
#: app/routes/recover/index.tsx:370
#: app/routes/recover/index.tsx:362
#: app/routes/signup/index.tsx:598
#: app/routes/verify/index.tsx:234
msgid "Recover your account"
Expand Down Expand Up @@ -689,7 +689,7 @@ msgstr "Security key"
msgid "Select an account to continue or add a new one."
msgstr "Select an account to continue or add a new one."

#: app/routes/recover/index.tsx:404
#: app/routes/recover/index.tsx:396
msgid "Send recovery link"
msgstr "Send recovery link"

Expand Down Expand Up @@ -850,7 +850,7 @@ msgstr "Thanks,"
msgid "That action isn't available right now."
msgstr "That action isn't available right now."

#: app/routes/recover/index.tsx:334
#: app/routes/recover/index.tsx:326
msgid "That code is invalid or has expired. Check the email, or start over."
msgstr "That code is invalid or has expired. Check the email, or start over."

Expand Down Expand Up @@ -1040,7 +1040,7 @@ msgid "We sent a verification code to your email address."
msgstr "We sent a verification code to your email address."

#. placeholder {0}: sent.email
#: app/routes/recover/index.tsx:317
#: app/routes/recover/index.tsx:309
msgid "We've sent a link to <0>{0}</0>. Open it on the device you want to sign in with, or enter the code from that email here."
msgstr "We've sent a link to <0>{0}</0>. Open it on the device you want to sign in with, or enter the code from that email here."

Expand Down Expand Up @@ -1068,7 +1068,7 @@ msgstr "Welcome"
msgid "While Datum is currently free of charge to use, we require a valid payment method during the signup process."
msgstr "While Datum is currently free of charge to use, we require a valid payment method during the signup process."

#: app/routes/recover/index.tsx:359
#: app/routes/recover/index.tsx:351
#: app/routes/signup/index.tsx:471
msgid "Wrong address? Start over"
msgstr "Wrong address? Start over"
Expand Down Expand Up @@ -1121,7 +1121,7 @@ msgstr "You'll be asked to sign in before authorizing."
msgid "You've been signed out"
msgstr "You've been signed out"

#: app/routes/recover/index.tsx:399
#: app/routes/recover/index.tsx:391
msgid "you@example.com"
msgstr "you@example.com"

Expand Down
10 changes: 1 addition & 9 deletions app/routes/recover/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,15 +143,7 @@ export async function action({ request }: ActionFunctionArgs) {

// Bot gate before any provider work, so a rejection costs what an acceptance costs.
// A distinct action name per intent: a request token cannot be replayed against code entry.
// The sealed recovery ticket already gates this step, and the request step that issued
// it passed a fresh reCAPTCHA. A user who left to fetch the code from their mail comes
// back with an aged token and a clean score; rejecting that told them their code was
// invalid, which is both wrong and the normal path.
if (
await recaptchaRejects(String(form.get('recaptchaToken') ?? ''), 'recovery_code', {
tolerateStale: true,
})
) {
if (await recaptchaRejects(String(form.get('recaptchaToken') ?? ''), 'recovery_code')) {
return invalidCode();
}
if (!parsed.success) return invalidCode();
Expand Down
27 changes: 4 additions & 23 deletions app/server/infra/recaptcha.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,13 @@ import { logAuthEvent } from '@/server/observability';

const SITEVERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify';
const REQUEST_TIMEOUT_MS = 2000;
const MAX_TOKEN_AGE_MS = 2 * 60_000;

export type RecaptchaReason =
| 'ok'
| 'not-configured'
| 'no-token'
| 'rejected'
| 'action-mismatch'
| 'stale'
| 'hostname-mismatch'
| 'transport';

Expand Down Expand Up @@ -76,7 +74,6 @@ export async function verifyRecaptcha(
score?: number;
action?: string;
hostname?: string;
challenge_ts?: string;
'error-codes'?: unknown;
};

Expand All @@ -101,17 +98,14 @@ export async function verifyRecaptcha(

const score = typeof body.score === 'number' ? body.score : null;

// No local age check: Google enforces expiry and single use itself, answering
// success=false with timeout-or-duplicate. challenge_ts is when the page's challenge was
// created, not when execute() minted the token, so aging from it rejects real users.
if (!body.success) return { outcome: 'invalid', score, reason: 'rejected', errorCodes };
if (body.action !== expectedAction) {
return { outcome: 'invalid', score, reason: 'action-mismatch' };
}

// Math.abs so a backward-skewed clock cannot silently disable this check.
const issued = body.challenge_ts ? Date.parse(body.challenge_ts) : NaN;
if (Number.isNaN(issued) || Math.abs(Date.now() - issued) > MAX_TOKEN_AGE_MS) {
return { outcome: 'invalid', score, reason: 'stale' };
}

// The only control against someone farming tokens with our public site key on their own
// domain. env.server refuses to boot when the secret is set without PUBLIC_ORIGIN, so
// this guard is a fallback rather than a live skip path.
Expand All @@ -133,19 +127,7 @@ export async function verifyRecaptcha(
* the gated set stays enumerable. Callers must still run it before any account lookup, or
* the fast reject path becomes an enumeration timing oracle (G7).
*/
/**
* `tolerateStale` is for a step the user reaches minutes after the page rendered — the
* recovery code screen, where they leave to fetch the code from their mail. grecaptcha
* hands back a challenge aged from page load rather than from the execute() call, so an
* ordinary user returning after two minutes fails the age check with a perfect score.
* Staleness alone is then not evidence of a bot, and every other verdict still rejects.
* Only pass it where a separate credential already gates the step.
*/
export async function recaptchaRejects(
token: string,
expectedAction: string,
{ tolerateStale = false }: { tolerateStale?: boolean } = {}
): Promise<boolean> {
export async function recaptchaRejects(token: string, expectedAction: string): Promise<boolean> {
const verdict = await verifyRecaptcha(token, expectedAction);

// Unconfigured deployments stay dark, audit trail included — otherwise the metric reads
Expand All @@ -165,6 +147,5 @@ export async function recaptchaRejects(
}

// 'unavailable' is Google failing us, not the caller — fail open.
if (tolerateStale && verdict.reason === 'stale') return false;
return verdict.outcome === 'invalid';
}
22 changes: 19 additions & 3 deletions cypress/component/server/recaptcha.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,16 +101,32 @@ describe('verifyRecaptcha', () => {
});
});

it('rejects a stale token', () => {
const old = new Date(Date.now() - 5 * 60_000).toISOString();
// challenge_ts is when the page's challenge was created, not when execute() minted the
// token. A user who submits hours after page load still holds a token Google accepts.
it('accepts a Google-verified token whose challenge_ts is hours old', () => {
const old = new Date(Date.now() - 5 * 60 * 60_000).toISOString();
callService({
fn: 'verifyRecaptcha',
env: RECAPTCHA_ENV,
recaptchaInput: { token: 'tok', expectedAction: 'signup' },
recaptchaFetch: { body: ok({ challenge_ts: old }) },
}).then((v) => {
expect(v.outcome.outcome).to.equal('valid');
expect(v.outcome.reason).to.equal('ok');
});
});

// Genuine expiry and replay are Google's call, reported as timeout-or-duplicate.
it('rejects an expired or replayed token Google reports as timeout-or-duplicate', () => {
callService({
fn: 'verifyRecaptcha',
env: RECAPTCHA_ENV,
recaptchaInput: { token: 'tok', expectedAction: 'signup' },
recaptchaFetch: { body: { success: false, 'error-codes': ['timeout-or-duplicate'] } },
}).then((v) => {
expect(v.outcome.outcome).to.equal('invalid');
expect(v.outcome.reason).to.equal('stale');
expect(v.outcome.reason).to.equal('rejected');
expect(v.outcome.errorCodes).to.include('timeout-or-duplicate');
});
});

Expand Down
Loading