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:
+1
-18
@@ -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<string, ByoProvider>,
|
||||
@@ -788,22 +787,6 @@ function loadByoConfig(): ByoApiConfig {
|
||||
}
|
||||
}
|
||||
|
||||
function getByoHeaders(): Record<string, string> {
|
||||
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() 不输入时能用当前闪动的那句
|
||||
|
||||
+4
-29
@@ -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<string, string> {
|
||||
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 (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center px-8">
|
||||
@@ -1318,14 +1293,14 @@ function PlayInner() {
|
||||
<p className="font-serif italic text-clay-900 text-lg leading-[1.7] mb-6">
|
||||
{error}
|
||||
</p>
|
||||
{isByoActive && (
|
||||
{byoOn && (
|
||||
<p className="font-sans text-xs text-ember-600 mb-10 leading-relaxed">
|
||||
提示:当前已启用「自带 API」。如果请求失败,请返回首页并检查右上角 API 配置的 Key、Endpoint 和 Model 是否正确,并确认您的服务额度充足。
|
||||
</p>
|
||||
)}
|
||||
<Link
|
||||
href="/"
|
||||
className={"text-[10px] smallcaps text-clay-700 hover:text-ember-500 transition-colors inline-flex items-center gap-3" + (isByoActive ? "" : " mt-4")}
|
||||
className={"text-[10px] smallcaps text-clay-700 hover:text-ember-500 transition-colors inline-flex items-center gap-3" + (byoOn ? "" : " mt-4")}
|
||||
>
|
||||
<i className="fa-solid fa-arrow-left text-[9px]" />
|
||||
返 回
|
||||
|
||||
@@ -5,6 +5,7 @@ export async function fetchWithRetry(
|
||||
init: RetryInit,
|
||||
): Promise<Response> {
|
||||
const { retries = 2, retryDelayMs = 1500, ...fetchInit } = init;
|
||||
if (!fetchInit.redirect) fetchInit.redirect = "manual";
|
||||
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
export const BYO_STORAGE_KEY = "infiplot:byoApi";
|
||||
|
||||
const MAX_HEADER_SIZE = 2048;
|
||||
|
||||
export function getByoHeaders(): Record<string, string> {
|
||||
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;
|
||||
}
|
||||
+39
-18
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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