AkurAI Build
Menu

popagent

public

Latest 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" };
  }
}