fix: close SSRF/rebinding, IDOR, and stale-session authz gaps found in audit
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>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
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;
|
||||
@@ -121,3 +122,74 @@ export async function validateWebhookUrl(rawUrl: string): Promise<string | null>
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user