From c30d11d60b8d44096ccf2b94a835d13e99ba24eb Mon Sep 17 00:00:00 2001 From: Zonghao Yuan <64521992+zonghaoyuan@users.noreply.github.com> Date: Fri, 5 Jun 2026 00:23:35 +0800 Subject: [PATCH] fix(security): harden BYO API header against SSRF and input abuse (#33) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 --------- Co-authored-by: Claude Opus 4.6 --- app/page.tsx | 19 +------ app/play/page.tsx | 33 ++---------- lib/ai-client/fetchWithRetry.ts | 1 + lib/byoHeaders.ts | 23 ++++++++ lib/config.ts | 57 +++++++++++++------- lib/validateUrl.ts | 95 +++++++++++++++++++++++++++++++++ 6 files changed, 163 insertions(+), 65 deletions(-) create mode 100644 lib/byoHeaders.ts create mode 100644 lib/validateUrl.ts diff --git a/app/page.tsx b/app/page.tsx index 8395c2a..db64177 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -11,6 +11,7 @@ import { type Gender, } from "@/lib/options"; import { readStoredTtsConfig } from "@/lib/clientTtsConfig"; +import { BYO_STORAGE_KEY, getByoHeaders } from "@/lib/byoHeaders"; import { TtsKeyModal } from "@/components/TtsKeyModal"; /* ============================================================================ @@ -749,8 +750,6 @@ const DEFAULT_BYO: ByoApiConfig = { }, }; -const BYO_STORAGE_KEY = "infiplot:byoApi"; - function normalizeByoSection( s: unknown, providers: Record, @@ -788,22 +787,6 @@ function loadByoConfig(): ByoApiConfig { } } -function getByoHeaders(): Record { - if (typeof window === "undefined") return {}; - try { - const raw = localStorage.getItem(BYO_STORAGE_KEY); - if (raw) { - const parsed = JSON.parse(raw); - if (parsed.llm?.enabled || parsed.painter?.enabled) { - return { "x-byo-api": raw }; - } - } - } catch { - /* ignore */ - } - return {}; -} - /* ---------- typewriter ---------- */ // 父组件持有当前 phrase 的索引(这样 start() 不输入时能用当前闪动的那句 diff --git a/app/play/page.tsx b/app/play/page.tsx index 3bc9578..322fb31 100644 --- a/app/play/page.tsx +++ b/app/play/page.tsx @@ -35,25 +35,9 @@ import type { VisionResponse, } from "@infiplot/types"; import { track } from "@/lib/analytics"; +import { getByoHeaders, isByoActive } from "@/lib/byoHeaders"; const MUTED_STORAGE_KEY = "infiplot:muted"; -const BYO_STORAGE_KEY = "infiplot:byoApi"; - -function getByoHeaders(): Record { - if (typeof window === "undefined") return {}; - try { - const raw = localStorage.getItem(BYO_STORAGE_KEY); - if (raw) { - const parsed = JSON.parse(raw); - if (parsed.llm?.enabled || parsed.painter?.enabled) { - return { "x-byo-api": raw }; - } - } - } catch { - /* ignore */ - } - return {}; -} // Consecutive silent (no-audio) beats before we surface the BYO-key nudge to a // non-BYO, unmuted player. Set high enough that one transient miss won't trip @@ -1298,16 +1282,7 @@ function PlayInner() { // ── Render ──────────────────────────────────────────────────────────── if (error) { - const isByoActive = typeof window !== "undefined" && (() => { - try { - const raw = localStorage.getItem(BYO_STORAGE_KEY); - if (raw) { - const parsed = JSON.parse(raw); - return parsed.llm?.enabled || parsed.painter?.enabled; - } - } catch {} - return false; - })(); + const byoOn = isByoActive(); return (
@@ -1318,14 +1293,14 @@ function PlayInner() {

{error}

- {isByoActive && ( + {byoOn && (

提示:当前已启用「自带 API」。如果请求失败,请返回首页并检查右上角 API 配置的 Key、Endpoint 和 Model 是否正确,并确认您的服务额度充足。

)} 返 回 diff --git a/lib/ai-client/fetchWithRetry.ts b/lib/ai-client/fetchWithRetry.ts index 3f6531f..8cc647d 100644 --- a/lib/ai-client/fetchWithRetry.ts +++ b/lib/ai-client/fetchWithRetry.ts @@ -5,6 +5,7 @@ export async function fetchWithRetry( init: RetryInit, ): Promise { const { retries = 2, retryDelayMs = 1500, ...fetchInit } = init; + if (!fetchInit.redirect) fetchInit.redirect = "manual"; let lastError: unknown; for (let attempt = 0; attempt <= retries; attempt++) { diff --git a/lib/byoHeaders.ts b/lib/byoHeaders.ts new file mode 100644 index 0000000..1e110f5 --- /dev/null +++ b/lib/byoHeaders.ts @@ -0,0 +1,23 @@ +export const BYO_STORAGE_KEY = "infiplot:byoApi"; + +const MAX_HEADER_SIZE = 2048; + +export function getByoHeaders(): Record { + if (typeof window === "undefined") return {}; + try { + const raw = localStorage.getItem(BYO_STORAGE_KEY); + if (raw && raw.length <= MAX_HEADER_SIZE) { + const parsed = JSON.parse(raw); + if (parsed.llm?.enabled || parsed.painter?.enabled) { + return { "x-byo-api": raw }; + } + } + } catch { + /* ignore */ + } + return {}; +} + +export function isByoActive(): boolean { + return Object.keys(getByoHeaders()).length > 0; +} diff --git a/lib/config.ts b/lib/config.ts index 10def17..dde7482 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -3,6 +3,7 @@ import type { ProviderProtocol, TtsConfig, } from "@infiplot/types"; +import { isPublicUrl } from "./validateUrl"; const VALID_PROTOCOLS = [ "openai_compatible", @@ -49,6 +50,21 @@ function loadTtsConfig(): TtsConfig | undefined { return { baseUrl, apiKey, speechModel }; } +function safeEndpoint(v: unknown): string | undefined { + if (typeof v !== "string" || v.length === 0) return undefined; + const trimmed = v.trim(); + if (!trimmed || !isPublicUrl(trimmed)) { + console.error(`BYO endpoint rejected (not a public HTTPS URL): ${v.slice(0, 100).replace(/[\r\n]/g, "")}`); + return undefined; + } + return trimmed; +} + +function safeString(v: unknown, max: number): string | undefined { + if (typeof v !== "string" || v.length === 0) return undefined; + return v.slice(0, max).replace(/[\x00-\x1f]/g, ""); +} + export function loadEngineConfig(headers?: Headers): EngineConfig { const config: EngineConfig = { text: { @@ -75,25 +91,30 @@ export function loadEngineConfig(headers?: Headers): EngineConfig { const byoHeader = headers?.get("x-byo-api"); if (byoHeader) { - try { - const byo = JSON.parse(byoHeader); - if (byo.llm?.enabled) { - if (byo.llm.endpoint) config.text.baseUrl = byo.llm.endpoint; - if (byo.llm.apiKey) config.text.apiKey = byo.llm.apiKey; - if (byo.llm.model) config.text.model = byo.llm.model; - - // Also override vision if llm is enabled - if (byo.llm.endpoint) config.vision.baseUrl = byo.llm.endpoint; - if (byo.llm.apiKey) config.vision.apiKey = byo.llm.apiKey; - if (byo.llm.model) config.vision.model = byo.llm.model; + if (byoHeader.length > 2048) { + console.error("x-byo-api header exceeds 2 KB limit, ignoring"); + } else { + try { + const byo = JSON.parse(byoHeader); + if (byo.llm?.enabled) { + const ep = safeEndpoint(byo.llm?.endpoint); + const key = safeString(byo.llm?.apiKey, 256); + const model = safeString(byo.llm?.model, 128); + if (ep) { config.text.baseUrl = ep; config.vision.baseUrl = ep; } + if (key) { config.text.apiKey = key; config.vision.apiKey = key; } + if (model) { config.text.model = model; config.vision.model = model; } + } + if (byo.painter?.enabled) { + const ep = safeEndpoint(byo.painter?.endpoint); + const key = safeString(byo.painter?.apiKey, 256); + const model = safeString(byo.painter?.model, 128); + if (ep) config.image.baseUrl = ep; + if (key) config.image.apiKey = key; + if (model) config.image.model = model; + } + } catch (e) { + console.error("Failed to parse x-byo-api header:", e); } - if (byo.painter?.enabled) { - if (byo.painter.endpoint) config.image.baseUrl = byo.painter.endpoint; - if (byo.painter.apiKey) config.image.apiKey = byo.painter.apiKey; - if (byo.painter.model) config.image.model = byo.painter.model; - } - } catch (e) { - console.error("Failed to parse x-byo-api header in loadEngineConfig:", e); } } diff --git a/lib/validateUrl.ts b/lib/validateUrl.ts new file mode 100644 index 0000000..a4a3504 --- /dev/null +++ b/lib/validateUrl.ts @@ -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; +}