Skip to content

fix(login): answer a decoy under external delivery with nothing to mail - #322

Merged
Bccorb merged 1 commit into
mainfrom
fix/decoy-external-delivery
Sep 15, 2026
Merged

Bccorb merged 1 commit into
mainfrom
fix/decoy-external-delivery

Conversation

@Bccorb

@Bccorb Bccorb commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #321.

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, because mailing whatever it is handed is its job in that mode. The domain never resolves, so SES 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. RoxTarget took seven of them overnight from one person trying an address with no account.

What changes

  • src/controllers/decoyResponders.ts: respondOtpSent and decoyRequestMagicLink no longer read canReturnExternalDelivery or build a delivery block. A decoy answers { message, token } and { message } whatever the header says. The magic link responder still calls resolveMagicLinkUrl so a disallowed redirect gets the same 400 a real account gets; it discards the URL now, with a comment saying the call is made for its refusal. The file's header comment gains a third invariant, "No delivery block", with the reason.
  • src/services/decoyPrincipal.ts: decoyOtpFor and its HKDF label are removed. Its own doc said it existed only to fill the block.
  • Why omitting is safe: the block is only readable by a caller holding a valid internal service token, so a stranger sees the same answer as before. On the SDK side deliverAuthMessage already returns early when delivery is absent, so no SDK change is needed for the send to stop.
  • What it costs: a decoy skipping the send answers faster at the SDK's edge than a real account. 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, and it is not worth a bounce per probe.

Docs

  • docs/security-posture.md, "What is still observable": the external delivery bullet now says a decoy answers with no block, what the fabrication used to cost, and where the remaining timing difference sits.

Not in this PR

An @example.invalid guard in the SDK's deliverAuthMessage would protect adopters who lag behind this release. It belongs in seamless-auth-server and couples the SDK to a detail of the decoy's construction, so it is left as a follow-up there if wanted. RoxTarget keeps bouncing until this ships and roxtarget-prod-auth moves off v0.14.0.

Test plan

  • npm run typecheck, npm run lint, npm run format:check, npm run check:changeset-bump, npm run build
  • npm run test:run: 1477 passing. Three new cases in decoyContinuation.spec.ts drive external delivery for real (header plus a minted service token) against /otp/generate-email-otp, /otp/generate-login-email-otp and /magic-link and assert no delivery key. Run against the old responder they fail with the block present.
  • Changeset: patch.

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
@Bccorb
Bccorb merged commit b092487 into main Sep 15, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A decoy continuation under external delivery mails a real message to @example.invalid, and every one bounces

1 participant