Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 76 additions & 11 deletions src/lib/server/isURLLocal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,17 @@ const dnsLookup = (hostname: string): Promise<{ address: string; family: number
});
};

const dnsLookupAll = (
hostname: string
): Promise<Array<{ address: string; family: number }>> => {
return new Promise((resolve, reject) => {
dns.lookup(hostname, { all: true }, (err, addresses) => {
if (err) return reject(err);
resolve(addresses);
});
});
};

function assertValidHostname(hostname: string): void {
if (!hostname || hostname.length > 253) {
throw new Error("Invalid hostname");
Expand Down Expand Up @@ -38,20 +49,22 @@ export async function isURLLocal(URL: URL): Promise<boolean> {
assertValidHostname(URL.hostname);
}

const { address, family } = await dnsLookup(URL.hostname);

if (family === 4) {
const addr = new Address4(address);
const localSubnet = new Address4("127.0.0.0/8");
return addr.isInSubnet(localSubnet);
}
const addresses = await dnsLookupAll(URL.hostname);

if (family === 6) {
const addr = new Address6(address);
return addr.isLoopback() || addr.isInSubnet(new Address6("::1/128")) || addr.isLinkLocal();
for (const { address, family } of addresses) {
if (family === 4) {
const addr = new Address4(address);
const localSubnet = new Address4("127.0.0.0/8");
if (addr.isInSubnet(localSubnet)) return true;
} else if (family === 6) {
const addr = new Address6(address);
if (addr.isLoopback() || addr.isInSubnet(new Address6("::1/128")) || addr.isLinkLocal()) {
return true;
}
}
}

throw Error("Unknown IP family");
return false;
}

export function isURLStringLocal(url: string) {
Expand All @@ -72,3 +85,55 @@ export function isHostLocalhost(host: string): boolean {

return false;
}

/** Check if an IPv4 address falls in any private/reserved range (RFC 1918, loopback, link-local). */
export function isPrivateIPv4(address: string): boolean {
const addr = new Address4(address);
const privateRanges = [
new Address4("10.0.0.0/8"),
new Address4("172.16.0.0/12"),
new Address4("192.168.0.0/16"),
new Address4("127.0.0.0/8"),
new Address4("169.254.0.0/16"),
new Address4("0.0.0.0/8"),
];
return privateRanges.some((range) => addr.isInSubnet(range));
}

/** Check if an IPv6 address is loopback, link-local, or unique-local. */
export function isPrivateIPv6(address: string): boolean {
const addr = new Address6(address);
return addr.isLoopback() || addr.isLinkLocal() || addr.isInSubnet(new Address6("fc00::/7"));
}

/** Check if a URL resolves to a private/reserved IP address (SSRF protection).
* Resolves all DNS records and rejects if ANY address is private/reserved. */
export async function isURLPrivate(url: URL): Promise<boolean> {
const addresses = await dnsLookupAll(url.hostname);

for (const { address, family } of addresses) {
if (family === 4 && isPrivateIPv4(address)) return true;
if (family === 6 && isPrivateIPv6(address)) return true;
}

return false;
}

/** Validate that a URL is safe for external fetch (protocol + private IP check). */
export async function validateExternalUrl(
url: string,
opts?: { allowHttp?: boolean }
): Promise<URL> {
const parsed = new URL(url);
const protocol = parsed.protocol.toLowerCase();

if (protocol !== "https:" && !(opts?.allowHttp && protocol === "http:")) {
throw new Error(`Disallowed protocol: ${protocol}`);
}

if (await isURLPrivate(parsed)) {
throw new Error("URL resolves to a private/reserved IP address");
}

return parsed;
}