182 lines · 7.4 KB · TypeScript
RawDownload
import { BlockList, isIP } from "node:net"; import { Option } from "effect"; import type { TrustedProxyConfig } from "../config"; // ---------------------------------------------------------------------------// Client IP for Better Auth's rate limiter.//// Better Auth only reads the client IP from request headers. Nothing tells it// which TCP peer the request came from, so on a directly exposed self-host it// found no header, logged a warning, and pooled every caller into one bucket of// three sign-ins per ten seconds. The fix has two halves:////   1. The server stamps the socket peer address onto CLIENT_IP_HEADER before//      Better Auth sees the request, always overwriting anything the client//      sent. A direct client can only ever rate-limit itself.//   2. Behind a reverse proxy the peer is the proxy, so the operator names the//      header the proxy sets (EXECUTOR_TRUSTED_PROXY_HEADER) and the addresses//      it connects from (EXECUTOR_TRUSTED_PROXIES). That header is honoured only//      on connections from one of those addresses and stripped otherwise, so a//      client that reaches the container directly cannot assert a proxy header.//// Better Auth's own default reads `x-forwarded-for` from anyone. That is not// restored here: a client could rotate the header to dodge the limit. Behind// an unconfigured proxy every user therefore shares one bucket, so the stamper// warns the operator once when it sees a proxy-style header in that state.// --------------------------------------------------------------------------- /** Server-stamped socket peer address. Never trusted from the client. */export const CLIENT_IP_HEADER = "x-executor-client-ip"; /** * Headers a reverse proxy commonly sets to the client IP. Seeing one with no * trusted proxy configured is the signature of a proxied deployment that has * not told Executor about its proxy. */export const PROXY_HINT_HEADERS = [  "x-forwarded-for",  "x-real-ip",  "cf-connecting-ip",  "true-client-ip",] as const; export interface IpRange {  readonly address: string;  readonly prefix: number;  readonly family: "ipv4" | "ipv6";} /** * Parse an IP address or `address/prefix` CIDR range. `undefined` for anything * else, so a typo cannot silently become a non-matching (or all-matching) rule. */export const parseIpRange = (value: string): IpRange | undefined => {  const slash = value.indexOf("/");  const address = slash === -1 ? value : value.slice(0, slash);  const version = isIP(address);  if (version === 0) return undefined;  const family = version === 6 ? "ipv6" : "ipv4";  const maxPrefix = version === 6 ? 128 : 32;  if (slash === -1) return { address, prefix: maxPrefix, family };  const prefixText = value.slice(slash + 1);  if (!/^\d{1,3}$/.test(prefixText)) return undefined;  const prefix = Number(prefixText);  if (prefix > maxPrefix) return undefined;  return { address, prefix, family };}; const blockListOf = (ranges: readonly IpRange[]): BlockList => {  const list = new BlockList();  for (const range of ranges) list.addSubnet(range.address, range.prefix, range.family);  return list;}; // `isIP` rejects the `::ffff:` prefix form only when malformed; a dual-stack// socket may report an IPv4 peer as an IPv4-mapped IPv6 address, which// BlockList matches against IPv4 rules on its own.const isTrustedPeer = (list: BlockList, address: string): boolean => {  const version = isIP(address);  return version !== 0 && list.check(address, version === 6 ? "ipv6" : "ipv4");}; /** The `advanced.ipAddress` block handed to Better Auth. */export interface ClientIpAddressOptions {  /**   * Header names Better Auth walks, in order, to find the client IP. The   * proxy's header leads when one is configured; the server-stamped socket   * address is the fallback for requests that did not come through the proxy.   */  readonly ipAddressHeaders: string[];  /**   * Hops Better Auth strips from the right of a forwarded chain. Only present   * with a configured proxy; when every hop in a header is trusted, Better   * Auth finds no client in it and moves to the next header.   */  readonly trustedProxies?: string[];} export const clientIpAddressOptions = (  trustedProxy: TrustedProxyConfig | undefined,): ClientIpAddressOptions =>  trustedProxy    ? {        ipAddressHeaders: [trustedProxy.header, CLIENT_IP_HEADER],        trustedProxies: [...trustedProxy.proxies],      }    : { ipAddressHeaders: [CLIENT_IP_HEADER] }; /** * The request with its headers replaced, everything else passed through. * * Deliberately NOT `new Request(request, { headers })`: Bun's copy constructor * never delivers a body that is not one of its own native streams, and the * Vite dev middleware hands the handler `Readable.toWeb(req)`, so every * sign-in POST hung until the client gave up (the production Bun.serve path * was unaffected). Rebuilding from the parts with the body passed explicitly * behaves the same on both paths; `duplex: "half"` is what the Fetch spec * requires when a request body is a stream. */const withHeaders = (request: Request, headers: Headers): Request => {  const init: RequestInit & { duplex?: "half" } = {    method: request.method,    headers,    signal: request.signal,  };  if (request.body) {    init.body = request.body;    init.duplex = "half";  }  return new Request(request.url, init);}; export interface ClientIpStamperOptions {  /** Where the one-time unconfigured-proxy warning goes. Defaults to console.warn. */  readonly warn?: (message: string) => void;} const unconfiguredProxyWarning = (header: string): string =>  `[executor] An auth request carried ${header}, but no trusted proxy is configured, so every user behind that proxy shares one sign-in rate-limit bucket. Set EXECUTOR_TRUSTED_PROXY_HEADER and EXECUTOR_TRUSTED_PROXIES so the limit keys on the real client IP.`; /** * Build the per-request rewrite that stamps the socket peer address onto * CLIENT_IP_HEADER (or removes it when the runtime cannot report one) and * drops the trusted-proxy header unless the peer is a configured proxy. The * `remoteAddress` is the TCP peer as reported by the HTTP server, not anything * read from the request. * * With no trusted proxy configured, the first request that carries one of * PROXY_HINT_HEADERS triggers a single operator warning for the stamper's * lifetime (one stamper per server). */export const makeClientIpStamper = (  trustedProxy: TrustedProxyConfig | undefined,  options: ClientIpStamperOptions = {},): ((request: Request, remoteAddress: Option.Option<string>) => Request) => {  const proxies = trustedProxy    ? blockListOf(trustedProxy.proxies.flatMap((entry) => parseIpRange(entry) ?? []))    : undefined;  const warn = options.warn ?? ((message: string) => console.warn(message));  let warnedUnconfiguredProxy = false;  return (request, remoteAddress) => {    const headers = new Headers(request.headers);    const peer = Option.getOrUndefined(remoteAddress);    if (peer) {      headers.set(CLIENT_IP_HEADER, peer);    } else {      headers.delete(CLIENT_IP_HEADER);    }    if (trustedProxy && proxies && !(peer && isTrustedPeer(proxies, peer))) {      headers.delete(trustedProxy.header);    }    if (!trustedProxy && !warnedUnconfiguredProxy) {      const hint = PROXY_HINT_HEADERS.find((header) => headers.has(header));      if (hint) {        warnedUnconfiguredProxy = true;        warn(unconfiguredProxyWarning(hint));      }    }    return withHeaders(request, headers);  };};