From d8f3d2dda9130ea452d316e1a6e81d8e9909748a Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Tue, 15 Sep 2026 15:28:41 -0700 Subject: [PATCH] fix(login): answer a decoy under external delivery with nothing to mail Under external delivery the decoy responders for an OTP send and a magic link request fabricated a delivery block for shape parity, addressed to the decoy's synthetic @example.invalid email. The SDK mailed it, the domain never resolves, and the adopter's provider retried for fourteen hours and then bounced it against the adopter's sending identity. Every sign-in attempt for an unknown address was a guaranteed bounce on traffic the adopter does not control. The block is only readable with a service token, so omitting it discloses nothing to a stranger, and the SDK already sends nothing when it is absent. The magic link responder still runs the redirect check for its refusal and discards the URL. decoyOtpFor had no other reason to exist and is removed. Closes #321 --- .changeset/decoy-external-delivery.md | 18 ++++++ docs/security-posture.md | 21 +++++-- src/controllers/decoyResponders.ts | 57 +++++-------------- src/services/decoyPrincipal.ts | 10 ---- .../authentication/decoyContinuation.spec.ts | 31 ++++++++++ tests/unit/services/decoyPrincipal.spec.ts | 8 --- 6 files changed, 80 insertions(+), 65 deletions(-) create mode 100644 .changeset/decoy-external-delivery.md diff --git a/.changeset/decoy-external-delivery.md b/.changeset/decoy-external-delivery.md new file mode 100644 index 0000000..aec93d5 --- /dev/null +++ b/.changeset/decoy-external-delivery.md @@ -0,0 +1,18 @@ +--- +'seamless-auth-api': patch +--- + +A decoy continuation under external delivery no longer hands the SDK a message to send. + +When an adopter runs external delivery, the decoy responders for an OTP send and a magic +link request answered with a `delivery` block like a real account's, addressed to the +decoy's synthetic `@example.invalid` email. The SDK mailed it, the domain never resolves, +and the adopter's mail provider retried for hours and then bounced it against the +adopter's sending identity. Every sign-in attempt for an unknown address was a guaranteed +bounce, fourteen hours later, on traffic the adopter does not control (#321). + +The responders now omit the block. It is only readable by a caller holding a service +token, so a stranger sees the same answer as before, and the SDK's `deliverAuthMessage` +already sends nothing when the block is absent. `decoyOtpFor` had no other reason to +exist and is removed. `docs/security-posture.md` says why parity at the SDK's edge was +not worth a bounce per probe. diff --git a/docs/security-posture.md b/docs/security-posture.md index 64c3d39..3fd8231 100644 --- a/docs/security-posture.md +++ b/docs/security-posture.md @@ -165,11 +165,22 @@ key there means a coordinated release across both SDKs for an operational tuning understand what happened. Accepted, unchanged. - **A malformed identifier** answers `400`. This does not depend on whether any account exists, so it is not an enumeration signal. -- **External delivery mode** returns a fabricated code and the decoy's synthetic address - rather than the identifier the caller supplied, so a caller comparing the two can tell. - That mode requires a valid internal service token, which makes the caller a trusted - backend that can enumerate through the admin API anyway. Accepted, and the reason it is - acceptable is the service token, not the fabrication. +- **External delivery mode** answers a real OTP or magic link request with a `delivery` + block, the address and the code for the SDK to send, and answers a decoy with none. A + caller that can read the block can tell. That mode requires a valid internal service + token, which makes the caller a trusted backend that can enumerate through the admin + API anyway. Accepted, and the reason it is acceptable is the service token. + + The block used to be fabricated for a decoy instead, for shape parity. It was addressed + to the decoy's synthetic `@example.invalid` email, and the SDK mailed it, because + mailing whatever it is handed is the SDK's job in that mode. The domain never resolves, + so every probe of an unknown identifier became a message the adopter's mail provider + retried for hours and then bounced against the adopter's sending identity (#321). + Parity at the SDK's edge is what the fabrication bought, and it is not worth a bounce + per probe: a decoy skipping the send answers faster than a real account, but that is + the same difference direct delivery already carries on these endpoints, where the real + handler's send is in-process and the decoy's is not. + - **A deleted or revoked account** mid-flow is answered as a decoy rather than with a distinguishable `401`. That is the intended behaviour, and it means such a user sees a continuation that quietly never succeeds rather than a clear rejection. diff --git a/src/controllers/decoyResponders.ts b/src/controllers/decoyResponders.ts index e7eabb6..37a96c7 100644 --- a/src/controllers/decoyResponders.ts +++ b/src/controllers/decoyResponders.ts @@ -8,15 +8,10 @@ import { generateAuthenticationOptions } from '@simplewebauthn/server'; import { Request, Response } from 'express'; import { getSystemConfig } from '../config/getSystemConfig.js'; -import { canReturnExternalDelivery } from '../lib/externalDelivery.js'; import { signEphemeralToken } from '../lib/token.js'; import { buildPrfAuthenticationExtensions } from '../lib/webauthnPrf.js'; import { AuthEventService } from '../services/authEventService.js'; -import { - decoyCredentialIdFor, - decoyOtpFor, - decoyPrincipalForSubject, -} from '../services/decoyPrincipal.js'; +import { decoyCredentialIdFor, decoyPrincipalForSubject } from '../services/decoyPrincipal.js'; import { getLoginPolicy, isLoginMethodEnabled, @@ -45,6 +40,12 @@ import { hashDeviceFingerprint } from '../utils/utils.js'; * records is the exception, and it is the one every request already writes. * - **No real handler.** `defineRoute` dispatches here instead of the controller, so the * stand-in principal never reaches code that could persist it. + * - **No delivery block.** In external delivery mode a real send answers with the + * address and the code for the SDK to mail. A decoy never does, whatever the header + * says. The block is only readable by a caller holding a service token, so omitting it + * discloses nothing to a stranger, and the alternative is the SDK mailing a synthetic + * `@example.invalid` address for real: every one bounces, against the adopter's + * sending reputation, fourteen hours after the probe. * * Policy-dependent branches are reproduced rather than skipped. A deployment with * `email_otp` disabled answers `403 login_method_disabled` for every identifier, so a @@ -102,7 +103,6 @@ async function rejectDisabledMethod(method: LoginMethod, req: Request, res: Resp async function respondOtpSent(req: Request, res: Response, kind: 'otp_email' | 'otp_sms') { const authReq = req as AuthenticatedRequest; const subject = decoySubject(req); - const useExternalDelivery = await canReturnExternalDelivery(req); await logDecoy(req, `otp:${kind}`); @@ -115,19 +115,7 @@ async function respondOtpSent(req: Request, res: Response, kind: 'otp_email' | ' const token = await signEphemeralToken(subject, authReq.attemptId); - return res.status(200).json({ - message: 'success', - token, - ...(useExternalDelivery - ? { - delivery: { - kind, - to: kind === 'otp_email' ? authReq.user.email : authReq.user.phone, - token: decoyOtpFor(subject), - }, - } - : {}), - }); + return res.status(200).json({ message: 'success', token }); } export const decoySendEmailOtp = (req: Request, res: Response) => @@ -187,22 +175,19 @@ export const decoyRequestMagicLink = async (req: Request, res: Response) => { return; } - const authReq = req as AuthenticatedRequest; - const useExternalDelivery = await canReturnExternalDelivery(req); - await logDecoy(req, 'magic_link:request'); - const rawToken = decoyCredentialIdFor(decoySubject(req)); - // Both checks below answer 400 for a real account before anything is stored, and both // are reachable by choice: a caller picks the redirect it sends, and omitting a // User-Agent header is enough to trip the second. A decoy that skipped them would // answer 200 where a real account answers 400, which is a working oracle for the price - // of one deliberately bad request. - let magicLinkUrl: string; - + // of one deliberately bad request. The URL the first one builds goes nowhere, since + // nothing is mailed; the call is made for its refusal. try { - magicLinkUrl = await resolveMagicLinkUrl(rawToken, req.query.redirectUri as string | undefined); + await resolveMagicLinkUrl( + decoyCredentialIdFor(decoySubject(req)), + req.query.redirectUri as string | undefined, + ); } catch (error) { if (error instanceof MagicLinkRedirectNotAllowedError) { return res.status(400).json({ error: 'Redirect URI is not allowed' }); @@ -217,19 +202,7 @@ export const decoyRequestMagicLink = async (req: Request, res: Response) => { return res.status(400).json({ error: 'Invalid device data' }); } - return res.json({ - message: 'If an account exists, a login link has been sent.', - ...(useExternalDelivery - ? { - delivery: { - kind: 'magic_link_email', - to: authReq.user.email, - token: rawToken, - magicLinkUrl, - }, - } - : {}), - }); + return res.json({ message: 'If an account exists, a login link has been sent.' }); }; /** diff --git a/src/services/decoyPrincipal.ts b/src/services/decoyPrincipal.ts index a36bc7c..5f3981d 100644 --- a/src/services/decoyPrincipal.ts +++ b/src/services/decoyPrincipal.ts @@ -33,7 +33,6 @@ import { User } from '../models/users.js'; const DECOY_SUBJECT_INFO = 'seamless-auth/decoy-subject'; const DECOY_EMAIL_INFO = 'seamless-auth/decoy-email'; const DECOY_PHONE_INFO = 'seamless-auth/decoy-phone'; -const DECOY_OTP_INFO = 'seamless-auth/decoy-otp'; const DECOY_CREDENTIAL_INFO = 'seamless-auth/decoy-credential'; const DECOY_SHAPE_INFO = 'seamless-auth/decoy-shape'; @@ -116,15 +115,6 @@ function decoyPhoneFor(subject: string) { return `+1555${digits.toString().padStart(7, '0')}`; } -/** - * The code a decoy's OTP "is". Nothing ever compares against it, since a decoy - * verification always fails, but external delivery mode returns the generated code in - * the response body and a decoy has to put something of the right shape there. - */ -export function decoyOtpFor(subject: string) { - return (derive(DECOY_OTP_INFO, subject).readUInt32BE(0) % 1_000_000).toString().padStart(6, '0'); -} - /** * A credential id for the fabricated allow-list a decoy's WebAuthn challenge offers. * Base64url of 32 bytes, matching what a real credential id looks like on the wire. diff --git a/tests/integration/authentication/decoyContinuation.spec.ts b/tests/integration/authentication/decoyContinuation.spec.ts index 2ea95e8..ed45347 100644 --- a/tests/integration/authentication/decoyContinuation.spec.ts +++ b/tests/integration/authentication/decoyContinuation.spec.ts @@ -57,6 +57,7 @@ import { signEphemeralToken } from '../../../src/lib/token.js'; import { WebAuthnChallenge } from '../../../src/models/webauthnChallenges.js'; import { generateEmailOTP, generatePhoneOTP } from '../../../src/utils/otp.js'; import { hashDeviceFingerprint } from '../../../src/utils/utils.js'; +import { mintInternalServiceToken } from '../../factories/serviceTokenFactory.js'; let app: Application; @@ -121,6 +122,25 @@ describe('decoy continuation: OTP', () => { expect(generatePhoneOTP).not.toHaveBeenCalled(); }); + it.each([['/otp/generate-email-otp'], ['/otp/generate-login-email-otp']])( + 'hands the SDK nothing to mail for %s under external delivery', + async (path) => { + // A real send in this mode answers with the address and the code for the SDK to + // mail. A decoy's address is `@example.invalid`, so a block here would be a real + // message the SDK sends and SES bounces fourteen hours later, against the + // adopter's identity. The block is only readable with a service token, so leaving + // it out discloses nothing to a stranger. + const res = await request(app) + .get(path) + .set('x-seamless-auth-delivery-mode', 'external') + .set('x-seamless-service-token', await mintInternalServiceToken()); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ message: 'success', token: 'decoy-token' }); + expect(res.body).not.toHaveProperty('delivery'); + }, + ); + it.each([ ['/otp/verify-email-otp'], ['/otp/verify-phone-otp'], @@ -156,6 +176,17 @@ describe('decoy continuation: magic link', () => { expect(res.body.message).toBe('If an account exists, a login link has been sent.'); }); + it('hands the SDK nothing to mail under external delivery', async () => { + const res = await request(app) + .get('/magic-link') + .set('x-seamless-auth-delivery-mode', 'external') + .set('x-seamless-service-token', await mintInternalServiceToken()); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ message: 'If an account exists, a login link has been sent.' }); + expect(res.body).not.toHaveProperty('delivery'); + }); + it('polls as an unclicked link forever', async () => { const res = await request(app).get('/magic-link/check'); diff --git a/tests/unit/services/decoyPrincipal.spec.ts b/tests/unit/services/decoyPrincipal.spec.ts index 9bb3533..685f585 100644 --- a/tests/unit/services/decoyPrincipal.spec.ts +++ b/tests/unit/services/decoyPrincipal.spec.ts @@ -2,7 +2,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { decoyCredentialIdFor, - decoyOtpFor, decoyPrincipalForSubject, decoySubjectFor, } from '../../../src/services/decoyPrincipal.js'; @@ -121,13 +120,6 @@ describe('decoy principal', () => { }); describe('fabricated secrets', () => { - it('produces a six digit OTP', () => { - const subject = decoySubjectFor('nobody@example.com', 'email'); - - expect(decoyOtpFor(subject)).toMatch(/^\d{6}$/); - expect(decoyOtpFor(subject)).toBe(decoyOtpFor(subject)); - }); - it('produces a credential id shaped like a real one', () => { const subject = decoySubjectFor('nobody@example.com', 'email');