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:
5
.changeset/fix-google-provider-create.md
Normal file
5
.changeset/fix-google-provider-create.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix Google Generative AI custom provider not saving after model detection. The probe endpoint accepted `google-generative-ai` but create/update routes rejected it. Also adds SSRF protection, body validation, and fixes stale type mappings.
|
||||||
@@ -311,7 +311,7 @@ export interface NotificationProviderConfig {
|
|||||||
export interface CustomProvider {
|
export interface CustomProvider {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
apiType: "openai-compatible" | "anthropic-compatible";
|
apiType: "openai-compatible" | "anthropic-compatible" | "google-generative-ai";
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
models?: { id: string; name: string }[];
|
models?: { id: string; name: string }[];
|
||||||
|
|||||||
@@ -1770,7 +1770,7 @@ export function setLlamaCppEnabled(
|
|||||||
export interface CustomProvider {
|
export interface CustomProvider {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
apiType: "openai-compatible" | "anthropic-compatible";
|
apiType: "openai-compatible" | "anthropic-compatible" | "google-generative-ai";
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
models?: { id: string; name: string }[];
|
models?: { id: string; name: string }[];
|
||||||
@@ -1782,7 +1782,9 @@ export async function fetchCustomProviders(): Promise<CustomProviderConfig[] & {
|
|||||||
id: provider.id,
|
id: provider.id,
|
||||||
name: provider.name,
|
name: provider.name,
|
||||||
baseUrl: provider.baseUrl,
|
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,
|
apiKey: provider.apiKey,
|
||||||
models: (provider.models ?? []).map((model) => ({ id: model.id, name: model.name })),
|
models: (provider.models ?? []).map((model) => ({ id: model.id, name: model.name })),
|
||||||
} satisfies CustomProviderConfig));
|
} satisfies CustomProviderConfig));
|
||||||
@@ -1855,7 +1857,9 @@ export interface CustomProviderConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createCustomProvider(config: CustomProviderConfig): Promise<CustomProvider> {
|
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({
|
return addCustomProvider({
|
||||||
name: config.name?.trim() || config.id,
|
name: config.name?.trim() || config.id,
|
||||||
apiType,
|
apiType,
|
||||||
@@ -1870,8 +1874,7 @@ export function createCustomProvider(config: CustomProviderConfig): Promise<Cust
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Probe a custom provider's /models endpoint to discover available models.
|
* Probe a custom provider's /models endpoint to discover available models.
|
||||||
* Only works for OpenAI-compatible providers (the /models endpoint is an
|
* Supports OpenAI-compatible, Anthropic-compatible, and Google Generative AI providers.
|
||||||
* OpenAI convention). Returns the list of models found at the provider.
|
|
||||||
*/
|
*/
|
||||||
export interface ProbeModelResult {
|
export interface ProbeModelResult {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import "./CustomProvidersSection.css";
|
|||||||
|
|
||||||
type ProviderApiType = CustomProvider["apiType"];
|
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 = {
|
type LegacyProvider = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ describe("custom providers API routes", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(res.status).toBe(400);
|
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 () => {
|
it("POST /api/custom-providers rejects invalid baseUrl format", async () => {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
|
import dns from "node:dns/promises";
|
||||||
|
import net from "node:net";
|
||||||
import type { CustomProvider } from "@fusion/core";
|
import type { CustomProvider } from "@fusion/core";
|
||||||
import { ApiError, badRequest, notFound } from "../api-error.js";
|
import { ApiError, badRequest, notFound } from "../api-error.js";
|
||||||
import type { ApiRouteRegistrar } from "./types.js";
|
import type { ApiRouteRegistrar } from "./types.js";
|
||||||
@@ -29,8 +31,8 @@ function assertNonEmptyString(value: unknown, fieldName: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function assertApiType(value: unknown): CustomProvider["apiType"] {
|
function assertApiType(value: unknown): CustomProvider["apiType"] {
|
||||||
if (value !== "openai-compatible" && value !== "anthropic-compatible") {
|
if (value !== "openai-compatible" && value !== "anthropic-compatible" && value !== "google-generative-ai") {
|
||||||
throw badRequest("apiType must be either 'openai-compatible' or 'anthropic-compatible'");
|
throw badRequest("apiType must be 'openai-compatible', 'anthropic-compatible', or 'google-generative-ai'");
|
||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
@@ -176,6 +178,42 @@ async function probeProviderModels(
|
|||||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||||
throw badRequest("baseUrl must use http or 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;
|
let modelsUrl: string;
|
||||||
const headers: Record<string, 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
|
// NOTE: probe-models must be registered AFTER the :id param routes
|
||||||
// so Express does not match "probe-models" as an :id value.
|
// so Express does not match "probe-models" as an :id value.
|
||||||
router.post("/custom-providers/probe-models", async (req, res) => {
|
router.post("/custom-providers/probe-models", async (req, res) => {
|
||||||
try {
|
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 body = req.body as Record<string, unknown>;
|
||||||
|
|
||||||
const baseUrl = assertBaseUrl(body.baseUrl);
|
const baseUrl = assertBaseUrl(body.baseUrl);
|
||||||
@@ -451,7 +492,6 @@ export const registerCustomProviderRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
? body.apiKey.trim()
|
? body.apiKey.trim()
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// Probe endpoint accepts all three API types
|
|
||||||
const rawApiType = body.apiType as string | undefined;
|
const rawApiType = body.apiType as string | undefined;
|
||||||
if (
|
if (
|
||||||
rawApiType !== "openai-compatible" &&
|
rawApiType !== "openai-compatible" &&
|
||||||
|
|||||||
Reference in New Issue
Block a user