UNPKG

better-auth

Version:

The most comprehensive authentication framework for TypeScript.

152 lines (151 loc) • 8.32 kB
import { matchesOriginPattern } from "../../auth/trusted-origins.mjs"; import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error"; import { normalizePathname } from "@better-auth/core/utils/url"; import { createAuthMiddleware } from "@better-auth/core/api"; import { deprecate } from "@better-auth/core/utils/deprecate"; //#region src/api/middlewares/origin-check.ts /** * 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. * Only triggers for skipOriginCheck === true, not for path arrays. */ function shouldSkipCSRFForBackwardCompat(ctx) { return ctx.context.skipOriginCheck === true && ctx.context.options.advanced?.disableCSRFCheck === void 0; } /** * 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) { 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) => { const normalizedSkipPath = skipPath.replace(/\/+$/, ""); return currentPath === normalizedSkipPath || currentPath.startsWith(`${normalizedSkipPath}/`); }); } catch {} return false; } /** * 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. */ const originCheckMiddleware = createAuthMiddleware(async (ctx) => { if (ctx.request?.method === "GET" || ctx.request?.method === "OPTIONS" || ctx.request?.method === "HEAD" || !ctx.request) return; await validateOrigin(ctx); if (shouldSkipOriginCheck(ctx)) return; const { body, query } = ctx; const callbackURL = body?.callbackURL || query?.callbackURL; const redirectURL = body?.redirectTo; const errorCallbackURL = body?.errorCallbackURL; const newUserCallbackURL = body?.newUserCallbackURL; const validateURL = (url, label) => { if (!url) return; if (typeof url !== "string") throw APIError.fromStatus("BAD_REQUEST", { message: `Invalid ${label}: expected a string` }); if (!ctx.context.isTrustedOrigin(url, { allowRelativePaths: label !== "origin" })) { 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: ${ctx.context.trustedOrigins}`); if (label === "origin") throw APIError.from("FORBIDDEN", BASE_ERROR_CODES.INVALID_ORIGIN); if (label === "callbackURL") throw APIError.from("FORBIDDEN", BASE_ERROR_CODES.INVALID_CALLBACK_URL); if (label === "redirectURL") throw APIError.from("FORBIDDEN", BASE_ERROR_CODES.INVALID_REDIRECT_URL); if (label === "errorCallbackURL") throw APIError.from("FORBIDDEN", BASE_ERROR_CODES.INVALID_ERROR_CALLBACK_URL); if (label === "newUserCallbackURL") throw APIError.from("FORBIDDEN", BASE_ERROR_CODES.INVALID_NEW_USER_CALLBACK_URL); throw APIError.fromStatus("FORBIDDEN", { message: `Invalid ${label}` }); } }; callbackURL && validateURL(callbackURL, "callbackURL"); redirectURL && validateURL(redirectURL, "redirectURL"); errorCallbackURL && validateURL(errorCallbackURL, "errorCallbackURL"); newUserCallbackURL && validateURL(newUserCallbackURL, "newUserCallbackURL"); }); const originCheck = (getValue) => createAuthMiddleware(async (ctx) => { if (!ctx.request) return; if (shouldSkipOriginCheck(ctx)) return; const callbackURL = getValue(ctx); const validateURL = (url, label) => { if (!url) return; if (!ctx.context.isTrustedOrigin(url, { allowRelativePaths: label !== "origin" })) { 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: ${ctx.context.trustedOrigins}`); if (label === "origin") throw APIError.from("FORBIDDEN", BASE_ERROR_CODES.INVALID_ORIGIN); if (label === "callbackURL") throw APIError.from("FORBIDDEN", BASE_ERROR_CODES.INVALID_CALLBACK_URL); if (label === "redirectURL") throw APIError.from("FORBIDDEN", BASE_ERROR_CODES.INVALID_REDIRECT_URL); if (label === "errorCallbackURL") throw APIError.from("FORBIDDEN", BASE_ERROR_CODES.INVALID_ERROR_CALLBACK_URL); if (label === "newUserCallbackURL") throw APIError.from("FORBIDDEN", BASE_ERROR_CODES.INVALID_NEW_USER_CALLBACK_URL); throw APIError.fromStatus("FORBIDDEN", { message: `Invalid ${label}` }); } }; const callbacks = Array.isArray(callbackURL) ? callbackURL : [callbackURL]; for (const url of callbacks) 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, forceValidate = false) { const headers = ctx.request?.headers; if (!headers || !ctx.request) return; const originHeader = headers.get("origin") || headers.get("referer") || ""; const useCookies = headers.has("cookie"); if (ctx.context.skipCSRFCheck) return; if (shouldSkipCSRFForBackwardCompat(ctx)) { ctx.context.options.advanced?.disableOriginCheck === true && logBackwardCompatWarning(); return; } if (shouldSkipOriginCheck(ctx)) return; if (!(forceValidate || useCookies)) return; if (!originHeader || originHeader === "null") throw APIError.from("FORBIDDEN", BASE_ERROR_CODES.MISSING_OR_NULL_ORIGIN); const trustedOrigins = Array.isArray(ctx.context.options.trustedOrigins) ? ctx.context.trustedOrigins : [...ctx.context.trustedOrigins, ...(await ctx.context.options.trustedOrigins?.(ctx.request))?.filter((v) => Boolean(v)) || []]; if (!trustedOrigins.some((origin) => matchesOriginPattern(originHeader, origin))) { 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 APIError.from("FORBIDDEN", BASE_ERROR_CODES.INVALID_ORIGIN); } } /** * Middleware for CSRF protection using Fetch Metadata headers. * This prevents cross-site navigation login attacks while supporting progressive enhancement. */ const formCsrfMiddleware = createAuthMiddleware(async (ctx) => { if (!ctx.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) { const req = ctx.request; if (!req) return; if (ctx.context.skipCSRFCheck) return; if (shouldSkipCSRFForBackwardCompat(ctx)) return; const headers = req.headers; if (headers.has("cookie")) 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"); if (Boolean(site && site.trim() || mode && mode.trim() || dest && dest.trim())) { 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 APIError.from("FORBIDDEN", BASE_ERROR_CODES.CROSS_SITE_NAVIGATION_LOGIN_BLOCKED); } return await validateOrigin(ctx, true); } if (headers.get("origin") || headers.get("referer")) return await validateOrigin(ctx, true); } //#endregion export { formCsrfMiddleware, originCheck, originCheckMiddleware };