From 088e64eb2c1d7aee3ae3c0325cbb47648c00b70f Mon Sep 17 00:00:00 2001 From: Solaris-star <820622658@qq.com> Date: Tue, 21 Jul 2026 11:30:21 +0800 Subject: [PATCH] fix(security): harden open proxy fallback against SSRF and key leak The fallback proxy accepted any x-base-url without auth and used a substring check for api.openai.com, allowing unauthenticated SSRF and server API key exfiltration via attacker hosts containing that string. Require auth like other providers, parse x-base-url as a real URL, block private/loopback/CGNAT/metadata hosts, and only inject the server OpenAI key when the hostname is exactly api.openai.com. Fixes #6813 Fixes #6814 Signed-off-by: Solaris-star <820622658@qq.com> --- app/api/proxy.ts | 118 +++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 105 insertions(+), 13 deletions(-) diff --git a/app/api/proxy.ts b/app/api/proxy.ts index b3e5e7b7b93..7c42341e573 100644 --- a/app/api/proxy.ts +++ b/app/api/proxy.ts @@ -1,5 +1,72 @@ import { NextRequest, NextResponse } from "next/server"; import { getServerSideConfig } from "@/app/config/server"; +import { ModelProvider } from "@/app/constant"; +import { auth } from "./auth"; + +function parseBaseUrl(raw: string | null): URL | null { + if (!raw) return null; + try { + const url = new URL(raw); + if (url.protocol !== "http:" && url.protocol !== "https:") { + return null; + } + return url; + } catch { + return null; + } +} + +/** Block obvious SSRF targets (loopback / RFC1918 / link-local / CGNAT / metadata-ish). */ +function isBlockedHostname(hostname: string): boolean { + const host = hostname.toLowerCase().replace(/\.$/, ""); + + if ( + host === "localhost" || + host.endsWith(".localhost") || + host === "0.0.0.0" || + host === "::1" || + host === "metadata.google.internal" || + host.endsWith(".internal") + ) { + return true; + } + + // IPv6 ULA / link-local + if ( + host.startsWith("fe80:") || + host.startsWith("fc") || + host.startsWith("fd") || + host.startsWith("[fe80:") || + host.startsWith("[fc") || + host.startsWith("[fd") + ) { + return true; + } + + const m = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (!m) return false; + + const a = Number(m[1]); + const b = Number(m[2]); + const c = Number(m[3]); + const d = Number(m[4]); + if ([a, b, c, d].some((n) => n > 255)) return true; + + if (a === 0 || a === 10 || a === 127) return true; + if (a === 169 && b === 254) return true; // link-local / cloud metadata + if (a === 172 && b >= 16 && b <= 31) return true; + if (a === 192 && b === 168) return true; + if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT 100.64/10 + if (a >= 224) return true; // multicast / reserved + + return false; +} + +/** Only inject the server OpenAI key for the real OpenAI API host (not substring matches). */ +function isOpenAIApiHost(hostname: string): boolean { + const host = hostname.toLowerCase().replace(/\.$/, ""); + return host === "api.openai.com"; +} export async function handle( req: NextRequest, @@ -10,16 +77,40 @@ export async function handle( if (req.method === "OPTIONS") { return NextResponse.json({ body: "OK" }, { status: 200 }); } + + // Match named provider handlers: do not leave the fallback proxy unauthenticated. + const authResult = auth(req, ModelProvider.GPT); + if (authResult.error) { + return NextResponse.json(authResult, { + status: 401, + }); + } + const serverConfig = getServerSideConfig(); // remove path params from searchParams req.nextUrl.searchParams.delete("path"); req.nextUrl.searchParams.delete("provider"); + const baseUrl = parseBaseUrl(req.headers.get("x-base-url")); + if (!baseUrl) { + return NextResponse.json( + { error: true, msg: "invalid or missing x-base-url" }, + { status: 400 }, + ); + } + + if (isBlockedHostname(baseUrl.hostname)) { + return NextResponse.json( + { error: true, msg: "x-base-url target is not allowed" }, + { status: 400 }, + ); + } + const subpath = params.path.join("/"); - const fetchUrl = `${req.headers.get( - "x-base-url", - )}/${subpath}?${req.nextUrl.searchParams.toString()}`; + const base = baseUrl.toString().replace(/\/$/, ""); + const fetchUrl = `${base}/${subpath}?${req.nextUrl.searchParams.toString()}`; + const skipHeaders = ["connection", "host", "origin", "referer", "cookie"]; const headers = new Headers( Array.from(req.headers.entries()).filter((item) => { @@ -33,17 +124,18 @@ export async function handle( return true; }), ); - // if dalle3 use openai api key - const baseUrl = req.headers.get("x-base-url"); - if (baseUrl?.includes("api.openai.com")) { - if (!serverConfig.apiKey) { - return NextResponse.json( - { error: "OpenAI API key not configured" }, - { status: 500 }, - ); - } - headers.set("Authorization", `Bearer ${serverConfig.apiKey}`); + + // Inject server OpenAI key only when the hostname is exactly api.openai.com. + // Substring checks (includes) allowed key exfiltration via attacker hosts. + if (isOpenAIApiHost(baseUrl.hostname)) { + if (!serverConfig.apiKey) { + return NextResponse.json( + { error: "OpenAI API key not configured" }, + { status: 500 }, + ); } + headers.set("Authorization", `Bearer ${serverConfig.apiKey}`); + } const controller = new AbortController(); const fetchOptions: RequestInit = {