diff --git a/docs/content/docs/reference/security.mdx b/docs/content/docs/reference/security.mdx index 4e401bdd0f..df374761ef 100644 --- a/docs/content/docs/reference/security.mdx +++ b/docs/content/docs/reference/security.mdx @@ -35,8 +35,27 @@ Better Auth includes multiple safeguards to prevent Cross-Site Request Forgery ( 3. **Secure Cookie Settings** Session cookies use the `SameSite=Lax` attribute by default, preventing browsers from sending cookies with most cross-site requests. You can override this behavior using the `defaultCookieAttributes` option. -4. **No Mutations on GET Requests (with additional safeguards)** - `GET` requests are assumed to be read-only and should not alter the application’s state. In cases where a `GET` request must perform a mutation—such as during OAuth callbacks - Better Auth applies extra security measures, including validating `nonce` and `state` parameters to ensure the request’s authenticity. +4. **Fetch Metadata Protection for First-Login CSRF** + Better Auth also uses [Fetch Metadata headers](https://developer.mozilla.org/en-US/docs/Web/Security/Attacks/CSRF#fetch_metadata) (`Sec-Fetch-Site`, `Sec-Fetch-Mode`, `Sec-Fetch-Dest`) to provide additional CSRF protection specifically for **first-login scenarios**, where the client does not yet have any cookies. + + This protection applies to **sign-in and sign-up email routes**, which accept form submissions (simple requests) to support progressive enhancement patterns. + + When a sign-in or sign-up request is received **without** any cookies: + + - If the browser indicates a **cross-site navigation** (for example: `Sec-Fetch-Site: cross-site` and `Sec-Fetch-Mode: navigate`), Better Auth **blocks** the request as a potential login CSRF attack. + + - If the request is **same-origin** or **same-site**, Better Auth still validates the `Origin`/`Referer` against your `trustedOrigins` configuration, using the existing origin validation logic. + + - If the client does **not** send Fetch Metadata headers (older browsers, some mobile WebViews, non-browser clients), Better Auth falls back to its **previous behavior** and only performs origin validation when cookies are present, preserving backward compatibility. + + This mechanism allows modern browsers to receive stronger protection against first-login CSRF attacks **without requiring CSRF tokens or client-side JavaScript**, and works seamlessly with progressive enhancement patterns. + + + Non-browser HTTP clients (mobile apps, server-to-server) should set the `Origin` or `Referer` header appropriately when making requests that include cookies. This ensures proper CSRF validation regardless of Fetch Metadata support. + + +5. **No Mutations on GET Requests (with additional safeguards)** + `GET` requests are assumed to be read-only and should not alter the application's state. In cases where a `GET` request must perform a mutation—such as during OAuth callbacks - Better Auth applies extra security measures, including validating `nonce` and `state` parameters to ensure the request's authenticity. You can skip the CSRF check for all requests by setting the `disableCSRFCheck` option to `true` in the configuration. diff --git a/e2e/integration/solid-vinxi/src/lib/auth.ts b/e2e/integration/solid-vinxi/src/lib/auth.ts index 88fc1ef5f8..27c9b42f0c 100644 --- a/e2e/integration/solid-vinxi/src/lib/auth.ts +++ b/e2e/integration/solid-vinxi/src/lib/auth.ts @@ -11,6 +11,9 @@ export const auth = betterAuth({ emailAndPassword: { enabled: true, }, + trustedOrigins: [ + "http://test.com:3000", // Playwright host mapping if used + ], }); const { runMigrations } = await getMigrations(auth.options); diff --git a/e2e/integration/vanilla-node/e2e/app.ts b/e2e/integration/vanilla-node/e2e/app.ts index 85ee9e57b9..4f884aa1a3 100644 --- a/e2e/integration/vanilla-node/e2e/app.ts +++ b/e2e/integration/vanilla-node/e2e/app.ts @@ -15,6 +15,11 @@ export async function createAuthServer( emailAndPassword: { enabled: true, }, + trustedOrigins: [ + baseURL, + "http://localhost:*", // Dynamic frontend port + "http://test.com:*", // Cross-domain test + ], }); const { runMigrations } = await getMigrations(auth.options); diff --git a/e2e/smoke/test/bun.spec.ts b/e2e/smoke/test/bun.spec.ts index b971c0e186..668a8014b3 100644 --- a/e2e/smoke/test/bun.spec.ts +++ b/e2e/smoke/test/bun.spec.ts @@ -41,6 +41,7 @@ describe("(bun) simple server", () => { }), headers: { "content-type": "application/json", + origin: `http://localhost:${port}`, }, }, ); diff --git a/e2e/smoke/test/deno.spec.ts b/e2e/smoke/test/deno.spec.ts index 64957e0d0e..c5801c676f 100644 --- a/e2e/smoke/test/deno.spec.ts +++ b/e2e/smoke/test/deno.spec.ts @@ -40,6 +40,7 @@ describe("(deno) simple server", () => { }), headers: { "content-type": "application/json", + origin: `http://localhost:${port}`, }, }, ); diff --git a/e2e/smoke/test/fixtures/bun-simple.ts b/e2e/smoke/test/fixtures/bun-simple.ts index 691c9897ae..0db3d335b0 100644 --- a/e2e/smoke/test/fixtures/bun-simple.ts +++ b/e2e/smoke/test/fixtures/bun-simple.ts @@ -10,6 +10,9 @@ export const auth = betterAuth({ emailAndPassword: { enabled: true, }, + trustedOrigins: [ + "http://localhost:*", // Allow any localhost port for smoke tests + ], logger: { level: "debug", }, diff --git a/e2e/smoke/test/fixtures/deno-simple.ts b/e2e/smoke/test/fixtures/deno-simple.ts index f6e74055c6..c6d3c849cb 100644 --- a/e2e/smoke/test/fixtures/deno-simple.ts +++ b/e2e/smoke/test/fixtures/deno-simple.ts @@ -10,6 +10,9 @@ export const auth = betterAuth({ emailAndPassword: { enabled: true, }, + trustedOrigins: [ + "http://localhost:*", // Allow any localhost port for smoke tests + ], logger: { level: "debug", }, diff --git a/packages/better-auth/src/api/middlewares/origin-check.test.ts b/packages/better-auth/src/api/middlewares/origin-check.test.ts index d0fda6e60f..a79905a1bc 100644 --- a/packages/better-auth/src/api/middlewares/origin-check.test.ts +++ b/packages/better-auth/src/api/middlewares/origin-check.test.ts @@ -2,6 +2,7 @@ import { createAuthEndpoint } from "@better-auth/core/api"; import { describe, expect, vi } from "vitest"; import * as z from "zod"; import { createAuthClient } from "../../client"; +import { parseSetCookieHeader } from "../../cookies"; import { getTestInstance } from "../../test-utils/test-instance"; import { originCheck } from "./origin-check"; @@ -204,6 +205,256 @@ describe("Origin Check", async (it) => { }); }); +describe("Fetch Metadata CSRF Protection", async (it) => { + const { customFetchImpl, testUser, auth, signInWithTestUser } = + await getTestInstance({ + trustedOrigins: ["http://localhost:3000", "https://app.example.com"], + emailAndPassword: { + enabled: true, + async sendResetPassword(url, user) {}, + }, + advanced: { + disableCSRFCheck: false, + disableOriginCheck: false, + }, + }); + + it("should block cross-site navigation on first-login (no session cookie)", async (ctx) => { + const maliciousRequest = new Request( + "http://localhost:3000/api/auth/sign-in/email", + { + method: "POST", + headers: { + "content-type": "application/json", + "Sec-Fetch-Site": "cross-site", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Dest": "document", + origin: "https://evil.com", + }, + body: JSON.stringify({ + email: "attacker@evil.com", + password: "password123", + }), + }, + ); + + const response = await auth.handler(maliciousRequest); + expect(response.status).toBe(403); + const error = await response.json(); + expect(error.message).toBe( + "Cross-site navigation login blocked. This request appears to be a CSRF attack.", + ); + }); + + it("should allow same-origin navigation on first-login (no session cookie)", async (ctx) => { + const legitimateRequest = new Request( + "http://localhost:3000/api/auth/sign-in/email", + { + method: "POST", + headers: { + "content-type": "application/json", + "Sec-Fetch-Site": "same-origin", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Dest": "document", + origin: "http://localhost:3000", + }, + body: JSON.stringify({ + email: testUser.email, + password: testUser.password, + }), + }, + ); + + const response = await auth.handler(legitimateRequest); + + expect(response.status).not.toBe(403); + const error = await response.json().catch(() => null); + if (error?.message) { + expect(error.message).not.toBe( + "Cross-site navigation login blocked. This request appears to be a CSRF attack.", + ); + } + }); + + it("should allow same-site navigation on first-login (no session cookie)", async (ctx) => { + const legitimateRequest = new Request( + "http://localhost:3000/api/auth/sign-in/email", + { + method: "POST", + headers: { + "content-type": "application/json", + "Sec-Fetch-Site": "same-site", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Dest": "document", + origin: "https://app.example.com", + }, + body: JSON.stringify({ + email: testUser.email, + password: testUser.password, + }), + }, + ); + + const response = await auth.handler(legitimateRequest); + + expect(response.status).not.toBe(403); + const error = await response.json().catch(() => null); + if (error?.message) { + expect(error.message).not.toBe( + "Cross-site navigation login blocked. This request appears to be a CSRF attack.", + ); + } + }); + + it("should fallback to origin validation when Fetch Metadata is missing", async (ctx) => { + const requestWithoutMetadata = new Request( + "http://localhost:3000/api/auth/sign-in/email", + { + method: "POST", + headers: { + "content-type": "application/json", + origin: "http://localhost:3000", + }, + body: JSON.stringify({ + email: testUser.email, + password: testUser.password, + }), + }, + ); + + const response = await auth.handler(requestWithoutMetadata); + + expect(response.status).not.toBe(403); + }); + + it("should use existing origin validation when session cookie exists", async (ctx) => { + const signInResponse = await auth.api.signInEmail({ + body: { + email: testUser.email, + password: testUser.password, + }, + asResponse: true, + }); + + const setCookieHeader = signInResponse.headers.get("set-cookie"); + const cookies = parseSetCookieHeader(setCookieHeader || ""); + const sessionCookie = cookies.get("better-auth.session_token"); + if (!sessionCookie) { + throw new Error("Failed to get session cookie"); + } + + const requestWithSession = new Request( + "http://localhost:3000/api/auth/sign-in/email", + { + method: "POST", + headers: { + "content-type": "application/json", + cookie: `better-auth.session_token=${sessionCookie.value}`, + "Sec-Fetch-Site": "cross-site", + "Sec-Fetch-Mode": "navigate", + origin: "http://localhost:3000", + }, + body: JSON.stringify({ + email: testUser.email, + password: testUser.password, + }), + }, + ); + + const response = await auth.handler(requestWithSession); + + expect(response.status).not.toBe(403); + }); + + it("should block cross-site navigation for sign-up endpoint", async (ctx) => { + const maliciousRequest = new Request( + "http://localhost:3000/api/auth/sign-up/email", + { + method: "POST", + headers: { + "content-type": "application/json", + "Sec-Fetch-Site": "cross-site", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Dest": "document", + origin: "https://evil.com", + }, + body: JSON.stringify({ + email: "attacker@evil.com", + password: "password123", + name: "Attacker", + }), + }, + ); + + const response = await auth.handler(maliciousRequest); + expect(response.status).toBe(403); + const error = await response.json(); + expect(error.message).toBe( + "Cross-site navigation login blocked. This request appears to be a CSRF attack.", + ); + }); + + it("should allow cors mode requests (fetch/XHR)", async (ctx) => { + const fetchRequest = new Request( + "http://localhost:3000/api/auth/sign-in/email", + { + method: "POST", + headers: { + "content-type": "application/json", + "Sec-Fetch-Site": "same-origin", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Dest": "empty", + origin: "http://localhost:3000", + }, + body: JSON.stringify({ + email: testUser.email, + password: testUser.password, + }), + }, + ); + + const response = await auth.handler(fetchRequest); + + expect(response.status).not.toBe(403); + const error = await response.json().catch(() => null); + if (error?.message) { + expect(error.message).not.toBe( + "Cross-site navigation login blocked. This request appears to be a CSRF attack.", + ); + } + }); + + it("should allow requests with expired session cookie (cookie presence check)", async (ctx) => { + const requestWithExpiredCookie = new Request( + "http://localhost:3000/api/auth/sign-in/email", + { + method: "POST", + headers: { + "content-type": "application/json", + cookie: "better-auth.session_token=expired_or_invalid_token", + "Sec-Fetch-Site": "cross-site", + "Sec-Fetch-Mode": "navigate", + origin: "http://localhost:3000", + }, + body: JSON.stringify({ + email: testUser.email, + password: testUser.password, + }), + }, + ); + + const response = await auth.handler(requestWithExpiredCookie); + + expect(response.status).not.toBe(403); + const error = await response.json().catch(() => null); + if (error?.message) { + expect(error.message).not.toBe( + "Cross-site navigation login blocked. This request appears to be a CSRF attack.", + ); + } + }); +}); + describe("origin check middleware", async (it) => { it("should return invalid origin", async () => { const { client } = await getTestInstance({ diff --git a/packages/better-auth/src/api/middlewares/origin-check.ts b/packages/better-auth/src/api/middlewares/origin-check.ts index 114fa889b0..161d268d76 100644 --- a/packages/better-auth/src/api/middlewares/origin-check.ts +++ b/packages/better-auth/src/api/middlewares/origin-check.ts @@ -1,10 +1,12 @@ import type { GenericEndpointContext } from "@better-auth/core"; import { createAuthMiddleware } from "@better-auth/core/api"; +import { BASE_ERROR_CODES } from "@better-auth/core/error"; import { APIError } from "better-call"; +import { matchesOriginPattern } from "../../auth/trusted-origins"; /** - * A middleware to validate callbackURL and origin against - * trustedOrigins. + * A middleware to validate callbackURL and origin against trustedOrigins. + * Also handles CSRF protection using Fetch Metadata for first-login scenarios. */ export const originCheckMiddleware = createAuthMiddleware(async (ctx) => { // Skip origin check for GET, OPTIONS, HEAD requests - we don't mutate state here. @@ -16,14 +18,13 @@ export const originCheckMiddleware = createAuthMiddleware(async (ctx) => { ) { return; } - const headers = ctx.request?.headers; + await validateOrigin(ctx); + const { body, query } = ctx; - const originHeader = headers?.get("origin") || headers?.get("referer") || ""; const callbackURL = body?.callbackURL || query?.callbackURL; const redirectURL = body?.redirectTo; const errorCallbackURL = body?.errorCallbackURL; const newUserCallbackURL = body?.newUserCallbackURL; - const useCookies = headers?.has("cookie"); const validateURL = (url: string | undefined, label: string) => { if (!url) { @@ -42,16 +43,7 @@ export const originCheckMiddleware = createAuthMiddleware(async (ctx) => { throw new APIError("FORBIDDEN", { message: `Invalid ${label}` }); } }; - if ( - useCookies && - !ctx.context.skipCSRFCheck && - !ctx.context.skipOriginCheck - ) { - if (!originHeader || originHeader === "null") { - throw new APIError("FORBIDDEN", { message: "Missing or null Origin" }); - } - validateURL(originHeader, "origin"); - } + callbackURL && validateURL(callbackURL, "callbackURL"); redirectURL && validateURL(redirectURL, "redirectURL"); errorCallbackURL && validateURL(errorCallbackURL, "errorCallbackURL"); @@ -88,3 +80,114 @@ export const originCheck = ( validateURL(url, "callbackURL"); } }); + +/** + * Validates origin header against trusted origins. + * @param ctx - The endpoint context + * @param forceValidate - If true, always validate origin regardless of cookies/skip flags + */ +async function validateOrigin( + ctx: GenericEndpointContext, + forceValidate = false, +): Promise { + const headers = ctx.request?.headers; + if (!headers || !ctx.request) { + return; + } + const originHeader = headers.get("origin") || headers.get("referer") || ""; + const useCookies = headers.has("cookie"); + + const shouldValidate = + forceValidate || + (useCookies && !ctx.context.skipCSRFCheck && !ctx.context.skipOriginCheck); + + if (!shouldValidate) { + return; + } + + if (!originHeader || originHeader === "null") { + throw new APIError("FORBIDDEN", { message: "Missing or null Origin" }); + } + + const trustedOrigins: string[] = Array.isArray( + ctx.context.options.trustedOrigins, + ) + ? ctx.context.trustedOrigins + : [ + ...ctx.context.trustedOrigins, + ...((await ctx.context.options.trustedOrigins?.(ctx.request)) || []), + ]; + + const isTrustedOrigin = trustedOrigins.some((origin) => + matchesOriginPattern(originHeader, origin), + ); + if (!isTrustedOrigin) { + ctx.context.logger.error(`Invalid origin: ${originHeader}`); + ctx.context.logger.info( + `If it's a valid URL, please add ${originHeader} to trustedOrigins in your auth config\n`, + `Current list of trustedOrigins: ${trustedOrigins}`, + ); + throw new APIError("FORBIDDEN", { message: "Invalid origin" }); + } +} + +/** + * Middleware for CSRF protection using Fetch Metadata headers. + * This prevents cross-site navigation login attacks while supporting progressive enhancement. + */ +export const formCsrfMiddleware = createAuthMiddleware(async (ctx) => { + const request = ctx.request; + if (!request) { + return; + } + + await validateFormCsrf(ctx); +}); + +/** + * Validates CSRF protection for first-login scenarios using Fetch Metadata headers. + * This prevents cross-site form submission attacks while supporting progressive enhancement. + */ +async function validateFormCsrf(ctx: GenericEndpointContext): Promise { + const req = ctx.request; + if (!req) { + return; + } + + const headers = req.headers; + const hasAnyCookies = headers.has("cookie"); + + if (hasAnyCookies) { + return await validateOrigin(ctx); + } + + const site = headers.get("Sec-Fetch-Site"); + const mode = headers.get("Sec-Fetch-Mode"); + const dest = headers.get("Sec-Fetch-Dest"); + + const hasMetadata = Boolean( + (site && site.trim()) || (mode && mode.trim()) || (dest && dest.trim()), + ); + + if (hasMetadata) { + // Block cross-site navigation requests (classic CSRF attack pattern) + if (site === "cross-site" && mode === "navigate") { + ctx.context.logger.error( + "Blocked cross-site navigation login attempt (CSRF protection)", + { + secFetchSite: site, + secFetchMode: mode, + secFetchDest: dest, + }, + ); + throw new APIError("FORBIDDEN", { + message: BASE_ERROR_CODES.CROSS_SITE_NAVIGATION_LOGIN_BLOCKED, + }); + } + + return await validateOrigin(ctx, true); + } + + // No cookies, no Fetch Metadata → fallback to old behavior (no validation) + return; +} diff --git a/packages/better-auth/src/api/routes/sign-in.test.ts b/packages/better-auth/src/api/routes/sign-in.test.ts index 4d2a3b55a6..651c47652b 100644 --- a/packages/better-auth/src/api/routes/sign-in.test.ts +++ b/packages/better-auth/src/api/routes/sign-in.test.ts @@ -137,3 +137,194 @@ describe("url checks", async (it) => { ); }); }); + +describe("sign-in CSRF protection", async (it) => { + const { auth, testUser } = await getTestInstance({ + trustedOrigins: ["http://localhost:3000"], + emailAndPassword: { + enabled: true, + }, + }); + + it("should block cross-site navigation login attempts (no cookies)", async () => { + const maliciousRequest = new Request( + "http://localhost:3000/api/auth/sign-in/email", + { + method: "POST", + headers: { + "content-type": "application/json", + "Sec-Fetch-Site": "cross-site", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Dest": "document", + origin: "https://evil.com", + }, + body: JSON.stringify({ + email: "attacker@evil.com", + password: "password123", + }), + }, + ); + + const response = await auth.handler(maliciousRequest); + expect(response.status).toBe(403); + const error = await response.json(); + expect(error.message).toBe( + BASE_ERROR_CODES.CROSS_SITE_NAVIGATION_LOGIN_BLOCKED, + ); + }); + + it("should allow same-origin navigation login attempts", async () => { + const legitimateRequest = new Request( + "http://localhost:3000/api/auth/sign-in/email", + { + method: "POST", + headers: { + "content-type": "application/json", + "Sec-Fetch-Site": "same-origin", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Dest": "document", + origin: "http://localhost:3000", + }, + body: JSON.stringify({ + email: testUser.email, + password: testUser.password, + }), + }, + ); + + const response = await auth.handler(legitimateRequest); + expect(response.status).not.toBe(403); + }); + + it("should allow fetch/XHR requests (cors mode)", async () => { + const fetchRequest = new Request( + "http://localhost:3000/api/auth/sign-in/email", + { + method: "POST", + headers: { + "content-type": "application/json", + "Sec-Fetch-Site": "same-origin", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Dest": "empty", + origin: "http://localhost:3000", + }, + body: JSON.stringify({ + email: testUser.email, + password: testUser.password, + }), + }, + ); + + const response = await auth.handler(fetchRequest); + expect(response.status).not.toBe(403); + }); + + it("should use origin validation when cookies exist", async () => { + const requestWithCookies = new Request( + "http://localhost:3000/api/auth/sign-in/email", + { + method: "POST", + headers: { + "content-type": "application/json", + cookie: "some_cookie=value", + "Sec-Fetch-Site": "cross-site", + "Sec-Fetch-Mode": "navigate", + origin: "http://localhost:3000", + }, + body: JSON.stringify({ + email: testUser.email, + password: testUser.password, + }), + }, + ); + + const response = await auth.handler(requestWithCookies); + // Should not be blocked by CSRF check since cookies exist - uses origin validation instead + expect(response.status).not.toBe(403); + }); +}); + +describe("sign-in with form data", async (it) => { + const { auth, testUser } = await getTestInstance({ + trustedOrigins: ["http://localhost:3000"], + emailAndPassword: { + enabled: true, + }, + }); + + it("should accept form-urlencoded content type", async () => { + const formRequest = new Request( + "http://localhost:3000/api/auth/sign-in/email", + { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + "Sec-Fetch-Site": "same-origin", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Dest": "document", + origin: "http://localhost:3000", + }, + body: new URLSearchParams({ + email: testUser.email, + password: testUser.password, + }), + }, + ); + + const response = await auth.handler(formRequest); + expect(response.status).toBe(200); + const data = await response.json(); + expect(data.token).toBeDefined(); + expect(data.user.email).toBe(testUser.email); + }); + + it("should block cross-site form submissions", async () => { + const maliciousFormRequest = new Request( + "http://localhost:3000/api/auth/sign-in/email", + { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + "Sec-Fetch-Site": "cross-site", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Dest": "document", + origin: "https://evil.com", + }, + body: new URLSearchParams({ + email: "attacker@evil.com", + password: "password123", + }), + }, + ); + + const response = await auth.handler(maliciousFormRequest); + expect(response.status).toBe(403); + const error = await response.json(); + expect(error.message).toBe( + BASE_ERROR_CODES.CROSS_SITE_NAVIGATION_LOGIN_BLOCKED, + ); + }); + + it("should allow same-site form submissions from trusted origins", async () => { + const formRequest = new Request( + "http://localhost:3000/api/auth/sign-in/email", + { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + "Sec-Fetch-Site": "same-site", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Dest": "document", + origin: "http://localhost:3000", + }, + body: new URLSearchParams({ + email: testUser.email, + password: testUser.password, + }), + }, + ); + + const response = await auth.handler(formRequest); + expect(response.status).toBe(200); + }); +}); diff --git a/packages/better-auth/src/api/routes/sign-in.ts b/packages/better-auth/src/api/routes/sign-in.ts index 17b865f417..633cf00be9 100644 --- a/packages/better-auth/src/api/routes/sign-in.ts +++ b/packages/better-auth/src/api/routes/sign-in.ts @@ -9,6 +9,7 @@ import { parseUserOutput } from "../../db/schema"; import { handleOAuthUserInfo } from "../../oauth2/link-account"; import type { InferUser } from "../../types"; import { generateState } from "../../utils"; +import { formCsrfMiddleware } from "../middlewares/origin-check"; import { createEmailVerificationToken } from "./email-verification"; const socialSignInBodySchema = z.object({ @@ -333,6 +334,7 @@ export const signInEmail = () => { method: "POST", operationId: "signInEmail", + use: [formCsrfMiddleware], body: z.object({ /** * Email of the user @@ -371,6 +373,10 @@ export const signInEmail = () => .optional(), }), metadata: { + allowedMediaTypes: [ + "application/x-www-form-urlencoded", + "application/json", + ], $Infer: { body: {} as { email: string; diff --git a/packages/better-auth/src/api/routes/sign-up.test.ts b/packages/better-auth/src/api/routes/sign-up.test.ts index 546edb1561..b73b09453c 100644 --- a/packages/better-auth/src/api/routes/sign-up.test.ts +++ b/packages/better-auth/src/api/routes/sign-up.test.ts @@ -1,3 +1,4 @@ +import { BASE_ERROR_CODES } from "@better-auth/core/error"; import { afterEach, describe, expect, vi } from "vitest"; import { getTestInstance } from "../../test-utils/test-instance"; @@ -158,8 +159,216 @@ describe("sign-up with custom fields", async (it) => { }), ).rejects.toThrowError( expect.objectContaining({ - status: 400, + statusCode: 400, }), ); }); }); + +describe("sign-up CSRF protection", async (it) => { + const { auth } = await getTestInstance( + { + trustedOrigins: ["http://localhost:3000"], + emailAndPassword: { + enabled: true, + }, + }, + { + disableTestUser: true, + }, + ); + + it("should block cross-site navigation sign-up attempts (no cookies)", async () => { + const maliciousRequest = new Request( + "http://localhost:3000/api/auth/sign-up/email", + { + method: "POST", + headers: { + "content-type": "application/json", + "Sec-Fetch-Site": "cross-site", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Dest": "document", + origin: "https://evil.com", + }, + body: JSON.stringify({ + email: "victim@example.com", + password: "password123", + name: "Victim", + }), + }, + ); + + const response = await auth.handler(maliciousRequest); + expect(response.status).toBe(403); + const error = await response.json(); + expect(error.message).toBe( + BASE_ERROR_CODES.CROSS_SITE_NAVIGATION_LOGIN_BLOCKED, + ); + }); + + it("should allow same-origin navigation sign-up attempts", async () => { + const legitimateRequest = new Request( + "http://localhost:3000/api/auth/sign-up/email", + { + method: "POST", + headers: { + "content-type": "application/json", + "Sec-Fetch-Site": "same-origin", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Dest": "document", + origin: "http://localhost:3000", + }, + body: JSON.stringify({ + email: "newuser@example.com", + password: "password123", + name: "New User", + }), + }, + ); + + const response = await auth.handler(legitimateRequest); + expect(response.status).not.toBe(403); + }); + + it("should allow fetch/XHR sign-up requests (cors mode)", async () => { + const fetchRequest = new Request( + "http://localhost:3000/api/auth/sign-up/email", + { + method: "POST", + headers: { + "content-type": "application/json", + "Sec-Fetch-Site": "same-origin", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Dest": "empty", + origin: "http://localhost:3000", + }, + body: JSON.stringify({ + email: "fetchuser@example.com", + password: "password123", + name: "Fetch User", + }), + }, + ); + + const response = await auth.handler(fetchRequest); + expect(response.status).not.toBe(403); + }); + + it("should use origin validation when cookies exist", async () => { + const requestWithCookies = new Request( + "http://localhost:3000/api/auth/sign-up/email", + { + method: "POST", + headers: { + "content-type": "application/json", + cookie: "some_cookie=value", + "Sec-Fetch-Site": "cross-site", + "Sec-Fetch-Mode": "navigate", + origin: "http://localhost:3000", + }, + body: JSON.stringify({ + email: "cookieuser@example.com", + password: "password123", + name: "Cookie User", + }), + }, + ); + + const response = await auth.handler(requestWithCookies); + // Should not be blocked by CSRF check since cookies exist - uses origin validation instead + expect(response.status).not.toBe(403); + }); +}); + +describe("sign-up with form data", async (it) => { + const { auth } = await getTestInstance( + { + trustedOrigins: ["http://localhost:3000"], + emailAndPassword: { + enabled: true, + }, + }, + { + disableTestUser: true, + }, + ); + + it("should accept form-urlencoded content type", async () => { + const formRequest = new Request( + "http://localhost:3000/api/auth/sign-up/email", + { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + "Sec-Fetch-Site": "same-origin", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Dest": "document", + origin: "http://localhost:3000", + }, + body: new URLSearchParams({ + email: "formuser@example.com", + password: "password123", + name: "Form User", + }), + }, + ); + + const response = await auth.handler(formRequest); + expect(response.status).toBe(200); + const data = await response.json(); + expect(data.token).toBeDefined(); + expect(data.user.email).toBe("formuser@example.com"); + }); + + it("should block cross-site form submissions", async () => { + const maliciousFormRequest = new Request( + "http://localhost:3000/api/auth/sign-up/email", + { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + "Sec-Fetch-Site": "cross-site", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Dest": "document", + origin: "https://evil.com", + }, + body: new URLSearchParams({ + email: "victim@example.com", + password: "password123", + name: "Victim", + }), + }, + ); + + const response = await auth.handler(maliciousFormRequest); + expect(response.status).toBe(403); + const error = await response.json(); + expect(error.message).toBe( + BASE_ERROR_CODES.CROSS_SITE_NAVIGATION_LOGIN_BLOCKED, + ); + }); + + it("should allow same-site form submissions from trusted origins", async () => { + const formRequest = new Request( + "http://localhost:3000/api/auth/sign-up/email", + { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + "Sec-Fetch-Site": "same-site", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Dest": "document", + origin: "http://localhost:3000", + }, + body: new URLSearchParams({ + email: "samesiteuser@example.com", + password: "password123", + name: "Same Site User", + }), + }, + ); + + const response = await auth.handler(formRequest); + expect(response.status).toBe(200); + }); +}); diff --git a/packages/better-auth/src/api/routes/sign-up.ts b/packages/better-auth/src/api/routes/sign-up.ts index 191c3f445d..27f1052059 100644 --- a/packages/better-auth/src/api/routes/sign-up.ts +++ b/packages/better-auth/src/api/routes/sign-up.ts @@ -9,6 +9,8 @@ import { setSessionCookie } from "../../cookies"; import { parseUserInput } from "../../db"; import { parseUserOutput } from "../../db/schema"; import type { AdditionalUserFieldsInput, InferUser, User } from "../../types"; + +import { formCsrfMiddleware } from "../middlewares/origin-check"; import { createEmailVerificationToken } from "./email-verification"; const signUpEmailBodySchema = z @@ -28,8 +30,13 @@ export const signUpEmail = () => { method: "POST", operationId: "signUpWithEmailAndPassword", - body: signUpEmailBodySchema, + use: [formCsrfMiddleware], + body: z.record(z.string(), z.any()), metadata: { + allowedMediaTypes: [ + "application/x-www-form-urlencoded", + "application/json", + ], $Infer: { body: {} as { name: string; @@ -204,6 +211,12 @@ export const signUpEmail = () => }); } + if (!password || typeof password !== "string") { + throw new APIError("BAD_REQUEST", { + message: BASE_ERROR_CODES.INVALID_PASSWORD, + }); + } + const minPasswordLength = ctx.context.password.config.minPasswordLength; if (password.length < minPasswordLength) { ctx.context.logger.error("Password is too short"); diff --git a/packages/better-auth/src/api/to-auth-endpoints.ts b/packages/better-auth/src/api/to-auth-endpoints.ts index fd9fd2db7a..0c69ae1a89 100644 --- a/packages/better-auth/src/api/to-auth-endpoints.ts +++ b/packages/better-auth/src/api/to-auth-endpoints.ts @@ -6,7 +6,6 @@ import { runWithRequestState, } from "@better-auth/core/context"; import { shouldPublishLog } from "@better-auth/core/env"; -import { APIError } from "@better-auth/core/error"; import type { EndpointContext, EndpointOptions, diff --git a/packages/core/src/error/codes.ts b/packages/core/src/error/codes.ts index 0f9e1043a3..75153b5e6d 100644 --- a/packages/core/src/error/codes.ts +++ b/packages/core/src/error/codes.ts @@ -28,4 +28,23 @@ export const BASE_ERROR_CODES = defineErrorCodes({ ACCOUNT_NOT_FOUND: "Account not found", USER_ALREADY_HAS_PASSWORD: "User already has a password. Provide that to delete the account.", + CROSS_SITE_NAVIGATION_LOGIN_BLOCKED: + "Cross-site navigation login blocked. This request appears to be a CSRF attack.", + VERIFICATION_EMAIL_NOT_ENABLED: "Verification email isn't enabled", + EMAIL_ALREADY_VERIFIED: "Email is already verified", + EMAIL_MISMATCH: "Email mismatch", + SESSION_NOT_FRESH: "Session is not fresh", + LINKED_ACCOUNT_ALREADY_EXISTS: "Linked account already exists", + INVALID_ORIGIN: "Invalid origin", + INVALID_CALLBACK_URL: "Invalid callbackURL", + INVALID_REDIRECT_URL: "Invalid redirectURL", + INVALID_ERROR_CALLBACK_URL: "Invalid errorCallbackURL", + INVALID_NEW_USER_CALLBACK_URL: "Invalid newUserCallbackURL", + MISSING_OR_NULL_ORIGIN: "Missing or null Origin", + CALLBACK_URL_REQUIRED: "callbackURL is required", + FAILED_TO_CREATE_VERIFICATION: "Unable to create verification", + FIELD_NOT_ALLOWED: "Field not allowed to be set", + ASYNC_VALIDATION_NOT_SUPPORTED: "Async validation is not supported", + VALIDATION_ERROR: "Validation Error", + MISSING_FIELD: "Field is required", }); diff --git a/packages/stripe/src/client.ts b/packages/stripe/src/client.ts index 92931c7e4a..fa06cde7cc 100644 --- a/packages/stripe/src/client.ts +++ b/packages/stripe/src/client.ts @@ -28,7 +28,6 @@ export const stripeClient = < > >, pathMethods: { - "/subscription/restore": "POST", "/subscription/billing-portal": "POST", "/subscription/restore": "POST", },