52 lines
1.8 KiB
TypeScript
52 lines
1.8 KiB
TypeScript
/**
|
|
* SSRF guard + client/server agreement for /api/preview/embed.
|
|
* Only tunnel-like preview hosts should be proxied — never arbitrary URLs.
|
|
*/
|
|
|
|
export function isPrivateIpHostname(hostname: string): boolean {
|
|
const m = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(hostname);
|
|
if (!m) return false;
|
|
const a = Number(m[1]);
|
|
const b = Number(m[2]);
|
|
if (a === 10) return true;
|
|
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
if (a === 192 && b === 168) return true;
|
|
if (a === 127) return true;
|
|
if (a === 169 && b === 254) return true;
|
|
if (a === 0) return true;
|
|
return false;
|
|
}
|
|
|
|
/** Server-side hostname allowlist after redirects */
|
|
export function serverPreviewHostnameAllowed(hostname: string, protocol: string): boolean {
|
|
const h = hostname.toLowerCase();
|
|
const p = protocol.toLowerCase();
|
|
if (p !== "http:" && p !== "https:") return false;
|
|
if (isPrivateIpHostname(h)) return false;
|
|
if (h === "localhost" || h === "127.0.0.1") {
|
|
return process.env.NODE_ENV === "development";
|
|
}
|
|
if (h.endsWith(".preview.vibnai.com")) return true;
|
|
if (h === "preview.vibnai.com") return true;
|
|
const suffixes = (process.env.NEXT_PUBLIC_PREVIEW_EMBED_PROXY_HOST_SUFFIXES ?? "")
|
|
.split(",")
|
|
.map((s) => s.trim().toLowerCase())
|
|
.filter(Boolean);
|
|
return suffixes.some((suf) => (suf.startsWith(".") ? h.endsWith(suf) : h === suf));
|
|
}
|
|
|
|
/**
|
|
* Remote URLs we should load through /api/preview/embed so the bridge script is same-origin.
|
|
* Same-origin targets return false (already works without proxy).
|
|
*/
|
|
export function previewUrlEligibleForEmbedProxy(rawUrl: string, appOrigin: string): boolean {
|
|
try {
|
|
const u = new URL(rawUrl);
|
|
const app = new URL(appOrigin);
|
|
if (u.origin === app.origin) return false;
|
|
return serverPreviewHostnameAllowed(u.hostname, u.protocol);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|