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 c037fbc34d..004b5c7651 100644 --- a/packages/better-auth/src/api/middlewares/origin-check.test.ts +++ b/packages/better-auth/src/api/middlewares/origin-check.test.ts @@ -537,6 +537,61 @@ describe("origin check middleware", async () => { ); expect(sampleInternalEndpointInvalid.error?.status).toBe(403); }); + + it("should skip origin check for matched paths when skipOriginCheck is set to an array", async () => { + const { client } = await getTestInstance({ + trustedOrigins: ["https://trusted-site.com"], + advanced: { + disableOriginCheck: false, + }, + plugins: [ + { + id: "test", + init() { + return { + context: { + skipOriginCheck: ["/public/data"], + }, + }; + }, + endpoints: { + publicEndpoint: createAuthEndpoint( + "/public/data", + { + method: "GET", + query: z.object({ + callbackURL: z.string(), + }), + use: [originCheck((c) => c.query.callbackURL)], + }, + async (c) => c.query.callbackURL, + ), + protectedEndpoint: createAuthEndpoint( + "/protected/data", + { + method: "GET", + query: z.object({ + callbackURL: z.string(), + }), + use: [originCheck((c) => c.query.callbackURL)], + }, + async (c) => c.query.callbackURL, + ), + }, + }, + ], + }); + + const skipped = await client.$fetch( + "/public/data?callbackURL=https://malicious.com", + ); + expect(skipped.data).toBe("https://malicious.com"); + + const blocked = await client.$fetch( + "/protected/data?callbackURL=https://malicious.com", + ); + expect(blocked.error?.status).toBe(403); + }); }); describe("trusted origins with baseURL inferred from request", async () => { diff --git a/packages/better-auth/src/api/middlewares/origin-check.ts b/packages/better-auth/src/api/middlewares/origin-check.ts index 9010cccaa9..921a69e509 100644 --- a/packages/better-auth/src/api/middlewares/origin-check.ts +++ b/packages/better-auth/src/api/middlewares/origin-check.ts @@ -18,6 +18,29 @@ function shouldSkipCSRFForBackwardCompat(ctx: GenericEndpointContext): boolean { ); } +/** + * Checks if the origin check should be skipped for the current request. + * Handles both boolean (skip all) and array (skip specific paths) configurations. + */ +function shouldSkipOriginCheck(ctx: GenericEndpointContext): boolean { + const skipOriginCheck = ctx.context.skipOriginCheck; + if (skipOriginCheck === true) { + return true; + } + if (Array.isArray(skipOriginCheck) && ctx.request) { + try { + const basePath = new URL(ctx.context.baseURL).pathname; + const currentPath = normalizePathname(ctx.request.url, basePath); + return skipOriginCheck.some((skipPath) => + currentPath.startsWith(skipPath), + ); + } catch { + // + } + } + return false; +} + /** * Logs deprecation warning for users relying on coupled behavior. * Only logs if user explicitly set disableOriginCheck (not test environment default). @@ -45,7 +68,7 @@ export const originCheckMiddleware = createAuthMiddleware(async (ctx) => { } await validateOrigin(ctx); - if (ctx.context.skipOriginCheck) { + if (shouldSkipOriginCheck(ctx)) { return; } @@ -117,7 +140,7 @@ export const originCheck = ( if (!ctx.request) { return; } - if (ctx.context.skipOriginCheck) { + if (shouldSkipOriginCheck(ctx)) { return; } const callbackURL = getValue(ctx); @@ -199,20 +222,8 @@ async function validateOrigin( return; } - const skipOriginCheck = ctx.context.skipOriginCheck; - if (Array.isArray(skipOriginCheck)) { - try { - const basePath = new URL(ctx.context.baseURL).pathname; - const currentPath = normalizePathname(ctx.request.url, basePath); - const shouldSkipPath = skipOriginCheck.some((skipPath) => - currentPath.startsWith(skipPath), - ); - if (shouldSkipPath) { - return; - } - } catch { - // If parsing fails, don't skip - continue with validation - } + if (shouldSkipOriginCheck(ctx)) { + return; } const shouldValidate = forceValidate || useCookies;