mirror of
https://github.com/better-auth/better-auth.git
synced 2026-05-25 16:36:34 -05:00
Merge branch 'canary' into fix/add-refetch-to-all-clients
This commit is contained in:
@@ -532,8 +532,8 @@ export const auth = betterAuth({
|
||||
|
||||
- `ipAddress`: IP address configuration for rate limiting and session tracking
|
||||
- `useSecureCookies`: Use secure cookies (default: `false`)
|
||||
- `disableCSRFCheck`: Disable trusted origins check (⚠️ security risk)
|
||||
- `disableOriginCheck`: Disable origin check (⚠️ security risk)
|
||||
- `disableCSRFCheck`: Disable all CSRF protection including origin header validation and Fetch Metadata checks (⚠️ security risk)
|
||||
- `disableOriginCheck`: Disable URL validation for `callbackURL`, `redirectTo`, and other redirect URLs (⚠️ security risk)
|
||||
- `crossSubDomainCookies`: Configure cookies to be shared across subdomains
|
||||
- `cookies`: Customize cookie names and attributes
|
||||
- `defaultCookieAttributes`: Default attributes for all cookies
|
||||
|
||||
@@ -57,7 +57,16 @@ Better Auth includes multiple safeguards to prevent Cross-Site Request Forgery (
|
||||
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.
|
||||
### Disabling Security Checks
|
||||
|
||||
Better Auth provides two separate options to disable security checks. These options control different aspects of security:
|
||||
|
||||
#### `disableCSRFCheck`
|
||||
|
||||
Disables **all CSRF protection**, including:
|
||||
- Origin header validation when cookies are present
|
||||
- Fetch Metadata checks (`Sec-Fetch-Site`, `Sec-Fetch-Mode`, `Sec-Fetch-Dest`)
|
||||
- Cross-site navigation blocking for first-login scenarios
|
||||
|
||||
```typescript
|
||||
{
|
||||
@@ -67,7 +76,17 @@ You can skip the CSRF check for all requests by setting the `disableCSRFCheck` o
|
||||
}
|
||||
```
|
||||
|
||||
You can skip the origin check for all requests by setting the `disableOriginCheck` option to `true` in the configuration.
|
||||
<Callout type="warning">
|
||||
Disabling CSRF checks allows requests from any origin to use cookies and perform actions on behalf of users. This opens your application to CSRF attacks.
|
||||
</Callout>
|
||||
|
||||
#### `disableOriginCheck`
|
||||
|
||||
Disables **URL validation** against `trustedOrigins`, including:
|
||||
- `callbackURL` validation
|
||||
- `redirectTo` validation
|
||||
- `errorCallbackURL` validation
|
||||
- `newUserCallbackURL` validation
|
||||
|
||||
```typescript
|
||||
{
|
||||
@@ -78,9 +97,20 @@ You can skip the origin check for all requests by setting the `disableOriginChec
|
||||
```
|
||||
|
||||
<Callout type="warning">
|
||||
Skipping csrf check will open your application to CSRF attacks. And skipping origin check may open up your application to other security vulnerabilities including open redirects.
|
||||
Disabling origin checks allows any URL to be used in redirects and callbacks. This opens your application to open redirect vulnerabilities.
|
||||
</Callout>
|
||||
|
||||
<Callout type="info">
|
||||
For backward compatibility, `disableOriginCheck: true` also disables CSRF protection. If you only want to disable URL validation without affecting CSRF protection, this is not currently possible - both checks are disabled together when using this option.
|
||||
</Callout>
|
||||
|
||||
#### Summary
|
||||
|
||||
| Option | What it disables |
|
||||
|--------|-----------------|
|
||||
| `disableCSRFCheck` | CSRF protection only (origin header validation, Fetch Metadata checks) |
|
||||
| `disableOriginCheck` | URL validation AND CSRF protection (for backward compatibility) |
|
||||
|
||||
## OAuth State and PKCE
|
||||
|
||||
To secure OAuth flows, Better Auth stores the OAuth state and PKCE (Proof Key for Code Exchange) in the database. The state helps prevent CSRF attacks, while PKCE protects against code injection threats. Once the OAuth process completes, these values are removed from the database.
|
||||
|
||||
@@ -497,6 +497,9 @@ describe("origin check middleware", async (it) => {
|
||||
it("should return invalid origin", async () => {
|
||||
const { client } = await getTestInstance({
|
||||
trustedOrigins: ["https://trusted-site.com"],
|
||||
advanced: {
|
||||
disableOriginCheck: false,
|
||||
},
|
||||
plugins: [
|
||||
{
|
||||
id: "test",
|
||||
@@ -728,3 +731,224 @@ describe("trusted origins with baseURL inferred from request", async (it) => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("disableCSRFCheck and disableOriginCheck separation", async (it) => {
|
||||
it("disableCSRFCheck should allow untrusted origins with cookies (CSRF bypass)", async () => {
|
||||
const { customFetchImpl, testUser } = await getTestInstance({
|
||||
trustedOrigins: ["http://localhost:3000"],
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
},
|
||||
advanced: {
|
||||
disableCSRFCheck: true,
|
||||
disableOriginCheck: false,
|
||||
},
|
||||
});
|
||||
|
||||
const client = createAuthClient({
|
||||
baseURL: "http://localhost:3000",
|
||||
fetchOptions: {
|
||||
customFetchImpl,
|
||||
headers: {
|
||||
origin: "http://evil-site.com",
|
||||
cookie: "session=test",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Should succeed because CSRF check is disabled (origin header not validated)
|
||||
const res = await client.signIn.email({
|
||||
email: testUser.email,
|
||||
password: testUser.password,
|
||||
// But callbackURL should still be validated by origin check
|
||||
callbackURL: "http://localhost:3000/dashboard",
|
||||
});
|
||||
|
||||
expect(res.data?.user).toBeDefined();
|
||||
});
|
||||
|
||||
it("disableCSRFCheck should still validate callbackURL (origin check still active)", async () => {
|
||||
const { customFetchImpl, testUser } = await getTestInstance({
|
||||
trustedOrigins: ["http://localhost:3000"],
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
},
|
||||
advanced: {
|
||||
disableCSRFCheck: true,
|
||||
disableOriginCheck: false,
|
||||
},
|
||||
});
|
||||
|
||||
const client = createAuthClient({
|
||||
baseURL: "http://localhost:3000",
|
||||
fetchOptions: {
|
||||
customFetchImpl,
|
||||
headers: {
|
||||
origin: "http://evil-site.com",
|
||||
cookie: "session=test",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Origin header passes (CSRF disabled), but callbackURL should fail
|
||||
const res = await client.signIn.email({
|
||||
email: testUser.email,
|
||||
password: testUser.password,
|
||||
callbackURL: "http://malicious-site.com/steal",
|
||||
});
|
||||
|
||||
expect(res.error?.status).toBe(403);
|
||||
expect(res.error?.message).toBe("Invalid callbackURL");
|
||||
});
|
||||
|
||||
it("disableOriginCheck should allow untrusted callbackURL", async () => {
|
||||
const { customFetchImpl, testUser } = await getTestInstance({
|
||||
trustedOrigins: ["http://localhost:3000"],
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
},
|
||||
advanced: {
|
||||
disableCSRFCheck: false,
|
||||
disableOriginCheck: true,
|
||||
},
|
||||
});
|
||||
|
||||
const client = createAuthClient({
|
||||
baseURL: "http://localhost:3000",
|
||||
fetchOptions: {
|
||||
customFetchImpl,
|
||||
headers: {
|
||||
origin: "http://localhost:3000",
|
||||
cookie: "session=test",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Origin header is trusted, and callbackURL validation is disabled
|
||||
const res = await client.signIn.email({
|
||||
email: testUser.email,
|
||||
password: testUser.password,
|
||||
callbackURL: "http://any-site.com/redirect",
|
||||
});
|
||||
|
||||
expect(res.data?.user).toBeDefined();
|
||||
});
|
||||
|
||||
it("disableOriginCheck also disables CSRF for backward compatibility", async () => {
|
||||
const { customFetchImpl, testUser } = await getTestInstance({
|
||||
trustedOrigins: ["http://localhost:3000"],
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
},
|
||||
advanced: {
|
||||
disableOriginCheck: true,
|
||||
},
|
||||
});
|
||||
|
||||
const warnFn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
const client = createAuthClient({
|
||||
baseURL: "http://localhost:3000",
|
||||
fetchOptions: {
|
||||
customFetchImpl,
|
||||
headers: {
|
||||
origin: "http://evil-site.com",
|
||||
cookie: "session=test",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(warnFn).toHaveBeenCalledTimes(0);
|
||||
// disableOriginCheck: true also disables CSRF for backward compatibility
|
||||
// so this should succeed even with an untrusted origin
|
||||
const res = await client.signIn.email({
|
||||
email: testUser.email,
|
||||
password: testUser.password,
|
||||
callbackURL: "http://any-site.com/redirect",
|
||||
});
|
||||
expect(warnFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(warnFn).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^\[Deprecation]/),
|
||||
);
|
||||
|
||||
expect(res.data?.user).toBeDefined();
|
||||
{
|
||||
await client.signIn.email({
|
||||
email: testUser.email,
|
||||
password: testUser.password,
|
||||
callbackURL: "http://any-site.com/redirect",
|
||||
});
|
||||
expect(warnFn).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("disableCSRFCheck should bypass Fetch Metadata CSRF protection", async () => {
|
||||
const { auth, testUser } = await getTestInstance({
|
||||
trustedOrigins: ["http://localhost:3000"],
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
},
|
||||
advanced: {
|
||||
disableCSRFCheck: true,
|
||||
disableOriginCheck: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Cross-site navigation that would normally be blocked
|
||||
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: testUser.email,
|
||||
password: testUser.password,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const response = await auth.handler(maliciousRequest);
|
||||
// Should NOT be blocked because CSRF check is disabled
|
||||
expect(response.status).not.toBe(403);
|
||||
});
|
||||
|
||||
it("both flags disabled should bypass all checks", async () => {
|
||||
const { customFetchImpl, testUser } = await getTestInstance({
|
||||
trustedOrigins: ["http://localhost:3000"],
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
},
|
||||
advanced: {
|
||||
disableCSRFCheck: true,
|
||||
disableOriginCheck: true,
|
||||
},
|
||||
});
|
||||
|
||||
const client = createAuthClient({
|
||||
baseURL: "http://localhost:3000",
|
||||
fetchOptions: {
|
||||
customFetchImpl,
|
||||
headers: {
|
||||
origin: "http://evil-site.com",
|
||||
cookie: "session=test",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Both CSRF and origin checks are disabled
|
||||
const res = await client.signIn.email({
|
||||
email: testUser.email,
|
||||
password: testUser.password,
|
||||
callbackURL: "http://malicious-site.com/steal",
|
||||
});
|
||||
|
||||
expect(res.data?.user).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,32 @@
|
||||
import type { GenericEndpointContext } from "@better-auth/core";
|
||||
import { createAuthMiddleware } from "@better-auth/core/api";
|
||||
import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error";
|
||||
import { deprecate } from "@better-auth/core/utils";
|
||||
import { matchesOriginPattern } from "../../auth/trusted-origins";
|
||||
|
||||
/**
|
||||
* Checks if CSRF should be skipped for backward compatibility.
|
||||
* Previously, disableOriginCheck also disabled CSRF checks.
|
||||
* This maintains that behavior when disableCSRFCheck isn't explicitly set.
|
||||
*/
|
||||
function shouldSkipCSRFForBackwardCompat(ctx: GenericEndpointContext): boolean {
|
||||
return (
|
||||
ctx.context.skipOriginCheck &&
|
||||
ctx.context.options.advanced?.disableCSRFCheck === undefined
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs deprecation warning for users relying on coupled behavior.
|
||||
* Only logs if user explicitly set disableOriginCheck (not test environment default).
|
||||
*/
|
||||
const logBackwardCompatWarning = deprecate(
|
||||
function logBackwardCompatWarning() {},
|
||||
"disableOriginCheck: true currently also disables CSRF checks. " +
|
||||
"In a future version, disableOriginCheck will ONLY disable URL validation. " +
|
||||
"To keep CSRF disabled, add disableCSRFCheck: true to your config.",
|
||||
);
|
||||
|
||||
/**
|
||||
* A middleware to validate callbackURL and origin against trustedOrigins.
|
||||
* Also handles CSRF protection using Fetch Metadata for first-login scenarios.
|
||||
@@ -19,6 +43,10 @@ export const originCheckMiddleware = createAuthMiddleware(async (ctx) => {
|
||||
}
|
||||
await validateOrigin(ctx);
|
||||
|
||||
if (ctx.context.skipOriginCheck) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { body, query } = ctx;
|
||||
const callbackURL = body?.callbackURL || query?.callbackURL;
|
||||
const redirectURL = body?.redirectTo;
|
||||
@@ -87,6 +115,9 @@ export const originCheck = (
|
||||
if (!ctx.request) {
|
||||
return;
|
||||
}
|
||||
if (ctx.context.skipOriginCheck) {
|
||||
return;
|
||||
}
|
||||
const callbackURL = getValue(ctx);
|
||||
const validateURL = (url: string | undefined, label: string) => {
|
||||
if (!url) {
|
||||
@@ -156,9 +187,17 @@ async function validateOrigin(
|
||||
const originHeader = headers.get("origin") || headers.get("referer") || "";
|
||||
const useCookies = headers.has("cookie");
|
||||
|
||||
const shouldValidate =
|
||||
forceValidate ||
|
||||
(useCookies && !ctx.context.skipCSRFCheck && !ctx.context.skipOriginCheck);
|
||||
if (ctx.context.skipCSRFCheck) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldSkipCSRFForBackwardCompat(ctx)) {
|
||||
ctx.context.options.advanced?.disableOriginCheck === true &&
|
||||
logBackwardCompatWarning();
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldValidate = forceValidate || useCookies;
|
||||
|
||||
if (!shouldValidate) {
|
||||
return;
|
||||
@@ -215,6 +254,14 @@ async function validateFormCsrf(ctx: GenericEndpointContext): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.context.skipCSRFCheck) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldSkipCSRFForBackwardCompat(ctx)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = req.headers;
|
||||
const hasAnyCookies = headers.has("cookie");
|
||||
|
||||
|
||||
@@ -107,7 +107,11 @@ describe("sign-in", async (it) => {
|
||||
|
||||
describe("url checks", async (it) => {
|
||||
it("should reject untrusted origins", async () => {
|
||||
const { client } = await getTestInstance();
|
||||
const { client } = await getTestInstance({
|
||||
advanced: {
|
||||
disableOriginCheck: false,
|
||||
},
|
||||
});
|
||||
const res = await client.signIn.social({
|
||||
provider: "google",
|
||||
callbackURL: "http://malicious.com",
|
||||
@@ -139,6 +143,9 @@ describe("sign-in CSRF protection", async (it) => {
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
},
|
||||
advanced: {
|
||||
disableCSRFCheck: false,
|
||||
},
|
||||
});
|
||||
|
||||
it("should block cross-site navigation login attempts (no cookies)", async () => {
|
||||
@@ -245,6 +252,9 @@ describe("sign-in with form data", async (it) => {
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
},
|
||||
advanced: {
|
||||
disableCSRFCheck: false,
|
||||
},
|
||||
});
|
||||
|
||||
it("should accept form-urlencoded content type", async () => {
|
||||
|
||||
@@ -172,6 +172,9 @@ describe("sign-up CSRF protection", async (it) => {
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
},
|
||||
advanced: {
|
||||
disableCSRFCheck: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
disableTestUser: true,
|
||||
@@ -287,6 +290,9 @@ describe("sign-up with form data", async (it) => {
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
},
|
||||
advanced: {
|
||||
disableCSRFCheck: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
disableTestUser: true,
|
||||
|
||||
@@ -37,15 +37,25 @@ describe("cookies", async () => {
|
||||
});
|
||||
|
||||
it("should set multiple cookies", async () => {
|
||||
await client.signIn.social(
|
||||
const { client, testUser } = await getTestInstance({
|
||||
session: {
|
||||
cookieCache: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
await client.signIn.email(
|
||||
{
|
||||
provider: "github",
|
||||
callbackURL: "https://example.com",
|
||||
email: testUser.email,
|
||||
password: testUser.password,
|
||||
},
|
||||
{
|
||||
onSuccess(context) {
|
||||
const cookies = context.response.headers.get("Set-Cookie");
|
||||
expect(cookies?.split(",").length).toBeGreaterThan(1);
|
||||
expect(cookies).toBeDefined();
|
||||
const parsed = parseSetCookieHeader(cookies!);
|
||||
// With cookie cache enabled, we should have session_token and session_data cookies
|
||||
expect(parsed.size).toBeGreaterThan(1);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -343,6 +343,9 @@ describe("magic link verify origin validation", async () => {
|
||||
},
|
||||
}),
|
||||
],
|
||||
advanced: {
|
||||
disableOriginCheck: false,
|
||||
},
|
||||
});
|
||||
|
||||
const client = createAuthClient({
|
||||
|
||||
@@ -140,6 +140,9 @@ describe("Social Providers", async (c) => {
|
||||
clientSecret: "test",
|
||||
},
|
||||
},
|
||||
advanced: {
|
||||
disableOriginCheck: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
disableTestUser: true,
|
||||
|
||||
@@ -151,17 +151,32 @@ export type BetterAuthAdvancedOptions = {
|
||||
*/
|
||||
useSecureCookies?: boolean | undefined;
|
||||
/**
|
||||
* Disable trusted origins check
|
||||
* Disable all CSRF protection.
|
||||
*
|
||||
* When enabled, this disables:
|
||||
* - Origin header validation when cookies are present
|
||||
* - Fetch Metadata checks (Sec-Fetch-Site, Sec-Fetch-Mode, Sec-Fetch-Dest)
|
||||
* - Cross-site navigation blocking for first-login scenarios
|
||||
*
|
||||
* ⚠︎ This is a security risk and it may expose your application to
|
||||
* CSRF attacks
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
disableCSRFCheck?: boolean | undefined;
|
||||
/**
|
||||
* Disable origin check
|
||||
* Disable URL validation against trustedOrigins.
|
||||
*
|
||||
* ⚠︎ This may allow requests from any origin to be processed by
|
||||
* Better Auth. And could lead to security vulnerabilities.
|
||||
* When enabled, this disables validation of:
|
||||
* - callbackURL
|
||||
* - redirectTo
|
||||
* - errorCallbackURL
|
||||
* - newUserCallbackURL
|
||||
*
|
||||
* ⚠︎ This may allow open redirects and could lead to security
|
||||
* vulnerabilities.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
disableOriginCheck?: boolean | undefined;
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user