UNPKG

better-auth

Version:

The most comprehensive authentication framework for TypeScript.

239 lines (238 loc) • 10.6 kB
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 };