better-auth
Version:
The most comprehensive authentication framework for TypeScript.
239 lines (238 loc) • 10.6 kB
JavaScript
import { wildcardMatch } from "./wildcard.mjs";
import { env } from "@better-auth/core/env";
import { BetterAuthError } from "@better-auth/core/error";
//#region src/utils/url.ts
const SLASH_CHAR_CODE = "/".charCodeAt(0);
/**
* Minimal loopback check for dev scheme inference only. Reachable from
* `client/config.ts` via `getBaseURL`, so we MUST NOT import the full
* `@better-auth/core/utils/host` classifier here: its `utils/ip` dependency
* on zod would leak into the client bundle (see `e2e/smoke/test/vite.spec.ts`).
*
* Server-side SSRF/loopback checks (oauth redirect matching, trusted-origin
* resolution, electron fetch gate) continue to use the authoritative
* `isLoopbackHost` from `@better-auth/core/utils/host`. This helper's only
* job is picking `http` vs `https` for dev ergonomics.
*/
function isLoopbackForDevScheme(host) {
const hostname = host.replace(/:\d+$/, "").replace(/^\[|\]$/g, "").toLowerCase();
return hostname === "localhost" || hostname.endsWith(".localhost") || hostname === "::1" || hostname.startsWith("127.");
}
function trimTrailingSlashes(value) {
let end = value.length;
while (end > 0 && value.charCodeAt(end - 1) === SLASH_CHAR_CODE) end--;
return end === value.length ? value : value.slice(0, end);
}
function checkHasPath(url) {
try {
return (trimTrailingSlashes(new URL(url).pathname) || "/") !== "/";
} catch {
throw new BetterAuthError(`Invalid base URL: ${url}. Please provide a valid base URL.`);
}
}
function assertHasProtocol(url) {
try {
const parsedUrl = new URL(url);
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") throw new BetterAuthError(`Invalid base URL: ${url}. URL must include 'http://' or 'https://'`);
} catch (error) {
if (error instanceof BetterAuthError) throw error;
throw new BetterAuthError(`Invalid base URL: ${url}. Please provide a valid base URL.`, { cause: error });
}
}
function withPath(url, path = "/api/auth") {
assertHasProtocol(url);
if (checkHasPath(url)) return url;
const trimmedUrl = trimTrailingSlashes(url);
if (!path || path === "/") return trimmedUrl;
path = path.startsWith("/") ? path : `/${path}`;
return `${trimmedUrl}${path}`;
}
function validateProxyHeader(header, type) {
if (!header || header.trim() === "") return false;
if (type === "proto") return header === "http" || header === "https";
if (type === "host") {
if ([
/\.\./,
/\0/,
/[\s]/,
/^[.]/,
/[<>'"]/,
/javascript:/i,
/file:/i,
/data:/i
].some((pattern) => pattern.test(header))) return false;
return /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*(:[0-9]{1,5})?$/.test(header) || /^(\d{1,3}\.){3}\d{1,3}(:[0-9]{1,5})?$/.test(header) || /^\[[0-9a-fA-F:]+\](:[0-9]{1,5})?$/.test(header) || /^localhost(:[0-9]{1,5})?$/i.test(header);
}
return false;
}
function getBaseURL(url, path, request, loadEnv, trustedProxyHeaders) {
if (url) return withPath(url, path);
if (loadEnv !== false) {
const fromEnv = env.BETTER_AUTH_URL || env.NEXT_PUBLIC_BETTER_AUTH_URL || env.PUBLIC_BETTER_AUTH_URL || env.NUXT_PUBLIC_BETTER_AUTH_URL || env.NUXT_PUBLIC_AUTH_URL || (env.BASE_URL !== "/" ? env.BASE_URL : void 0);
if (fromEnv) return withPath(fromEnv, path);
}
const fromRequest = request?.headers.get("x-forwarded-host");
const fromRequestProto = request?.headers.get("x-forwarded-proto");
if (fromRequest && fromRequestProto && trustedProxyHeaders) {
if (validateProxyHeader(fromRequestProto, "proto") && validateProxyHeader(fromRequest, "host")) try {
return withPath(`${fromRequestProto}://${fromRequest}`, path);
} catch (_error) {}
}
if (request) {
const url = getOrigin(request.url);
if (!url) throw new BetterAuthError("Could not get origin from request. Please provide a valid base URL.");
return withPath(url, path);
}
if (typeof window !== "undefined" && window.location) return withPath(window.location.origin, path);
}
function getOrigin(url) {
try {
const parsedUrl = new URL(url);
return parsedUrl.origin === "null" ? null : parsedUrl.origin;
} catch {
return null;
}
}
function getProtocol(url) {
try {
return new URL(url).protocol;
} catch {
return null;
}
}
function getHost(url) {
try {
return new URL(url).host;
} catch {
return null;
}
}
/**
* Checks if the baseURL config is a dynamic config object
*/
function isDynamicBaseURLConfig(config) {
return typeof config === "object" && config !== null && "allowedHosts" in config && Array.isArray(config.allowedHosts);
}
/**
* Check if a value is a `Request`
* - `instanceof`: works for native Request instances
* - `toString`: handles where instanceof check fails but the object is still a
* valid Request (e.g. cross-realm, polyfills). Paired with a shape check so
* an object that only spoofs `Symbol.toStringTag` without the real shape is
* rejected before downstream code tries to read `.headers` / `.url`.
*
* @param value The value to check
* @returns `true` if the value is a Request instance
*/
function isRequestLike(value) {
if (value instanceof Request) return true;
if (typeof value !== "object" || value === null || Object.prototype.toString.call(value) !== "[object Request]") return false;
const v = value;
return typeof v.url === "string" && typeof v.headers === "object" && v.headers !== null && typeof v.headers.get === "function";
}
/**
* Extracts the host from a `Request` or `Headers`.
* Honors `x-forwarded-host` only when `trustedProxyHeaders` is enabled,
* then falls back to the `host` header and finally the request URL.
*/
function getHostFromSource(source, trustedProxyHeaders) {
const headers = isRequestLike(source) ? source.headers : source;
if (trustedProxyHeaders) {
const forwardedHost = headers.get("x-forwarded-host");
if (forwardedHost && validateProxyHeader(forwardedHost, "host")) return forwardedHost;
}
const host = headers.get("host");
if (host && validateProxyHeader(host, "host")) return host;
if (isRequestLike(source)) try {
return new URL(source.url).host;
} catch {
return null;
}
return null;
}
/**
* Extracts the protocol from a `Request` or `Headers`.
* Honors `x-forwarded-proto` only when `trustedProxyHeaders` is enabled,
* then falls back to the request URL, then to "https".
*/
function getProtocolFromSource(source, configProtocol, trustedProxyHeaders) {
if (configProtocol === "http" || configProtocol === "https") return configProtocol;
const headers = isRequestLike(source) ? source.headers : source;
if (trustedProxyHeaders) {
const forwardedProto = headers.get("x-forwarded-proto");
if (forwardedProto && validateProxyHeader(forwardedProto, "proto")) return forwardedProto;
}
if (isRequestLike(source)) try {
const url = new URL(source.url);
if (url.protocol === "http:" || url.protocol === "https:") return url.protocol.slice(0, -1);
} catch {}
const host = getHostFromSource(source, trustedProxyHeaders);
if (host && isLoopbackForDevScheme(host)) return "http";
return "https";
}
/**
* Matches a hostname against a host pattern.
* Supports wildcard patterns like `*.vercel.app` or `preview-*.myapp.com`.
*
* @param host The hostname to test (e.g., "myapp.com", "preview-123.vercel.app")
* @param pattern The host pattern (e.g., "myapp.com", "*.vercel.app")
* @returns {boolean} true if the host matches the pattern, false otherwise.
*
* @example
* ```ts
* matchesHostPattern("myapp.com", "myapp.com") // true
* matchesHostPattern("preview-123.vercel.app", "*.vercel.app") // true
* matchesHostPattern("preview-123.myapp.com", "preview-*.myapp.com") // true
* matchesHostPattern("evil.com", "myapp.com") // false
* ```
*/
const matchesHostPattern = (host, pattern) => {
if (!host || !pattern) return false;
const normalizedHost = host.replace(/^https?:\/\//, "").split("/")[0].toLowerCase();
const normalizedPattern = pattern.replace(/^https?:\/\//, "").split("/")[0].toLowerCase();
if (normalizedPattern.includes("*") || normalizedPattern.includes("?")) return wildcardMatch(normalizedPattern)(normalizedHost);
return normalizedHost.toLowerCase() === normalizedPattern.toLowerCase();
};
/**
* Resolves the base URL from a dynamic config based on the incoming request.
* Validates the derived host against the allowedHosts allowlist.
*
* @param config The dynamic base URL config
* @param request The incoming request
* @param basePath The base path to append
* @returns The resolved base URL with path
* @throws BetterAuthError if host is not in allowedHosts and no fallback is set
*/
function resolveDynamicBaseURL(config, source, basePath, trustedProxyHeaders) {
const host = getHostFromSource(source, trustedProxyHeaders);
if (!host) {
if (config.fallback) return withPath(config.fallback, basePath);
throw new BetterAuthError("Could not determine host from request headers. Please provide a fallback URL in your baseURL config.");
}
if (config.allowedHosts.some((pattern) => matchesHostPattern(host, pattern))) return withPath(`${getProtocolFromSource(source, config.protocol, trustedProxyHeaders)}://${host}`, basePath);
if (config.fallback) return withPath(config.fallback, basePath);
throw new BetterAuthError(`Host "${host}" is not in the allowed hosts list. Allowed hosts: ${config.allowedHosts.join(", ")}. Add this host to your allowedHosts config or provide a fallback URL.`);
}
/**
* Resolves the base URL from any config type (static string or dynamic object).
* This is the main entry point for base URL resolution.
*
* @param config The base URL config (string or object)
* @param basePath The base path to append
* @param request Optional request for dynamic resolution
* @param loadEnv Whether to load from environment variables
* @param trustedProxyHeaders Whether to trust proxy headers (for legacy behavior)
* @returns The resolved base URL with path
*/
function resolveBaseURL(config, basePath, source, loadEnv, trustedProxyHeaders) {
if (isDynamicBaseURLConfig(config)) {
if (source) return resolveDynamicBaseURL(config, source, basePath, trustedProxyHeaders);
if (config.fallback) return withPath(config.fallback, basePath);
return getBaseURL(void 0, basePath, void 0, loadEnv, trustedProxyHeaders);
}
const request = isRequestLike(source) ? source : void 0;
if (typeof config === "string") return getBaseURL(config, basePath, request, loadEnv, trustedProxyHeaders);
return getBaseURL(void 0, basePath, request, loadEnv, trustedProxyHeaders);
}
//#endregion
export { getBaseURL, getHost, getHostFromSource, getOrigin, getProtocol, getProtocolFromSource, isDynamicBaseURLConfig, isRequestLike, matchesHostPattern, resolveBaseURL, resolveDynamicBaseURL, trimTrailingSlashes };