Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 105 additions & 13 deletions app/api/proxy.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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) => {
Expand All @@ -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 = {
Expand Down