Initial commit
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
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 };
|
||||
Binary file not shown.
Reference in New Issue
Block a user