fix: Google provider save after model detection, SSRF protection, PR review fixes

- Add google-generative-ai to CustomProvider.apiType union type
- Update assertApiType to accept google-generative-ai in create/update
- Fix createCustomProvider mapping in legacy.ts for Google type
- Fix fetchCustomProviders mapping for Google type
- Add google-generative-ai to CustomProvidersSection API_TYPES
- Add SSRF protection to probeProviderModels (block private/loopback)
- Add body validation to probe-models route handler
- Update stale JSDoc in legacy.ts
This commit is contained in:
Islam Nofl
2026-05-14 16:56:11 +03:00
parent a487a20bbc
commit 55c35cf611
6 changed files with 61 additions and 13 deletions

View File

@@ -311,7 +311,7 @@ export interface NotificationProviderConfig {
export interface CustomProvider {
id: string;
name: string;
apiType: "openai-compatible" | "anthropic-compatible";
apiType: "openai-compatible" | "anthropic-compatible" | "google-generative-ai";
baseUrl: string;
apiKey?: string;
models?: { id: string; name: string }[];

View File

@@ -1770,7 +1770,7 @@ export function setLlamaCppEnabled(
export interface CustomProvider {
id: string;
name: string;
apiType: "openai-compatible" | "anthropic-compatible";
apiType: "openai-compatible" | "anthropic-compatible" | "google-generative-ai";
baseUrl: string;
apiKey?: string;
models?: { id: string; name: string }[];
@@ -1782,7 +1782,9 @@ export async function fetchCustomProviders(): Promise<CustomProviderConfig[] & {
id: provider.id,
name: provider.name,
baseUrl: provider.baseUrl,
api: provider.apiType === "anthropic-compatible" ? "anthropic-messages" : "openai-completions",
api: provider.apiType === "anthropic-compatible" ? "anthropic-messages"
: provider.apiType === "google-generative-ai" ? "google-generative-ai"
: "openai-completions",
apiKey: provider.apiKey,
models: (provider.models ?? []).map((model) => ({ id: model.id, name: model.name })),
} satisfies CustomProviderConfig));
@@ -1855,7 +1857,9 @@ export interface CustomProviderConfig {
}
export function createCustomProvider(config: CustomProviderConfig): Promise<CustomProvider> {
const apiType = config.api === "anthropic-messages" ? "anthropic-compatible" : "openai-compatible";
const apiType = config.api === "anthropic-messages" ? "anthropic-compatible"
: config.api === "google-generative-ai" ? "google-generative-ai"
: "openai-compatible";
return addCustomProvider({
name: config.name?.trim() || config.id,
apiType,
@@ -1870,8 +1874,7 @@ export function createCustomProvider(config: CustomProviderConfig): Promise<Cust
/**
* Probe a custom provider's /models endpoint to discover available models.
* Only works for OpenAI-compatible providers (the /models endpoint is an
* OpenAI convention). Returns the list of models found at the provider.
* Supports OpenAI-compatible, Anthropic-compatible, and Google Generative AI providers.
*/
export interface ProbeModelResult {
id: string;

View File

@@ -13,7 +13,7 @@ import "./CustomProvidersSection.css";
type ProviderApiType = CustomProvider["apiType"];
const API_TYPES: ProviderApiType[] = ["openai-compatible", "anthropic-compatible"];
const API_TYPES: ProviderApiType[] = ["openai-compatible", "anthropic-compatible", "google-generative-ai"];
type LegacyProvider = {
id: string;

View File

@@ -239,7 +239,7 @@ describe("custom providers API routes", () => {
});
expect(res.status).toBe(400);
expect(String(res.body.error)).toContain("apiType must be either");
expect(String(res.body.error)).toContain("apiType must be");
});
it("POST /api/custom-providers rejects invalid baseUrl format", async () => {

View File

@@ -1,4 +1,6 @@
import crypto from "node:crypto";
import dns from "node:dns/promises";
import net from "node:net";
import type { CustomProvider } from "@fusion/core";
import { ApiError, badRequest, notFound } from "../api-error.js";
import type { ApiRouteRegistrar } from "./types.js";
@@ -29,8 +31,8 @@ function assertNonEmptyString(value: unknown, fieldName: string): string {
}
function assertApiType(value: unknown): CustomProvider["apiType"] {
if (value !== "openai-compatible" && value !== "anthropic-compatible") {
throw badRequest("apiType must be either 'openai-compatible' or 'anthropic-compatible'");
if (value !== "openai-compatible" && value !== "anthropic-compatible" && value !== "google-generative-ai") {
throw badRequest("apiType must be 'openai-compatible', 'anthropic-compatible', or 'google-generative-ai'");
}
return value;
}
@@ -176,6 +178,42 @@ async function probeProviderModels(
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw badRequest("baseUrl must use http or https");
}
// SSRF protection: reject private/loopback/link-local hosts
const hostname = url.hostname.toLowerCase();
if (
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname === "::1" ||
hostname === "[::1]" ||
hostname.endsWith(".local") ||
hostname.endsWith(".internal")
) {
throw badRequest("baseUrl must not be a loopback or private address");
}
// Resolve hostname to IP and check against private ranges.
// If resolution fails, let the fetch attempt proceed naturally.
try {
const resolved = await dns.lookup(hostname, { all: true });
const addresses = resolved.map((a) => a.address);
for (const addr of addresses) {
if (net.isIP(addr) === 0) continue;
const parts = addr.split(".").map(Number);
if (parts.length === 4 && !Number.isNaN(parts[0])) {
// 127.0.0.0/8
if (parts[0] === 127) throw badRequest("baseUrl must not be a loopback or private address");
// 10.0.0.0/8
if (parts[0] === 10) throw badRequest("baseUrl must not be a loopback or private address");
// 172.16.0.0/12
if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) throw badRequest("baseUrl must not be a loopback or private address");
// 192.168.0.0/16
if (parts[0] === 192 && parts[1] === 168) throw badRequest("baseUrl must not be a loopback or private address");
// 169.254.0.0/16 (link-local, includes cloud metadata)
if (parts[0] === 169 && parts[1] === 254) throw badRequest("baseUrl must not be a loopback or private address");
}
}
} catch {
// DNS resolution failed — proceed without SSRF check; the fetch will fail naturally
}
let modelsUrl: string;
const headers: Record<string, string> = {
@@ -439,10 +477,13 @@ export const registerCustomProviderRoutes: ApiRouteRegistrar = (ctx) => {
}
});
// NOTE: probe-models must be registered AFTER the :id param routes
// so Express does not match "probe-models" as an :id value.
// NOTE: probe-models must be registered AFTER the :id param routes
// so Express does not match "probe-models" as an :id value.
router.post("/custom-providers/probe-models", async (req, res) => {
try {
if (!req.body || typeof req.body !== "object") {
throw badRequest("request body must be an object");
}
const body = req.body as Record<string, unknown>;
const baseUrl = assertBaseUrl(body.baseUrl);
@@ -451,7 +492,6 @@ export const registerCustomProviderRoutes: ApiRouteRegistrar = (ctx) => {
? body.apiKey.trim()
: undefined;
// Probe endpoint accepts all three API types
const rawApiType = body.apiType as string | undefined;
if (
rawApiType !== "openai-compatible" &&