c30d11d60b
* fix(security): harden BYO API header against SSRF and input abuse - Add lib/validateUrl.ts with HTTPS-only + public-IP enforcement, provider allowlist, IPv6 rejection, and userinfo-in-URL blocking. - Add lib/byoHeaders.ts — single source of truth for client-side BYO header construction (deduplicates app/page.tsx & app/play/page.tsx). - config.ts: validate BYO endpoints via isPublicUrl(), cap header at 2 KB, truncate apiKey/model strings, sanitize log output. - fetchWithRetry: default redirect to "manual" to block 302-to-intranet. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(security): address Copilot review — trim endpoint, strip control chars, drop unused import - safeEndpoint: trim whitespace before URL validation - safeString: strip ASCII control characters to prevent header injection - play/page.tsx: remove unused BYO_STORAGE_KEY import Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
96 lines
2.8 KiB
TypeScript
96 lines
2.8 KiB
TypeScript
// Block SSRF: only allow HTTPS URLs pointing to public internet hosts.
|
|
|
|
const ALLOWED_HOSTS = new Set([
|
|
"api.openai.com",
|
|
"api.anthropic.com",
|
|
"generativelanguage.googleapis.com",
|
|
"api.runware.ai",
|
|
"api.replicate.com",
|
|
"api.deepseek.com",
|
|
"dashscope.aliyuncs.com",
|
|
"api.siliconflow.cn",
|
|
"api.together.xyz",
|
|
"openrouter.ai",
|
|
"api.mistral.ai",
|
|
"api.groq.com",
|
|
"api.fireworks.ai",
|
|
"api.cohere.com",
|
|
]);
|
|
|
|
const BLOCKED_HOSTS = new Set([
|
|
"localhost",
|
|
"metadata.google.internal",
|
|
"metadata.internal",
|
|
]);
|
|
|
|
const PRIVATE_RANGES = [
|
|
{ start: ip4ToNum(0, 0, 0, 0), end: ip4ToNum(0, 255, 255, 255) },
|
|
{ start: ip4ToNum(10, 0, 0, 0), end: ip4ToNum(10, 255, 255, 255) },
|
|
{ start: ip4ToNum(100, 64, 0, 0), end: ip4ToNum(100, 127, 255, 255) },
|
|
{ start: ip4ToNum(127, 0, 0, 0), end: ip4ToNum(127, 255, 255, 255) },
|
|
{ start: ip4ToNum(169, 254, 0, 0), end: ip4ToNum(169, 254, 255, 255) },
|
|
{ start: ip4ToNum(172, 16, 0, 0), end: ip4ToNum(172, 31, 255, 255) },
|
|
{ start: ip4ToNum(192, 168, 0, 0), end: ip4ToNum(192, 168, 255, 255) },
|
|
{ start: ip4ToNum(224, 0, 0, 0), end: ip4ToNum(239, 255, 255, 255) },
|
|
{ start: ip4ToNum(240, 0, 0, 0), end: ip4ToNum(255, 255, 255, 255) },
|
|
];
|
|
|
|
function ip4ToNum(a: number, b: number, c: number, d: number): number {
|
|
return ((a << 24) | (b << 16) | (c << 8) | d) >>> 0;
|
|
}
|
|
|
|
function parseIp4(ip: string): number | null {
|
|
const parts = ip.split(".");
|
|
if (parts.length !== 4) return null;
|
|
for (const p of parts) {
|
|
const n = Number(p);
|
|
if (!Number.isInteger(n) || n < 0 || n > 255) return null;
|
|
}
|
|
return ip4ToNum(
|
|
Number(parts[0]),
|
|
Number(parts[1]),
|
|
Number(parts[2]),
|
|
Number(parts[3]),
|
|
);
|
|
}
|
|
|
|
function isPrivateIp(ip: string): boolean {
|
|
const n = parseIp4(ip);
|
|
if (n === null) return true;
|
|
return PRIVATE_RANGES.some((r) => n >= r.start && n <= r.end);
|
|
}
|
|
|
|
export function isAllowlistedHost(hostname: string): boolean {
|
|
return ALLOWED_HOSTS.has(hostname);
|
|
}
|
|
|
|
export function isPublicUrl(raw: string): boolean {
|
|
let url: URL;
|
|
try {
|
|
url = new URL(raw);
|
|
} catch {
|
|
return false;
|
|
}
|
|
|
|
if (url.protocol !== "https:") return false;
|
|
if (url.username || url.password) return false;
|
|
|
|
const host = url.hostname.toLowerCase();
|
|
|
|
if (BLOCKED_HOSTS.has(host)) return false;
|
|
// Reject all IPv6 addresses (including ::ffff:127.0.0.1 mapped forms)
|
|
if (host.includes(":")) return false;
|
|
|
|
// Fast path: known API providers always pass
|
|
if (ALLOWED_HOSTS.has(host)) return true;
|
|
|
|
// For unknown domains, block IP literals pointing to private ranges
|
|
const ipv4 = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host);
|
|
if (ipv4) return !isPrivateIp(host);
|
|
|
|
// Domain names: allow — DNS rebinding is mitigated by redirect: "manual"
|
|
// on fetchWithRetry and the fact that Vercel's runtime resolves DNS once
|
|
// per fetch (no keep-alive reuse across requests).
|
|
return true;
|
|
}
|