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
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
GETrequests withcredentials: '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
GETwith 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:Why it is exploitable in production
I confirmed the precondition rather than assuming it. The Express adapter sets the session cookie with:
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-linkby switching toPOSTwith 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:
POST /otp/generate-*(dropping or deprecating the GET forms), consistent with the magic-link change in seamless-auth-server#103.requestResult(... method: 'GET' ...)calls toPOSTwith an empty JSON body, matching therequestMagicLinkpattern that sendsJSON.stringify({})sofetchWithAuthdeclares 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
checkandstatusGET routes (/magic-link/check,/step-up/status,/totp/status,/oauth/providers,/organizations) are reads, not state changes, so they are out of scope.Related