Files
ihasmail-inbuxa/server/src/clientip.ts
T
jcoffey-dev 98e105efd6 Bound what a request can make the server hold
Cap JSON bodies at 64 KB on every API route except JMAP and uploads, which
bound themselves. Sign-in used to read a body of any size before its rate
limits ran; the flood ceiling now also runs before the body is read.

For sessions whose JMAP requests are checked, lower the read cap from 16 MB
to 4 MB, allow four such reads per session at once, and turn requests away
with a 503 once 32 MB is held across everyone.

Count sign-in limits per /64 for IPv6, since one host holds a whole /64.

Bind the compose example to loopback, and run it read-only with no
capabilities and no-new-privileges. Keep .env.* out of git and the image
build context.
2026-09-16 07:54:20 -07:00

118 lines
4.8 KiB
TypeScript

import { isIP } from "node:net";
/**
* Work out who is really talking to us, for rate limiting and session records.
*
* `X-Forwarded-For` is a list that each hop appends to, so the entry nearest
* the right is the one our own proxy observed and the entries to its left were
* supplied by whoever came before — including the client. nginx's
* `$proxy_add_x_forwarded_for` appends, so a client sending
* `X-Forwarded-For: 1.2.3.4` arrives as `1.2.3.4, <their real address>`:
* reading the leftmost entry hands an attacker a rate-limit key they can
* change at will. Read from the right instead, skipping hops we run ourselves,
* and only believe the header at all when the peer is a proxy we trust.
*/
/** Peers whose forwarding headers are believed when none are configured. */
const DEFAULT_TRUSTED = ["127.0.0.0/8", "::1/128", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "fc00::/7"];
export interface TrustConfig {
trustProxy: boolean;
/** CIDRs or bare addresses; empty means DEFAULT_TRUSTED. */
trustedProxies: string[];
}
function toBits(addr: string): { value: bigint; width: number } | null {
const v = isIP(addr);
if (v === 4) {
const parts = addr.split(".").map(Number);
if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return null;
return { value: parts.reduce((acc, n) => (acc << 8n) | BigInt(n), 0n), width: 32 };
}
if (v === 6) {
// Expand "::" and any embedded IPv4 tail into eight 16-bit groups.
let text = addr;
const tail = /:(\d+\.\d+\.\d+\.\d+)$/.exec(text);
if (tail) {
const b = tail[1]!.split(".").map(Number);
text = `${text.slice(0, tail.index)}:${((b[0]! << 8) | b[1]!).toString(16)}:${((b[2]! << 8) | b[3]!).toString(16)}`;
}
const [head, rest] = text.split("::");
const left = head ? head.split(":").filter(Boolean) : [];
const right = rest !== undefined ? (rest ? rest.split(":").filter(Boolean) : []) : null;
const groups = right === null ? left : [...left, ...Array<string>(8 - left.length - right.length).fill("0"), ...right];
if (groups.length !== 8) return null;
let value = 0n;
for (const g of groups) {
const n = parseInt(g, 16);
if (!Number.isInteger(n) || n < 0 || n > 0xffff) return null;
value = (value << 16n) | BigInt(n);
}
return { value, width: 128 };
}
return null;
}
/** Is `addr` inside `range`, which may be a CIDR or a single address? */
export function inRange(addr: string, range: string): boolean {
const [net, bitsText] = range.trim().split("/");
const a = toBits(addr);
const n = toBits(net ?? "");
if (!a || !n || a.width !== n.width) return false;
const bits = bitsText === undefined ? n.width : Number(bitsText);
if (!Number.isInteger(bits) || bits < 0 || bits > n.width) return false;
if (bits === 0) return true;
const shift = BigInt(n.width - bits);
return a.value >> shift === n.value >> shift;
}
export function isTrustedProxy(addr: string, cfg: TrustConfig): boolean {
const ranges = cfg.trustedProxies.length ? cfg.trustedProxies : DEFAULT_TRUSTED;
return ranges.some((r) => inRange(addr, r));
}
export interface ForwardHeaders {
forwardedFor?: string;
realIp?: string;
}
/**
* The client address to attribute a request to. `peer` is the socket address,
* which is the only part nobody downstream can forge.
*/
export function resolveClientIp(peer: string, headers: ForwardHeaders, cfg: TrustConfig): string {
if (!cfg.trustProxy || !peer || peer === "unknown") return peer || "unknown";
// A peer we do not run is not allowed to tell us who its client is.
if (!isTrustedProxy(peer, cfg)) return peer;
const chain = (headers.forwardedFor ?? "")
.split(",")
.map((s) => s.trim().replace(/^\[|\]$/g, "").replace(/^::ffff:(?=\d+\.\d+\.\d+\.\d+$)/i, ""))
.filter((s) => isIP(s) !== 0);
// Rightmost first: the last hop we trust is ours, anything left of the first
// untrusted entry was written by someone we have no reason to believe.
for (let i = chain.length - 1; i >= 0; i--) {
if (!isTrustedProxy(chain[i]!, cfg)) return chain[i]!;
}
if (chain.length) return chain[0]!;
const real = headers.realIp?.trim();
return real && isIP(real) !== 0 ? real : peer;
}
/**
* The key a rate limit counts an address under.
*
* An IPv4 address is the key as it is. An IPv6 address is cut to its /64: that
* is the smallest block an ISP or a VPS hands out, so anyone who holds one
* address holds 2^64 of them, and a limit keyed on the full address is no
* limit. Everyone behind one /64 shares a budget, which is the same bargain an
* IPv4 NAT already makes.
*/
export function rateLimitKey(ip: string): string {
if (isIP(ip) !== 6) return ip;
const bits = toBits(ip);
if (!bits) return ip;
const prefix = bits.value >> 64n;
const groups = [48n, 32n, 16n, 0n].map((s) => ((prefix >> s) & 0xffffn).toString(16));
return `${groups.join(":")}::/64`;
}