mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-08-13 14:37:26 +00:00
89 lines
2.6 KiB
TypeScript
89 lines
2.6 KiB
TypeScript
import { lookup } from "node:dns/promises";
|
|
import { isIP } from "node:net";
|
|
|
|
const BLOCKED_HOSTS = new Set(["localhost", "metadata.google.internal"]);
|
|
|
|
export async function assertSafeHttpUrl(input: string) {
|
|
const url = new URL(input);
|
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
throw new Error(`Unsupported URL protocol: ${url.protocol}`);
|
|
}
|
|
|
|
const hostname = url.hostname.toLowerCase().replace(/^\[(.*)\]$/, "$1");
|
|
if (BLOCKED_HOSTS.has(hostname) || hostname.endsWith(".localhost")) {
|
|
throw new Error(`Blocked internal hostname: ${hostname}`);
|
|
}
|
|
|
|
if (isBlockedIp(hostname)) {
|
|
throw new Error(`Blocked internal IP address: ${hostname}`);
|
|
}
|
|
|
|
const addresses = await lookup(hostname, { all: true, verbatim: true });
|
|
for (const address of addresses) {
|
|
if (isBlockedIp(address.address)) {
|
|
throw new Error(`Blocked internal resolved address: ${address.address}`);
|
|
}
|
|
}
|
|
|
|
return url;
|
|
}
|
|
|
|
export function canonicalizeUrl(input: string, base?: string) {
|
|
const url = new URL(input, base);
|
|
url.hash = "";
|
|
|
|
for (const param of [...url.searchParams.keys()]) {
|
|
if (/^(utm_|fbclid$|gclid$|mc_cid$|mc_eid$)/i.test(param)) {
|
|
url.searchParams.delete(param);
|
|
}
|
|
}
|
|
|
|
if ((url.protocol === "https:" && url.port === "443") || (url.protocol === "http:" && url.port === "80")) {
|
|
url.port = "";
|
|
}
|
|
|
|
if (url.pathname !== "/" && url.pathname.endsWith("/")) {
|
|
url.pathname = url.pathname.slice(0, -1);
|
|
}
|
|
|
|
return url.toString();
|
|
}
|
|
|
|
function isBlockedIp(value: string) {
|
|
const kind = isIP(value);
|
|
if (kind === 4) {
|
|
const parts = value.split(".").map((part) => Number(part));
|
|
const [a, b] = parts;
|
|
return (
|
|
a === 0 ||
|
|
a === 10 ||
|
|
a === 127 ||
|
|
(a === 169 && b === 254) ||
|
|
(a === 172 && b >= 16 && b <= 31) ||
|
|
(a === 192 && b === 168) ||
|
|
(a === 100 && b >= 64 && b <= 127)
|
|
);
|
|
}
|
|
|
|
if (kind === 6) {
|
|
const normalized = value.toLowerCase();
|
|
const mappedDottedIpv4 = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(normalized);
|
|
if (mappedDottedIpv4) {
|
|
return isBlockedIp(mappedDottedIpv4[1]);
|
|
}
|
|
const mappedHexIpv4 = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(normalized);
|
|
if (mappedHexIpv4) {
|
|
const high = Number.parseInt(mappedHexIpv4[1], 16);
|
|
const low = Number.parseInt(mappedHexIpv4[2], 16);
|
|
return isBlockedIp(`${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`);
|
|
}
|
|
return (
|
|
normalized === "::1" ||
|
|
normalized.startsWith("fc") ||
|
|
normalized.startsWith("fd") ||
|
|
normalized.startsWith("fe80")
|
|
);
|
|
}
|
|
|
|
return false;
|
|
}
|