better-auth
Version:
The most comprehensive authentication framework for TypeScript.
152 lines (151 loc) • 8.32 kB
JavaScript
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 };