Skip to content

CSRF: OTP generate routes are state-changing GETs reachable cross-site #101

Description

@Bccorb

Summary

Four state-changing OTP routes are still reachable by a simple cross-site GET, which is the same CSRF vector that #93 just closed for /magic-link. A signed-in user visiting a hostile page can be made to send unbounded SMS and email.

The vector

These client methods issue GET requests with credentials: 'include':

  • requestPhoneOtp -> GET /otp/generate-phone-otp (sends an SMS)
  • requestLoginPhoneOtp -> GET /otp/generate-login-phone-otp (sends an SMS)
  • requestEmailOtp -> GET /otp/generate-email-otp (sends an email)
  • requestLoginEmailOtp -> GET /otp/generate-login-email-otp (sends an email)

A bare authenticated GET with no custom headers is a "simple request" in CORS terms, so it needs no preflight and can be triggered cross-site with nothing more than:

<img src="https://api.example.com/auth/otp/generate-email-otp">

Why it is exploitable in production

I confirmed the precondition rather than assuming it. The Express adapter sets the session cookie with:

sameSite: opts.cookieSameSite ?? (secure ? "none" : "lax")

So the production default is SameSite=None, which means the cookie is sent on cross-site GETs. A page the victim visits can fire these requests and the adapter will treat them as authenticated.

Impact

For a signed-in user, an attacker page can trigger repeated SMS and email sends. This is a messaging-abuse and toll-fraud vector (SMS in particular has a per-message cost), and a nuisance-spam vector against the user's own inbox and phone.

Why this should ship with the magic-link fix, not after

#93 fixed exactly this pattern for /magic-link by switching to POST with a JSON body, which forces a CORS preflight and makes the route unreachable cross-site. That fix is correct, but it closes one instance of the vector while leaving four open. Shipping it alone signals the class is understood while the OTP generate routes remain exploitable.

Proposed fix

Same remedy as #93, applied to all four generate routes, coordinated across the SDK and the Express adapter:

  • Adapter: serve POST /otp/generate-* (dropping or deprecating the GET forms), consistent with the magic-link change in seamless-auth-server#103.
  • SDK: change the four requestResult(... method: 'GET' ...) calls to POST with an empty JSON body, matching the requestMagicLink pattern that sends JSON.stringify({}) so fetchWithAuth declares a JSON content type and triggers the preflight.

Both halves must land together, and this has the same adapter-version-lockstep concern as #100.

The check and status GET routes (/magic-link/check, /step-up/status, /totp/status, /oauth/providers, /organizations) are reads, not state changes, so they are out of scope.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions