mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-20 20:26:55 -05:00
feat: support form data for email sign-in/sign-up and fallback to checking fetch Metadata for first login (#6314)
Co-authored-by: Taesu <166604494+bytaesu@users.noreply.github.com> Co-authored-by: Bereket Engida <86073083+Bekacru@users.noreply.github.com> Co-authored-by: Jonathan Samines <jn.samines@gmail.com>
This commit is contained in:
committed by
Alex Yang
co-authored by
Taesu
Bereket Engida
Jonathan Samines
parent
5484706ceb
commit
3f06dd2ddd
@@ -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.
|
||||
|
||||
<Callout type="info">
|
||||
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.
|
||||
</Callout>
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -41,6 +41,7 @@ describe("(bun) simple server", () => {
|
||||
}),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
origin: `http://localhost:${port}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -40,6 +40,7 @@ describe("(deno) simple server", () => {
|
||||
}),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
origin: `http://localhost:${port}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
+3
@@ -10,6 +10,9 @@ export const auth = betterAuth({
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
},
|
||||
trustedOrigins: [
|
||||
"http://localhost:*", // Allow any localhost port for smoke tests
|
||||
],
|
||||
logger: {
|
||||
level: "debug",
|
||||
},
|
||||
|
||||
+3
@@ -10,6 +10,9 @@ export const auth = betterAuth({
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
},
|
||||
trustedOrigins: [
|
||||
"http://localhost:*", // Allow any localhost port for smoke tests
|
||||
],
|
||||
logger: {
|
||||
level: "debug",
|
||||
},
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = <O extends BetterAuthOptions>() =>
|
||||
{
|
||||
method: "POST",
|
||||
operationId: "signInEmail",
|
||||
use: [formCsrfMiddleware],
|
||||
body: z.object({
|
||||
/**
|
||||
* Email of the user
|
||||
@@ -371,6 +373,10 @@ export const signInEmail = <O extends BetterAuthOptions>() =>
|
||||
.optional(),
|
||||
}),
|
||||
metadata: {
|
||||
allowedMediaTypes: [
|
||||
"application/x-www-form-urlencoded",
|
||||
"application/json",
|
||||
],
|
||||
$Infer: {
|
||||
body: {} as {
|
||||
email: string;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = <O extends BetterAuthOptions>() =>
|
||||
{
|
||||
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 = <O extends BetterAuthOptions>() =>
|
||||
});
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -28,7 +28,6 @@ export const stripeClient = <
|
||||
>
|
||||
>,
|
||||
pathMethods: {
|
||||
"/subscription/restore": "POST",
|
||||
"/subscription/billing-portal": "POST",
|
||||
"/subscription/restore": "POST",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user