fix(security): harden BYO API header against SSRF and input abuse (#33)
* 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>
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
// 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;
|
||||
}
|
||||
Reference in New Issue
Block a user