Skip to content

Commit 0939087

Browse files
anuveyatsuclaude
andcommitted
test(arc-auth): prove magic-link is scanner/prefetch-safe (po-gq7)
Gov/enterprise mail security (Defender Safe Links, Proofpoint, Mimecast, Barracuda) GET-prefetches links in email to scan them. Verify the existing GET-safe design holds end-to-end: a bare GET is idempotent (read-only peek, no token consume, no session cookie) and only the explicit human POST signs in — single-use, replay-safe. - Add route-level scanner-safety tests driving worker.fetch: 3x GET leaves the token pending + sets no cookie; POST then completes; POST replay 400s; GET on a consumed link never re-issues a session. Suite 56 -> 60 green. - Document the idempotency guarantee + residual risk (JS-detonation sandbox auto-submitting the form) and the OTP escape hatch in the GET handler. No behavior change — verification + defense-in-depth doc only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4e896cf commit 0939087

2 files changed

Lines changed: 94 additions & 1 deletion

File tree

cloud/auth/src/index.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,17 @@ export default {
301301
}
302302

303303
// Step 2: the emailed link lands here. A bare GET never signs in — it shows a
304-
// confirmation page so an email scanner / prefetcher can't consume the token.
304+
// confirmation page so an email scanner / prefetcher can't consume the token. This is
305+
// the industry-standard mitigation for gov/enterprise mail security (Defender Safe
306+
// Links, Proofpoint URL Defense, Mimecast, Barracuda), which GET-prefetch every link
307+
// to scan it. peekEmailLogin is read-only (SELECT), so this route is fully IDEMPOTENT:
308+
// any number of GETs leaves the token pending and issues no session cookie — only the
309+
// explicit human POST below consumes it. Verified end-to-end in test/email.test.ts
310+
// ("scanner/prefetch safety"). Residual risk: a JS-executing detonation sandbox that
311+
// renders this page and auto-submits the form would post same-origin and pass the CSRF
312+
// guard — but real scanners deliberately don't submit auth forms (it would break every
313+
// sign-in on the web). If that ever proves untrue for the /build audience, the escape
314+
// hatch is an OTP code the user types from the email (scanner-proof by construction).
305315
if (path === '/email/verify' && request.method === 'GET') {
306316
const token = url.searchParams.get('token') ?? ''
307317
const peek = await peekEmailLogin(env.DB, now(), token)

cloud/auth/test/email.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
} from '../src/email'
1111
import { upsertEmailUser } from '../src/tokens'
1212
import { sha256Hex } from '../src/util'
13+
import worker, { type Env } from '../src/index'
1314

1415
interface EmailLoginRow {
1516
id: string
@@ -219,6 +220,88 @@ describe('magic-link lifecycle', () => {
219220
})
220221
})
221222

223+
// po-gq7 — end-to-end scanner/prefetch safety at the ROUTE level. Gov/enterprise mail
224+
// security (Defender Safe Links, Proofpoint URL Defense, Mimecast, Barracuda) auto-opens
225+
// links in email to scan them. The guarantee under test: a bare GET NEVER consumes the
226+
// token or issues a session — only the explicit human POST does. This drives the real
227+
// worker.fetch handler so the GET→peek / POST→verify wiring is exercised, not just the
228+
// underlying functions.
229+
describe('magic-link scanner/prefetch safety (route level, po-gq7)', () => {
230+
const BASE = 'https://arc.portaljs.com'
231+
// Real clock: the worker reads Date.now(), so mint the token against the same "now" the
232+
// handler will see (well inside the 30-min TTL). No fake timers needed.
233+
const t = Math.floor(Date.now() / 1000)
234+
const ctx = { waitUntil() {}, passThroughOnException() {} } as unknown as ExecutionContext
235+
236+
const envFor = (db: FakeD1): Env =>
237+
({
238+
DB: db as any,
239+
GITHUB_CLIENT_ID: 'x',
240+
GITHUB_CLIENT_SECRET: 'x',
241+
SESSION_SECRET: 'test-secret-please-ignore',
242+
BASE_URL: BASE,
243+
RESEND_API_KEY: 'x',
244+
EMAIL_FROM: 'Arc <login@arc.portaljs.com>',
245+
// POSTHOG_KEY intentionally unset → captureServerEvent no-ops (no network in tests).
246+
}) as Env
247+
248+
const getVerify = (env: Env, token: string) =>
249+
worker.fetch(new Request(`${BASE}/email/verify?token=${encodeURIComponent(token)}`), env, ctx)
250+
const postVerify = (env: Env, token: string) =>
251+
worker.fetch(
252+
new Request(`${BASE}/email/verify`, {
253+
method: 'POST',
254+
headers: { 'content-type': 'application/json', origin: BASE },
255+
body: JSON.stringify({ token }),
256+
}),
257+
env,
258+
ctx
259+
)
260+
261+
it('a scanner GET-prefetch does NOT consume the token or set a session cookie', async () => {
262+
const db = new FakeD1()
263+
const { token } = await createEmailLogin(db as any, t, 'user@agency.gov')
264+
// Simulate an aggressive scanner opening the link several times.
265+
for (let i = 0; i < 3; i++) {
266+
const res = await getVerify(envFor(db), token)
267+
expect(res.status).toBe(200)
268+
expect(res.headers.get('set-cookie')).toBeNull() // no session issued on GET
269+
expect(await res.text()).toContain('Continue as') // it's the confirm page
270+
expect(db.logins[0].status).toBe('pending') // token still unspent
271+
}
272+
})
273+
274+
it('after any number of scanner GETs, the human POST still completes sign-in', async () => {
275+
const db = new FakeD1()
276+
const { token } = await createEmailLogin(db as any, t, 'user@agency.gov')
277+
for (let i = 0; i < 3; i++) await getVerify(envFor(db), token)
278+
const res = await postVerify(envFor(db), token)
279+
expect(res.status).toBe(302) // redirect into the dashboard
280+
expect(res.headers.get('set-cookie')).toContain('arc_session=') // session issued
281+
expect(db.logins[0].status).toBe('claimed')
282+
expect(db.users).toHaveLength(1) // user provisioned
283+
})
284+
285+
it('the POST is single-use: a replay (double-click / prefetched POST) fails', async () => {
286+
const db = new FakeD1()
287+
const { token } = await createEmailLogin(db as any, t, 'user@agency.gov')
288+
const first = await postVerify(envFor(db), token)
289+
expect(first.status).toBe(302)
290+
const second = await postVerify(envFor(db), token)
291+
expect(second.status).toBe(400) // already-used → error page
292+
expect(second.headers.get('set-cookie')).toBeNull() // no second session
293+
})
294+
295+
it('a GET on an already-consumed link shows the used-page, never re-issues a session', async () => {
296+
const db = new FakeD1()
297+
const { token } = await createEmailLogin(db as any, t, 'user@agency.gov')
298+
await postVerify(envFor(db), token) // human consumes it
299+
const res = await getVerify(envFor(db), token)
300+
expect(res.status).toBe(400)
301+
expect(res.headers.get('set-cookie')).toBeNull()
302+
})
303+
})
304+
222305
describe('upsertEmailUser', () => {
223306
let db: FakeD1
224307
beforeEach(() => {

0 commit comments

Comments
 (0)