Menu
popagent
publicLatest change 6edc7e928c57dab23c0973ed3f7ee70b3ebb72d5 - Initial popagent baseline by AkurAI Build
import { lookup } from "node:dns/promises";
import { isIP } from "node:net";
const IPV4_RE = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/;
export type BrowserDestinationRules = {
allowHosts?: string[];
denyHosts?: string[];
};
function ipv4Parts(hostname: string): number[] | undefined {
const match = hostname.match(IPV4_RE);
if (!match) return undefined;
const parts = match.slice(1).map(Number);
return parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255) ? parts : undefined;
}
function isPrivateIpv4(hostname: string): boolean {
const parts = ipv4Parts(hostname);
if (!parts) return false;
const [a, b] = parts as [number, number, number, number];
return a === 0 || a === 10 || (a === 100 && b >= 64 && b <= 127)
|| (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168)
|| a === 127 || (a === 169 && b === 254) || a >= 224;
}
function isPrivateIpv6(hostname: string): boolean {
const value = hostname.toLowerCase().replace(/^\[|\]$/g, "");
if (value === "::" || value === "::1") return true;
if (value.startsWith("::ffff:")) return isPrivateIpv4(value.slice(7));
const first = Number.parseInt(value.split(":")[0] || "0", 16);
return Number.isFinite(first) && ((first & 0xfe00) === 0xfc00 || (first & 0xffc0) === 0xfe80 || (first & 0xff00) === 0xff00);
}
function isPrivateAddress(address: string): boolean {
return isIP(address) === 4 ? isPrivateIpv4(address) : isIP(address) === 6 ? isPrivateIpv6(address) : false;
}
function hostEntries(value: string | undefined): string[] {
return (value ?? "").split(",").map((entry) => entry.trim().toLowerCase()).filter(Boolean);
}
function matchesHost(entry: string, hostname: string, port: string): boolean {
const target = `${hostname}${port ? `:${port}` : ""}`;
if (entry === target || (!port && entry === hostname)) return true;
if (entry.startsWith("*.") && !entry.includes(":")) {
const suffix = entry.slice(1);
return hostname.endsWith(suffix) && hostname.length > suffix.length;
}
return false;
}
function entriesFor(rules: BrowserDestinationRules, kind: "allow" | "deny") {
const configured = kind === "allow" ? rules.allowHosts : rules.denyHosts;
const environment = kind === "allow" ? process.env.POPAGENT_BROWSER_ALLOW : process.env.POPAGENT_BROWSER_DENY;
return [...(configured ?? []), ...hostEntries(environment)].map((entry) => entry.trim().toLowerCase()).filter(Boolean);
}
export function isAllowedDestination(
url: string,
rules: BrowserDestinationRules = {},
): { allowed: boolean; reason?: string } {
let parsed: URL;
try { parsed = new URL(url); } catch { return { allowed: false, reason: "invalid URL" }; }
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return { allowed: false, reason: `unsupported scheme ${parsed.protocol}` };
}
const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, "");
const port = parsed.port;
if (entriesFor(rules, "deny").some((entry) => matchesHost(entry, hostname, port))) {
return { allowed: false, reason: "destination is denied by browser policy" };
}
if (entriesFor(rules, "allow").some((entry) => matchesHost(entry, hostname, port))) {
return { allowed: true };
}
if (hostname === "localhost") return { allowed: false, reason: "localhost is not allowed" };
if (isPrivateAddress(hostname)) return { allowed: false, reason: "private or local IP address is not allowed" };
return { allowed: true };
}
type ResolveAddresses = (hostname: string) => Promise<string[]>;
const resolveAddresses: ResolveAddresses = async (hostname) => {
const results = await lookup(hostname, { all: true, verbatim: true });
return [...new Set(results.map((result) => result.address))];
};
export async function validateBrowserDestination(
url: string,
rules: BrowserDestinationRules = {},
resolve: ResolveAddresses = resolveAddresses,
): Promise<{ allowed: boolean; reason?: string }> {
const initial = isAllowedDestination(url, rules);
if (!initial.allowed) return initial;
const parsed = new URL(url);
const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, "");
if (entriesFor(rules, "allow").some((entry) => matchesHost(entry, hostname, parsed.port))) return initial;
if (isIP(hostname)) return initial;
try {
const addresses = await resolve(hostname);
if (!addresses.length) return { allowed: false, reason: "hostname did not resolve" };
if (addresses.some(isPrivateAddress)) {
return { allowed: false, reason: "hostname resolves to a private or local address" };
}
return initial;
} catch {
return { allowed: false, reason: "hostname could not be resolved" };
}
}