5a9e306357
Bump to 0.5.1. Fixes: unfollowed-redirect SSRF + DNS-rebinding in AI url-import and webhook dispatch (new safeFetch with IP-pinned undici dispatcher); cross-user photo deletion via unvalidated recipe/review storage keys; comment-moderation and tier-quota checks trusting a stale cached session role/tier instead of the DB. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
196 lines
6.4 KiB
TypeScript
196 lines
6.4 KiB
TypeScript
import dns from "node:dns/promises";
|
|
import net from "node:net";
|
|
import { Agent } from "undici";
|
|
|
|
function isPrivateV4(a: number, b: number): boolean {
|
|
if (a === 127) return true;
|
|
if (a === 10) return true;
|
|
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
if (a === 192 && b === 168) return true;
|
|
if (a === 169 && b === 254) return true;
|
|
if (a >= 224) return true;
|
|
return false;
|
|
}
|
|
|
|
/** Expands a valid IPv6 address (any compression form) into 8 16-bit groups as a BigInt. */
|
|
function ipv6ToBigInt(ip: string): bigint | null {
|
|
if (net.isIPv6(ip) !== true) return null;
|
|
|
|
const [head, tail] = ip.split("::");
|
|
const headParts = head ? head.split(":") : [];
|
|
const tailParts = tail ? tail.split(":") : [];
|
|
|
|
// An embedded IPv4 tail (e.g. "::ffff:127.0.0.1") occupies the last two hextets.
|
|
const expand = (parts: string[]): string[] => {
|
|
const last = parts[parts.length - 1];
|
|
if (last && last.includes(".")) {
|
|
const octets = last.split(".").map(Number);
|
|
if (octets.length !== 4 || octets.some((o) => !Number.isInteger(o) || o < 0 || o > 255)) {
|
|
return [];
|
|
}
|
|
const hex1 = ((octets[0]! << 8) | octets[1]!).toString(16);
|
|
const hex2 = ((octets[2]! << 8) | octets[3]!).toString(16);
|
|
return [...parts.slice(0, -1), hex1, hex2];
|
|
}
|
|
return parts;
|
|
};
|
|
|
|
const expandedHead = expand(headParts);
|
|
const expandedTail = expand(tailParts);
|
|
|
|
let groups: string[];
|
|
if (ip.includes("::")) {
|
|
const missing = 8 - (expandedHead.length + expandedTail.length);
|
|
if (missing < 0) return null;
|
|
groups = [...expandedHead, ...Array(missing).fill("0"), ...expandedTail];
|
|
} else {
|
|
groups = expandedHead;
|
|
}
|
|
|
|
if (groups.length !== 8) return null;
|
|
|
|
let result = BigInt(0);
|
|
for (const g of groups) {
|
|
const val = parseInt(g || "0", 16);
|
|
if (Number.isNaN(val)) return null;
|
|
result = (result << BigInt(16)) | BigInt(val);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export function isPrivateAddress(ip: string): boolean {
|
|
const v4 = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
if (v4) {
|
|
const octets = v4.slice(1, 5).map(Number);
|
|
if (octets.some((o) => o > 255)) return true; // malformed, fail closed
|
|
const [a, b] = octets as [number, number];
|
|
return isPrivateV4(a, b);
|
|
}
|
|
|
|
const addr = ipv6ToBigInt(ip);
|
|
if (addr === null) return true; // unparseable, fail closed
|
|
|
|
if (addr === BigInt(0) || addr === BigInt(1)) return true; // :: and ::1
|
|
|
|
const fc00 = BigInt(0xfc00) << BigInt(112);
|
|
const fe80 = BigInt(0xfe80) << BigInt(112);
|
|
const mask7 = BigInt(0xfe00) << BigInt(112); // /7 mask for fc00::/7 (top 7 bits of the address)
|
|
const mask10 = BigInt(0xffc0) << BigInt(112); // /10 mask for fe80::/10 (top 10 bits of the address)
|
|
if ((addr & mask7) === (fc00 & mask7)) return true; // unique local fc00::/7
|
|
if ((addr & mask10) === (fe80 & mask10)) return true; // link-local fe80::/10
|
|
|
|
// IPv4-mapped ::ffff:0:0/96
|
|
if (addr >> BigInt(32) === BigInt(0xffff)) {
|
|
const embedded = addr & BigInt(0xffffffff);
|
|
const a = Number((embedded >> BigInt(24)) & BigInt(0xff));
|
|
const b = Number((embedded >> BigInt(16)) & BigInt(0xff));
|
|
return isPrivateV4(a, b);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Returns an error string if the URL is disallowed (SSRF), or null if safe.
|
|
* Blocks non-http(s) schemes, RFC-1918, loopback, link-local, cloud metadata.
|
|
*/
|
|
export async function validateWebhookUrl(rawUrl: string): Promise<string | null> {
|
|
let url: URL;
|
|
try {
|
|
url = new URL(rawUrl);
|
|
} catch {
|
|
return "Invalid URL";
|
|
}
|
|
|
|
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
return "Webhook URL must use http or https";
|
|
}
|
|
|
|
let addresses: string[];
|
|
try {
|
|
const results = await dns.lookup(url.hostname, { all: true, family: 0 });
|
|
addresses = results.map((r) => r.address);
|
|
} catch {
|
|
return "Unable to resolve webhook hostname";
|
|
}
|
|
|
|
for (const addr of addresses) {
|
|
if (isPrivateAddress(addr)) {
|
|
return "Webhook URL must not point to a private or reserved address";
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/** Resolves hostname once and returns the first non-private address, or throws. */
|
|
async function resolvePinnedAddress(hostname: string): Promise<{ address: string; family: number }> {
|
|
let results: { address: string; family: number }[];
|
|
try {
|
|
results = await dns.lookup(hostname, { all: true, family: 0 });
|
|
} catch {
|
|
throw new Error("Unable to resolve hostname");
|
|
}
|
|
const safe = results.find((r) => !isPrivateAddress(r.address));
|
|
if (!safe) throw new Error("URL must not point to a private or reserved address");
|
|
return safe;
|
|
}
|
|
|
|
/**
|
|
* SSRF-safe fetch: resolves the hostname once, validates the resolved IP, then
|
|
* pins the actual connection to that exact IP (via a custom undici Agent lookup)
|
|
* so a subsequent DNS re-resolution by the HTTP client can't be rebound to an
|
|
* internal address. Redirects are followed manually, with each hop re-validated
|
|
* and re-pinned the same way — so a 3xx response can't be used to reach an
|
|
* internal service either.
|
|
*/
|
|
export async function safeFetch(
|
|
rawUrl: string,
|
|
init: { method?: string; headers?: Record<string, string>; body?: string; signal?: AbortSignal } = {},
|
|
maxRedirects = 5
|
|
): Promise<Response> {
|
|
let currentUrl = rawUrl;
|
|
|
|
for (let hop = 0; ; hop++) {
|
|
let url: URL;
|
|
try {
|
|
url = new URL(currentUrl);
|
|
} catch {
|
|
throw new Error("Invalid URL");
|
|
}
|
|
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
throw new Error("URL must use http or https");
|
|
}
|
|
|
|
const { address, family } = await resolvePinnedAddress(url.hostname);
|
|
|
|
const agent = new Agent({
|
|
connect: {
|
|
lookup: (_hostname, _opts, callback) => {
|
|
callback(null, [{ address, family: family as 4 | 6 }]);
|
|
},
|
|
},
|
|
});
|
|
|
|
// Node's global fetch is undici under the hood and accepts the same
|
|
// `dispatcher` extension, so this pins the connection without needing a
|
|
// separate undici-specific fetch call.
|
|
const res = await fetch(currentUrl, {
|
|
...init,
|
|
redirect: "manual",
|
|
// @ts-expect-error -- `dispatcher` is a Node/undici fetch extension, not in the lib.dom.d.ts RequestInit type
|
|
dispatcher: agent,
|
|
});
|
|
|
|
if (res.status >= 300 && res.status < 400) {
|
|
if (hop >= maxRedirects) throw new Error("Too many redirects");
|
|
const location = res.headers.get("location");
|
|
if (!location) throw new Error("Redirect with no Location header");
|
|
currentUrl = new URL(location, currentUrl).toString();
|
|
continue;
|
|
}
|
|
|
|
return res;
|
|
}
|
|
}
|