diff --git a/.changeset/otp-generate-csrf.md b/.changeset/otp-generate-csrf.md new file mode 100644 index 0000000..2dfe5fb --- /dev/null +++ b/.changeset/otp-generate-csrf.md @@ -0,0 +1,9 @@ +--- +"@seamless-auth/express": minor +--- + +Serve the OTP generate routes over POST instead of GET. + +`GET /auth/otp/generate-phone-otp`, `-email-otp`, and their `-login-` variants were state-changing routes (each sends an SMS or email) reachable as a simple cross-site request, so an `` on any page could trigger unbounded OTP messages to a signed-in user. This is the same vector already closed for `/auth/magic-link`. + +BREAKING: the four `GET /auth/otp/generate-*` routes are removed and replaced with POST. Pair this with `@seamless-auth/react` 0.5.0 or later, which requests them over POST. An older SDK that still issues GET will get a 404. diff --git a/packages/express/README.md b/packages/express/README.md index 7eaef0d..5003545 100644 --- a/packages/express/README.md +++ b/packages/express/README.md @@ -175,11 +175,14 @@ Routes include: - `DELETE /auth/logout` for the current session - `DELETE /auth/logout/all` for every session owned by the current user - `POST /auth/magic-link` to request a magic-link email - -`GET /auth/logout` and `GET /auth/magic-link` were removed. Both were -state-changing routes reachable as simple cross-site requests (an `` on -any page could revoke every session or trigger magic-link emails). Use -`DELETE /auth/logout/all` and `POST /auth/magic-link` instead. +- `POST /auth/otp/generate-phone-otp`, `-email-otp`, and their `-login-` variants to send an OTP + +`GET /auth/logout` and `GET /auth/magic-link` were removed, and the four +`GET /auth/otp/generate-*` routes are now POST only. All were state-changing +routes reachable as simple cross-site requests (an `` on any page could +revoke every session, trigger magic-link emails, or send unbounded OTP SMS and +email). Use `DELETE /auth/logout/all`, `POST /auth/magic-link`, and the POST +generate routes instead. **Options** diff --git a/packages/express/src/createServer.ts b/packages/express/src/createServer.ts index e3fa431..a70cd52 100644 --- a/packages/express/src/createServer.ts +++ b/packages/express/src/createServer.ts @@ -341,16 +341,16 @@ export function createSeamlessAuthServer( verifyLoginOtp(req, res, resolvedOpts, "email"), ); - r.get("/otp/generate-phone-otp", (req, res) => + r.post("/otp/generate-phone-otp", (req, res) => requestOtp(req, res, resolvedOpts, "phone"), ); - r.get("/otp/generate-email-otp", (req, res) => + r.post("/otp/generate-email-otp", (req, res) => requestOtp(req, res, resolvedOpts, "email"), ); - r.get("/otp/generate-login-phone-otp", (req, res) => + r.post("/otp/generate-login-phone-otp", (req, res) => requestOtp(req, res, resolvedOpts, "phone", "login"), ); - r.get("/otp/generate-login-email-otp", (req, res) => + r.post("/otp/generate-login-email-otp", (req, res) => requestOtp(req, res, resolvedOpts, "email", "login"), ); diff --git a/packages/express/tests/loginOtpRoutes.test.js b/packages/express/tests/loginOtpRoutes.test.js index ee1ae99..007ab7b 100644 --- a/packages/express/tests/loginOtpRoutes.test.js +++ b/packages/express/tests/loginOtpRoutes.test.js @@ -62,7 +62,7 @@ describe("login OTP routes", () => { ); const res = await request(createApp()) - .get("/auth/otp/generate-login-email-otp") + .post("/auth/otp/generate-login-email-otp") .set("Cookie", createPreAuthCookie()); expect(res.status).toBe(200); @@ -105,4 +105,25 @@ describe("login OTP routes", () => { }), ); }); + // The generate routes are state changing (they send an SMS or email). Serving + // them over GET made them reachable as a simple cross-site request, so an + // tag could trigger unbounded sends to a signed-in user. They are now + // POST only. Carries the pre-auth cookie so the cookie middleware does not + // answer 400 before routing, which would pass whether or not the route exists. + it("no longer exposes the generate routes over GET", async () => { + for (const path of [ + "/auth/otp/generate-phone-otp", + "/auth/otp/generate-email-otp", + "/auth/otp/generate-login-phone-otp", + "/auth/otp/generate-login-email-otp", + ]) { + const res = await request(createApp()) + .get(path) + .set("Cookie", createPreAuthCookie()); + + expect([404, 405]).toContain(res.status); + } + + expect(global.fetch).not.toHaveBeenCalled(); + }); });