import "server-only"; import { NextRequest, NextResponse } from "next/server"; const LOCAL_HOSTS = new Set(["127.0.0.1", "::1", "localhost"]); function bareHost(value: string | null): string { if (!value) return ""; const trimmed = value.trim().toLowerCase(); if (trimmed === "::1") return trimmed; if (trimmed.startsWith("[") && trimmed.includes("]")) { return trimmed.slice(1, trimmed.indexOf("]")); } return trimmed.split(":")[0]; } function isLocalHost(value: string | null): boolean { return LOCAL_HOSTS.has(bareHost(value)); } function trustedLanEnabled(): boolean { return process.env.PINKUDEX_TRUSTED_LAN === "1"; } function trustedHostnames(): Set { const raw = process.env.PINKUDEX_TRUSTED_HOSTNAMES; if (!raw) return new Set(); return new Set( raw .split(",") .map((v) => v.trim().toLowerCase()) .filter(Boolean), ); } function parseIPv4(value: string): number[] | null { const parts = value.split("."); if (parts.length !== 4) return null; const octets: number[] = []; for (const p of parts) { if (!/^\d{1,3}$/.test(p)) return null; const n = Number(p); if (n < 0 || n > 255) return null; octets.push(n); } return octets; } function isPrivateIPv4(value: string): boolean { const o = parseIPv4(value); if (!o) return false; // 127.0.0.0/8 (loopback) if (o[0] === 127) return true; // 10.0.0.0/8 if (o[0] === 10) return true; // 172.16.0.0/12 if (o[0] === 172 && o[1] >= 16 && o[1] <= 31) return true; // 192.168.0.0/16 if (o[0] === 192 && o[1] === 168) return true; // 100.64.0.0/10 (CGNAT / Tailscale) if (o[0] === 100 && o[1] >= 64 && o[1] <= 127) return true; return false; } function isPrivateIPv6(value: string): boolean { const v = value.toLowerCase(); if (v === "::1") return true; // fc00::/7 (ULA): first byte 0xfc or 0xfd if (v.startsWith("fc") || v.startsWith("fd")) return true; // fe80::/10 (link-local) if (v.startsWith("fe8") || v.startsWith("fe9") || v.startsWith("fea") || v.startsWith("feb")) return true; return false; } function isTrustedHost(value: string | null): boolean { const host = bareHost(value); if (!host) return false; if (LOCAL_HOSTS.has(host)) return true; if (!trustedLanEnabled()) return false; if (host.includes(":") || /^[0-9a-f:]+$/i.test(host)) { if (isPrivateIPv6(host)) return true; } if (isPrivateIPv4(host)) return true; if (trustedHostnames().has(host)) return true; return false; } function forwardedFor(req: NextRequest): string[] { const out: string[] = []; const xff = req.headers.get("x-forwarded-for"); if (xff) out.push(...xff.split(",").map((v) => v.trim()).filter(Boolean)); const realIp = req.headers.get("x-real-ip"); if (realIp) out.push(realIp.trim()); const forwarded = req.headers.get("forwarded"); if (forwarded) { for (const part of forwarded.split(",")) { const m = part.match(/(?:^|;)\s*for="?(\[?[^\]";,]+]?)"?/i); if (m) out.push(m[1].trim()); } } return out; } function sameOriginHeaderIsTrusted(req: NextRequest, header: "origin" | "referer"): boolean { const raw = req.headers.get(header); if (!raw) return true; try { return isTrustedHost(new URL(raw).hostname); } catch { return false; } } export function assertLocalRequest(req: NextRequest): NextResponse | null { const hostIsLocal = isTrustedHost(req.headers.get("host")) && isTrustedHost(req.nextUrl.hostname); const forwarded = forwardedFor(req); const forwardedIsLocal = forwarded.length === 0 || forwarded.every(isTrustedHost); const originIsLocal = sameOriginHeaderIsTrusted(req, "origin") && sameOriginHeaderIsTrusted(req, "referer"); if (hostIsLocal && forwardedIsLocal && originIsLocal) return null; return NextResponse.json( { error: "This endpoint is only available from the local machine." }, { status: 403 }, ); } export { isLocalHost };