diff --git a/docs/content/docs/concepts/plugins.mdx b/docs/content/docs/concepts/plugins.mdx index 8690d78d37..f8aa3531e1 100644 --- a/docs/content/docs/concepts/plugins.mdx +++ b/docs/content/docs/concepts/plugins.mdx @@ -125,6 +125,8 @@ Create Auth endpoints wraps around `createEndpoint` from Better Call. Inside the - `adapter`: This is the same as db but it give you `orm` like functions to interact with the database. (we recommend using this over `db` unless you need raw sql queries or for performance reasons) - `internalAdapter`: These are internal db calls that are used by Better Auth. For example, you can use these calls to create a session instead of using `adapter` directly. `internalAdapter.createSession(userId)` - `createAuthCookie`: This is a helper function that lets you get a cookie `name` and `options` for either to `set` or `get` cookies. It implements things like `__Secure-` prefix for cookies based on whether the connection is secure (HTTPS) or the application is running in production mode. +- `trustedOrigins`: This is the list of trusted origins that you specified via `options.trustedOrigins`. +- `isTrustedOrigin`: This is a helper function that allows you to quickly check whether a given url or path is trusted based on the trusted origins configuration. For other properties, you can check the Better Call documentation and the source code . @@ -355,6 +357,46 @@ const myPlugin = () => { } ``` +### Trusted origins + +If you're building custom plugins or endpoints, you can use the `isTrustedOrigin()` method available on the auth context to validate URLs against your trusted origins configuration. +This ensures your custom endpoints respect the same security settings as Better Auth's built-in endpoints. + +```ts title="plugin.ts" +import { createAuthEndpoint, APIError } from "better-auth/api"; +import * as z from "zod" + +const myPlugin = () => { + return { + id: "my-plugin", + trustedOrigins: [ + "http://trusted.com" + ], + endpoints: { + getTrustedHelloWorld: createAuthEndpoint("/my-plugin/hello-world", { + method: "GET", + query: z.object({ + url: z.string() + }), + }, async (ctx) => { + // The allowRelativePaths option can be used to either allow or disallow relative paths + if (!ctx.context.isTrustedOrigin(ctx.query.url, { allowRelativePaths: false })) { + throw new APIError("FORBIDDEN", { + message: "origin is not trusted." + }); + } + + return ctx.json({ + message: "Hello World" + }) + }) + } + } satisfies BetterAuthPlugin +} +``` + +See the [trusted origins and security](/docs/reference/security#trusted-origins) docs for more info. + ### Server-plugin helper functions Some additional helper functions for creating server plugins. diff --git a/docs/content/docs/reference/security.mdx b/docs/content/docs/reference/security.mdx index 3dc808e0b1..77d6f0a119 100644 --- a/docs/content/docs/reference/security.mdx +++ b/docs/content/docs/reference/security.mdx @@ -120,7 +120,7 @@ The most basic usage is to specify exact origins: } ``` -### Wildcard Domains +### Wildcard Origins Better Auth supports wildcard patterns in trusted origins, which allows you to trust multiple subdomains with a single entry: @@ -162,6 +162,23 @@ Trusted origins also support custom schemes for mobile apps and browser extensio } ``` +### Dynamic origin list + +You can also dynamically set the list of trusted origins by providing a function that returns it: + +```typescript +{ + trustedOrigins: async (request) => { + const trustedOrigins = await queryTrustedDomains(); + return trustedOrigins; + } +} +``` + + +**Important**: This function will be invoked per incoming request, so be careful if you decide to dynamically fetch your list of trusted domains. + + ## Reporting Vulnerabilities If you discover a security vulnerability in Better Auth, please report it to us at [security@better-auth.com](mailto:security@better-auth.com). We address all reports promptly, and credits will be given for validated discoveries. 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 0345aee98d..16839a1971 100644 --- a/packages/better-auth/src/api/middlewares/origin-check.test.ts +++ b/packages/better-auth/src/api/middlewares/origin-check.test.ts @@ -11,7 +11,6 @@ describe("Origin Check", async (it) => { "http://localhost:5000", "https://trusted.com", "*.my-site.com", - "https://*.protocol-site.com", ], emailAndPassword: { enabled: true, @@ -57,115 +56,6 @@ describe("Origin Check", async (it) => { expect(res.error?.message).toBe("Invalid callbackURL"); }); - it("should allow query params in callback url", async (ctx) => { - const client = createAuthClient({ - baseURL: "http://localhost:3000", - fetchOptions: { - customFetchImpl, - headers: { - origin: "https://localhost:3000", - }, - }, - }); - const res = await client.signIn.email({ - email: testUser.email, - password: testUser.password, - callbackURL: "/dashboard?test=123", - }); - expect(res.data?.user).toBeDefined(); - }); - - it("should allow plus signs in the callback url", async (ctx) => { - const client = createAuthClient({ - baseURL: "http://localhost:3000", - fetchOptions: { - customFetchImpl, - headers: { - origin: "https://localhost:3000", - }, - }, - }); - const res = await client.signIn.email({ - email: testUser.email, - password: testUser.password, - callbackURL: "/dashboard+page?test=123+456", - }); - expect(res.data?.user).toBeDefined(); - }); - - it("should reject callback url with double slash", async (ctx) => { - const client = createAuthClient({ - baseURL: "http://localhost:3000", - fetchOptions: { - customFetchImpl, - headers: { - origin: "https://localhost:3000", - }, - }, - }); - const res = await client.signIn.email({ - email: testUser.email, - password: testUser.password, - callbackURL: "//evil.com", - }); - expect(res.error?.status).toBe(403); - }); - - it("should reject callback urls with encoded malicious content", async (ctx) => { - const client = createAuthClient({ - baseURL: "http://localhost:3000", - fetchOptions: { - customFetchImpl, - headers: { - origin: "https://localhost:3000", - }, - }, - }); - - const maliciousPatterns = [ - "/%5C/evil.com", - `/\\/\\/evil.com`, - "/%5C/evil.com", - "/..%2F..%2Fevil.com", - "javascript:alert('xss')", - "data:text/html,", - ]; - - for (const pattern of maliciousPatterns) { - const res = await client.signIn.email({ - email: testUser.email, - password: testUser.password, - callbackURL: pattern, - }); - expect(res.error?.status).toBe(403); - } - }); - - it("should reject callback url with malicious domain with wildcard trusted origins", async (ctx) => { - const { customFetchImpl, testUser } = await getTestInstance({ - trustedOrigins: ["*.example.com"], - emailAndPassword: { - enabled: true, - async sendResetPassword(url, user) {}, - }, - }); - const client = createAuthClient({ - baseURL: "http://localhost:3000", - fetchOptions: { - customFetchImpl, - headers: { - cookie: "session=123", - }, - }, - }); - const res = await client.signIn.email({ - email: testUser.email, - password: testUser.password, - callbackURL: "malicious.com?.example.com", - }); - expect(res.error?.status).toBe(403); - }); - it("should reject untrusted origin headers", async (ctx) => { const client = createAuthClient({ baseURL: "http://localhost:3000", @@ -184,42 +74,6 @@ describe("Origin Check", async (it) => { expect(res.error?.status).toBe(403); }); - it("should reject untrusted origin headers which start with trusted origin", async (ctx) => { - const client = createAuthClient({ - baseURL: "http://localhost:3000", - fetchOptions: { - customFetchImpl, - headers: { - origin: "https://trusted.com.malicious.com", - cookie: "session=123", - }, - }, - }); - const res = await client.signIn.email({ - email: testUser.email, - password: testUser.password, - }); - expect(res.error?.status).toBe(403); - }); - - it("should reject untrusted origin subdomains", async (ctx) => { - const client = createAuthClient({ - baseURL: "http://localhost:3000", - fetchOptions: { - customFetchImpl, - headers: { - origin: "http://sub-domain.trusted.com", - cookie: "session=123", - }, - }, - }); - const res = await client.signIn.email({ - email: testUser.email, - password: testUser.password, - }); - expect(res.error?.status).toBe(403); - }); - it("should allow untrusted origin if they don't contain cookies", async (ctx) => { const client = createAuthClient({ baseURL: "http://localhost:3000", @@ -296,23 +150,6 @@ describe("Origin Check", async (it) => { callbackURL: "https://sub-domain.my-site.com/callback", }); expect(res.data?.user).toBeDefined(); - - // Test another subdomain with the wildcard pattern - const client2 = createAuthClient({ - baseURL: "https://another-sub.my-site.com", - fetchOptions: { - customFetchImpl, - headers: { - origin: "https://another-sub.my-site.com", - }, - }, - }); - const res2 = await client2.signIn.email({ - email: testUser.email, - password: testUser.password, - callbackURL: "https://another-sub.my-site.com/callback", - }); - expect(res2.data?.user).toBeDefined(); }); it("should work with GET requests", async (ctx) => { @@ -365,112 +202,6 @@ describe("Origin Check", async (it) => { }); expect(invalidRes.error?.status).toBe(403); }); - - it("should work with relative callbackURL with query params", async (ctx) => { - const client = createAuthClient({ - baseURL: "http://localhost:3000", - fetchOptions: { - customFetchImpl, - }, - }); - const res = await client.signIn.email({ - email: testUser.email, - password: testUser.password, - callbackURL: "/dashboard?email=123@email.com", - }); - expect(res.data?.user).toBeDefined(); - }); - - it("should work with protocol specific wildcard trusted origins", async () => { - // Test HTTPS protocol specific wildcard - should work - const httpsClient = createAuthClient({ - baseURL: "http://localhost:3000", - fetchOptions: { - customFetchImpl, - headers: { - origin: "https://api.protocol-site.com", - cookie: "session=123", - }, - }, - }); - const httpsRes = await httpsClient.signIn.email({ - email: testUser.email, - password: testUser.password, - callbackURL: "https://app.protocol-site.com/dashboard", - }); - expect(httpsRes.data?.user).toBeDefined(); - - // Test HTTP with HTTPS protocol wildcard - should fail - const httpClient = createAuthClient({ - baseURL: "http://localhost:3000", - fetchOptions: { - customFetchImpl, - headers: { - origin: "http://api.protocol-site.com", - cookie: "session=123", - }, - }, - }); - const httpRes = await httpClient.signIn.email({ - email: testUser.email, - password: testUser.password, - }); - expect(httpRes.error?.status).toBe(403); - }); - - it("should work with custom scheme wildcards (e.g. exp:// for Expo)", async () => { - const { customFetchImpl, testUser } = await getTestInstance({ - trustedOrigins: [ - "exp://10.0.0.*:*/*", - "exp://192.168.*.*:*/*", - "exp://172.*.*.*:*/*", - ], - emailAndPassword: { - enabled: true, - async sendResetPassword(url, user) {}, - }, - }); - - // Test custom scheme with wildcard - should work - const expoClient = createAuthClient({ - baseURL: "http://localhost:3000", - fetchOptions: { - customFetchImpl, - }, - }); - - // Test with IP matching the wildcard pattern - const resWithIP = await expoClient.signIn.email({ - email: testUser.email, - password: testUser.password, - callbackURL: "exp://10.0.0.29:8081/--/", - }); - expect(resWithIP.data?.user).toBeDefined(); - - // Test with different IP range that matches - const resWithIP2 = await expoClient.signIn.email({ - email: testUser.email, - password: testUser.password, - callbackURL: "exp://192.168.1.100:8081/--/", - }); - expect(resWithIP2.data?.user).toBeDefined(); - - // Test with different IP range that matches - const resWithIP3 = await expoClient.signIn.email({ - email: testUser.email, - password: testUser.password, - callbackURL: "exp://172.16.0.1:8081/--/", - }); - expect(resWithIP3.data?.user).toBeDefined(); - - // Test with IP that doesn't match any pattern - should fail - const resWithUnmatchedIP = await expoClient.signIn.email({ - email: testUser.email, - password: testUser.password, - callbackURL: "exp://203.0.113.0:8081/--/", - }); - expect(resWithUnmatchedIP.error?.status).toBe(403); - }); }); describe("origin check middleware", async (it) => { diff --git a/packages/better-auth/src/api/middlewares/origin-check.ts b/packages/better-auth/src/api/middlewares/origin-check.ts index d985997858..114fa889b0 100644 --- a/packages/better-auth/src/api/middlewares/origin-check.ts +++ b/packages/better-auth/src/api/middlewares/origin-check.ts @@ -1,8 +1,6 @@ import type { GenericEndpointContext } from "@better-auth/core"; import { createAuthMiddleware } from "@better-auth/core/api"; import { APIError } from "better-call"; -import { getHost, getOrigin, getProtocol } from "../../utils/url"; -import { wildcardMatch } from "../../utils/wildcard"; /** * A middleware to validate callbackURL and origin against @@ -19,59 +17,27 @@ export const originCheckMiddleware = createAuthMiddleware(async (ctx) => { return; } const headers = ctx.request?.headers; - const request = ctx.request; - const { body, query, context } = 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 trustedOrigins: string[] = Array.isArray(context.options.trustedOrigins) - ? context.trustedOrigins - : [ - ...context.trustedOrigins, - ...((await context.options.trustedOrigins?.(request)) || []), - ]; const useCookies = headers?.has("cookie"); - const matchesPattern = (url: string, pattern: string): boolean => { - if (url.startsWith("/")) { - return false; - } - if (pattern.includes("*")) { - // For protocol-specific wildcards, match the full origin - if (pattern.includes("://")) { - return wildcardMatch(pattern)(getOrigin(url) || url); - } - const host = getHost(url); - if (!host) { - return false; - } - return wildcardMatch(pattern)(host); - } - - const protocol = getProtocol(url); - return protocol === "http:" || protocol === "https:" || !protocol - ? pattern === getOrigin(url) - : url.startsWith(pattern); - }; const validateURL = (url: string | undefined, label: string) => { if (!url) { return; } - const isTrustedOrigin = trustedOrigins.some( - (origin) => - matchesPattern(url, origin) || - (url?.startsWith("/") && - label !== "origin" && - /^\/(?!\/|\\|%2f|%5c)[\w\-.\+/@]*(?:\?[\w\-.\+/=&%@]*)?$/.test(url)), - ); + const isTrustedOrigin = ctx.context.isTrustedOrigin(url, { + allowRelativePaths: label !== "origin", + }); + if (!isTrustedOrigin) { ctx.context.logger.error(`Invalid ${label}: ${url}`); ctx.context.logger.info( `If it's a valid URL, please add ${url} to trustedOrigins in your auth config\n`, - `Current list of trustedOrigins: ${trustedOrigins}`, + `Current list of trustedOrigins: ${ctx.context.trustedOrigins}`, ); throw new APIError("FORBIDDEN", { message: `Invalid ${label}` }); } @@ -99,57 +65,20 @@ export const originCheck = ( if (!ctx.request) { return; } - const { context } = ctx; const callbackURL = getValue(ctx); - const trustedOrigins: string[] = Array.isArray( - context.options.trustedOrigins, - ) - ? context.trustedOrigins - : [ - ...context.trustedOrigins, - ...((await context.options.trustedOrigins?.(ctx.request)) || []), - ]; - - const matchesPattern = (url: string, pattern: string): boolean => { - if (url.startsWith("/")) { - return false; - } - if (pattern.includes("*")) { - // For protocol-specific wildcards, match the full origin - if (pattern.includes("://")) { - return wildcardMatch(pattern)(getOrigin(url) || url); - } - const host = getHost(url); - if (!host) { - return false; - } - // For host-only wildcards, match just the host - return wildcardMatch(pattern)(host); - } - const protocol = getProtocol(url); - return protocol === "http:" || protocol === "https:" || !protocol - ? pattern === getOrigin(url) - : url.startsWith(pattern); - }; - const validateURL = (url: string | undefined, label: string) => { if (!url) { return; } - const isTrustedOrigin = trustedOrigins.some( - (origin) => - matchesPattern(url, origin) || - (url?.startsWith("/") && - label !== "origin" && - /^\/(?!\/|\\|%2f|%5c)[\w\-.\+/@]*(?:\?[\w\-.\+/=&%@]*)?$/.test( - url, - )), - ); + const isTrustedOrigin = ctx.context.isTrustedOrigin(url, { + allowRelativePaths: label !== "origin", + }); + if (!isTrustedOrigin) { ctx.context.logger.error(`Invalid ${label}: ${url}`); ctx.context.logger.info( `If it's a valid URL, please add ${url} to trustedOrigins in your auth config\n`, - `Current list of trustedOrigins: ${trustedOrigins}`, + `Current list of trustedOrigins: ${ctx.context.trustedOrigins}`, ); throw new APIError("FORBIDDEN", { message: `Invalid ${label}` }); } diff --git a/packages/better-auth/src/auth/trusted-origins.test.ts b/packages/better-auth/src/auth/trusted-origins.test.ts new file mode 100644 index 0000000000..c019b076ba --- /dev/null +++ b/packages/better-auth/src/auth/trusted-origins.test.ts @@ -0,0 +1,289 @@ +import { createAuthEndpoint } from "@better-auth/core/api"; +import { describe, expect, it } from "vitest"; +import * as z from "zod"; +import { getTestInstance } from "../test-utils"; +import type { BetterAuthOptions } from "../types"; + +async function createAuthTestInstance(overrides?: Partial) { + const testServerPlugin = { + id: "test-plugin", + endpoints: { + testTrustedOrigin: createAuthEndpoint( + "/test-trusted-origin", + { + method: "GET", + query: z.object({ + url: z.string(), + allowRelativePaths: z.coerce.boolean().optional(), + }), + }, + async (ctx) => { + const settings = + typeof ctx.query.allowRelativePaths === "boolean" + ? { allowRelativePaths: ctx.query.allowRelativePaths } + : undefined; + return ctx.context.isTrustedOrigin(ctx.query.url, settings); + }, + ), + }, + }; + + const testClientPlugin = { + id: "test-client-plugin", + $InferServerPlugin: {} as typeof testServerPlugin, + }; + + const { auth, client } = await getTestInstance( + { + plugins: [testServerPlugin], + ...overrides, + }, + { clientOptions: { plugins: [testClientPlugin] } }, + ); + + const isTrustedOrigin = async ( + url: string, + settings?: { allowRelativePaths: boolean }, + ) => { + const result = await client.testTrustedOrigin({ + query: { url, ...settings }, + }); + + if (result.error) { + throw result.error; + } + + return result.data; + }; + + return { auth, client, isTrustedOrigin }; +} + +describe("trusted origins", () => { + describe("trusted origins list support", () => { + it("should always allow the app's origin", async () => { + const { isTrustedOrigin } = await createAuthTestInstance(); + + await expect(isTrustedOrigin("http://localhost:3000")).resolves.toBe( + true, + ); + + await expect( + isTrustedOrigin("http://localhost:3000/some/path"), + ).resolves.toBe(true); + }); + + it("should reject origins that start with a trusted origin", async () => { + const { isTrustedOrigin } = await createAuthTestInstance({ + trustedOrigins: ["https://trusted.com"], + }); + + await expect( + isTrustedOrigin("https://trusted.com.malicious.com"), + ).resolves.toBe(false); + }); + + it("should reject untrusted origin subdomains", async () => { + const { isTrustedOrigin } = await createAuthTestInstance({ + trustedOrigins: ["https://trusted.com"], + }); + + await expect( + isTrustedOrigin("http://sub-domain.trusted.com"), + ).resolves.toBe(false); + }); + + it("should allow origins that directly match a trusted origin", async () => { + const { isTrustedOrigin } = await createAuthTestInstance({ + trustedOrigins: ["https://trusted.com"], + }); + + await expect(isTrustedOrigin("https://trusted.com")).resolves.toBe(true); + + await expect( + isTrustedOrigin("https://trusted.com/some/path"), + ).resolves.toBe(true); + }); + }); + + describe("relative paths support", () => { + it("should reject relative paths by default", async () => { + const { isTrustedOrigin } = await createAuthTestInstance(); + + await expect(isTrustedOrigin("/")).resolves.toBe(false); + await expect(isTrustedOrigin("/some-absolute-url")).resolves.toBe(false); + }); + + it("should allow relative paths", async () => { + const { isTrustedOrigin } = await createAuthTestInstance(); + + try { + await isTrustedOrigin("/", { allowRelativePaths: true }); + } catch (error) { + console.log(error); + } + + await expect( + isTrustedOrigin("/", { allowRelativePaths: true }), + ).resolves.toBe(true); + await expect( + isTrustedOrigin("/dashboard", { allowRelativePaths: true }), + ).resolves.toBe(true); + }); + + it("should allow relative paths with query params", async () => { + const { isTrustedOrigin } = await createAuthTestInstance(); + + await expect( + isTrustedOrigin("/dashboard?email=123@email.com", { + allowRelativePaths: true, + }), + ).resolves.toBe(true); + }); + + it("should allow relative paths with plus signs", async () => { + const { isTrustedOrigin } = await createAuthTestInstance(); + + await expect( + isTrustedOrigin("/dashboard+page?test=123+456", { + allowRelativePaths: true, + }), + ).resolves.toBe(true); + }); + + it("should reject urls with double dash", async () => { + const { isTrustedOrigin } = await createAuthTestInstance(); + + await expect( + isTrustedOrigin("//evil.com", { allowRelativePaths: true }), + ).resolves.toBe(false); + }); + + it("should reject urls with encoded malicious content", async () => { + const { isTrustedOrigin } = await createAuthTestInstance(); + + const maliciousPatterns = [ + "/%5C/evil.com", + `/\\/\\/evil.com`, + "/%5C/evil.com", + "/..%2F..%2Fevil.com", + "javascript:alert('xss')", + "data:text/html,", + ]; + + for (const pattern of maliciousPatterns) { + await expect( + isTrustedOrigin(pattern, { allowRelativePaths: true }), + ).resolves.toBe(false); + } + }); + }); + + describe("wildcards support", () => { + it("should allow origins that match a wildcard trusted origin", async () => { + const { isTrustedOrigin } = await createAuthTestInstance({ + trustedOrigins: ["*.my-site.com"], + }); + + await expect( + isTrustedOrigin("https://sub-domain.my-site.com"), + ).resolves.toBe(true); + + await expect( + isTrustedOrigin("https://sub-domain.my-site.com/callback"), + ).resolves.toBe(true); + + await expect( + isTrustedOrigin("https://another-sub.my-site.com"), + ).resolves.toBe(true); + + await expect( + isTrustedOrigin("https://another-sub.my-site.com/callback"), + ).resolves.toBe(true); + }); + + it("should reject urls with malicious domain with wildcard trusted origins", async (ctx) => { + const { isTrustedOrigin } = await createAuthTestInstance({ + trustedOrigins: ["*.example.com"], + }); + await expect(isTrustedOrigin("malicious.com?.example.com")).resolves.toBe( + false, + ); + }); + + it("should work with protocol specific wildcard trusted origins", async () => { + const { isTrustedOrigin } = await createAuthTestInstance({ + trustedOrigins: ["https://*.protocol-site.com"], + }); + + // Test HTTPS protocol specific wildcard - should work + await expect( + isTrustedOrigin("https://api.protocol-site.com"), + ).resolves.toBe(true); + + // Test HTTP with HTTPS protocol wildcard - should fail + await expect( + isTrustedOrigin("http://api.protocol-site.com"), + ).resolves.toBe(false); + }); + + it("should work with custom scheme wildcards (e.g. exp:// for Expo)", async () => { + const { isTrustedOrigin } = await createAuthTestInstance({ + trustedOrigins: [ + "exp://10.0.0.*:*/*", + "exp://192.168.*.*:*/*", + "exp://172.*.*.*:*/*", + ], + }); + + // Test with IP matching the wildcard pattern + await expect(isTrustedOrigin("exp://10.0.0.29:8081/--/")).resolves.toBe( + true, + ); + + // Test with different IP range that matches + await expect( + isTrustedOrigin("exp://192.168.1.100:8081/--/"), + ).resolves.toBe(true); + + // Test with different IP range that matches + await expect(isTrustedOrigin("exp://172.16.0.1:8081/--/")).resolves.toBe( + true, + ); + + // Test with IP that doesn't match any pattern - should fail + await expect(isTrustedOrigin("exp://203.0.113.0:8081/--/")).resolves.toBe( + false, + ); + }); + }); + + describe("dynamic trusted origins", () => { + it("should allow dynamically computed trusted origins", async () => { + const { isTrustedOrigin } = await createAuthTestInstance({ + trustedOrigins: async (request) => { + const url = new URL( + new URL(request.url).searchParams.get("url") ?? "unknown", + ); + return [url.origin]; + }, + }); + + await expect( + isTrustedOrigin("http://localhost:5000/callback"), + ).resolves.toBe(true); + }); + + it("should not allow dynamically computed trusted origins", async () => { + const { isTrustedOrigin } = await createAuthTestInstance({ + trustedOrigins: async (request) => { + return []; // no additional trusted origins + }, + }); + + await expect( + isTrustedOrigin("http://localhost:5000/callback"), + ).resolves.toBe(false); + }); + }); +}); diff --git a/packages/better-auth/src/auth/trusted-origins.ts b/packages/better-auth/src/auth/trusted-origins.ts new file mode 100644 index 0000000000..fea9e5c097 --- /dev/null +++ b/packages/better-auth/src/auth/trusted-origins.ts @@ -0,0 +1,45 @@ +import { getHost, getOrigin, getProtocol } from "../utils/url"; +import { wildcardMatch } from "../utils/wildcard"; + +/** + * Matches the given url against an origin or origin pattern + * See "options.trustedOrigins" for details of supported patterns + * + * @param url The url to test + * @param pattern The origin pattern + * @param [settings] Specify supported pattern matching settings + * @returns {boolean} true if the URL matches the origin pattern, false otherwise. + */ +export const matchesOriginPattern = ( + url: string, + pattern: string, + settings?: { allowRelativePaths: boolean }, +): boolean => { + if (url.startsWith("/")) { + if (settings?.allowRelativePaths) { + return ( + url.startsWith("/") && + /^\/(?!\/|\\|%2f|%5c)[\w\-.\+/@]*(?:\?[\w\-.\+/=&%@]*)?$/.test(url) + ); + } + + return false; + } + + if (pattern.includes("*")) { + // For protocol-specific wildcards, match the full origin + if (pattern.includes("://")) { + return wildcardMatch(pattern)(getOrigin(url) || url); + } + const host = getHost(url); + if (!host) { + return false; + } + // For host-only wildcards, match just the host + return wildcardMatch(pattern)(host); + } + const protocol = getProtocol(url); + return protocol === "http:" || protocol === "https:" || !protocol + ? pattern === getOrigin(url) + : url.startsWith(pattern); +}; diff --git a/packages/better-auth/src/context/__snapshots__/create-context.test.ts.snap b/packages/better-auth/src/context/__snapshots__/create-context.test.ts.snap index a19603f8bb..01ed2159f7 100644 --- a/packages/better-auth/src/context/__snapshots__/create-context.test.ts.snap +++ b/packages/better-auth/src/context/__snapshots__/create-context.test.ts.snap @@ -112,6 +112,7 @@ exports[`base context creation > should match config 1`] = ` "updateUserByEmail": [Function], "updateVerificationValue": [Function], }, + "isTrustedOrigin": [Function], "logger": { "debug": [Function], "error": [Function], diff --git a/packages/better-auth/src/context/create-context.ts b/packages/better-auth/src/context/create-context.ts index dda6e62db8..58865b1f6e 100644 --- a/packages/better-auth/src/context/create-context.ts +++ b/packages/better-auth/src/context/create-context.ts @@ -10,6 +10,7 @@ import { createTelemetry } from "@better-auth/telemetry"; import defu from "defu"; import type { Entries } from "type-fest"; import { checkEndpointConflicts } from "../api"; +import { matchesOriginPattern } from "../auth/trusted-origins"; import { createCookieGetter, getCookies } from "../cookies"; import { hashPassword, verifyPassword } from "../crypto/password"; import { createInternalAdapter } from "../db/internal-adapter"; @@ -174,6 +175,11 @@ export async function createAuthContext( }, tables, trustedOrigins: getTrustedOrigins(options), + isTrustedOrigin(url: string, settings?: { allowRelativePaths: boolean }) { + return ctx.trustedOrigins.some((origin) => + matchesOriginPattern(url, origin, settings), + ); + }, baseURL: baseURL || "", sessionConfig: { updateAge: diff --git a/packages/core/src/types/context.ts b/packages/core/src/types/context.ts index ceb7d3f06c..34345e3e5c 100644 --- a/packages/core/src/types/context.ts +++ b/packages/core/src/types/context.ts @@ -165,6 +165,16 @@ export type AuthContext = appName: string; baseURL: string; trustedOrigins: string[]; + /** + * Verifies whether url is a trusted origin according to the "trustedOrigins" configuration + * @param url The url to verify against the "trustedOrigins" configuration + * @param settings Specify supported pattern matching settings + * @returns {boolean} true if the URL matches the origin pattern, false otherwise. + */ + isTrustedOrigin: ( + url: string, + settings?: { allowRelativePaths: boolean }, + ) => boolean; oauthConfig: { /** * This is dangerous and should only be used in dev or staging environments.