127 lines
3.8 KiB
TypeScript
127 lines
3.8 KiB
TypeScript
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<string> {
|
|
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 };
|