Fix private AI custom endpoints (#1299)

This commit is contained in:
ZacharyZcR
2026-08-23 22:38:03 +08:00
committed by GitHub
parent fbf267fe5f
commit b5d13c3664
5 changed files with 50 additions and 10 deletions
+10 -5
View File
@@ -51,8 +51,7 @@ function normalizeHost(hostname: string): string {
/** /**
* True when the URL names a destination the SSRF guard would refuse. A bare * True when the URL names a destination the SSRF guard would refuse. A bare
* hostname that is not an IP literal (e.g. "ollama.internal") is treated as * hostname that is not an IP literal (e.g. "ollama.internal") is treated as
* private only if it is "localhost" -- anything else resolves through DNS and * private only if it is "localhost" -- anything else needs DNS resolution.
* is caught at connect time by the guard instead.
*/ */
export function isPrivateDestination(rawUrl: string): boolean { export function isPrivateDestination(rawUrl: string): boolean {
let url: URL; let url: URL;
@@ -98,12 +97,18 @@ export function evaluateEgress(
const host = normalizeHost(url.hostname); const host = normalizeHost(url.hostname);
const isPrivate = isPrivateDestination(rawUrl); const isPrivate = isPrivateDestination(rawUrl);
const normalized = allowlist.map((entry) => entry.trim().toLowerCase());
// An explicitly allowlisted hostname may resolve to a private address. It
// must use the private fetch path; sending it through safeOutboundFetch
// would reject it after DNS resolution and make hostname allowlist entries
// ineffective. Only administrators can write this list.
if (normalized.includes(host)) {
return { allowed: true, isPrivate: true };
}
if (!isPrivate) return { allowed: true, isPrivate: false }; if (!isPrivate) return { allowed: true, isPrivate: false };
const normalized = allowlist.map((entry) => entry.trim().toLowerCase());
if (normalized.includes(host)) return { allowed: true, isPrivate: true };
return { return {
allowed: false, allowed: false,
isPrivate: true, isPrivate: true,
+10
View File
@@ -61,6 +61,16 @@ describe("evaluateEgress", () => {
expect(decision.isPrivate).toBe(true); expect(decision.isPrivate).toBe(true);
}); });
it.each(["llm.internal", "host.docker.internal"])(
"routes allowlisted private DNS name %s through the private path",
(host) => {
expect(evaluateEgress(`http://${host}:8000/v1`, [host])).toEqual({
allowed: true,
isPrivate: true,
});
},
);
it("matches the allowlist case-insensitively", () => { it("matches the allowlist case-insensitively", () => {
expect( expect(
evaluateEgress("http://LOCALHOST:11434", ["localhost"]).allowed, evaluateEgress("http://LOCALHOST:11434", ["localhost"]).allowed,
+5 -1
View File
@@ -114,7 +114,11 @@ export async function probeAiModels(input: {
baseUrl?: string | null; baseUrl?: string | null;
apiKey?: string | null; apiKey?: string | null;
providerId?: number | null; providerId?: number | null;
}): Promise<{ models: string[]; source: "live" | "fallback" | "none" }> { }): Promise<{
models: string[];
source: "live" | "fallback" | "none";
warning?: string;
}> {
try { try {
return (await authApi.post("/ai/probe-models", input)).data; return (await authApi.post("/ai/probe-models", input)).data;
} catch (error) { } catch (error) {
+12 -4
View File
@@ -85,21 +85,24 @@ function AiProviderEditForm({
const [models, setModels] = useState<string[]>([]); const [models, setModels] = useState<string[]>([]);
const [customModel, setCustomModel] = useState(false); const [customModel, setCustomModel] = useState(false);
const [detecting, setDetecting] = useState(false); const [detecting, setDetecting] = useState(false);
const [detectWarning, setDetectWarning] = useState<string | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const detectModels = useCallback(async () => { const detectModels = useCallback(async () => {
setDetecting(true); setDetecting(true);
setDetectWarning(null);
try { try {
const detected = await getAiProviderModels(provider.id); const detected = await getAiProviderModels(provider.id);
setModels(detected); setModels(detected);
setCustomModel(!!defaultModel && !detected.includes(defaultModel)); setCustomModel(!!defaultModel && !detected.includes(defaultModel));
} catch { } catch (error) {
setModels([]); setModels([]);
setCustomModel(true); setCustomModel(true);
setDetectWarning(getErrorMessage(error, t("ai.modelDetectFailed")));
} finally { } finally {
setDetecting(false); setDetecting(false);
} }
}, [provider.id, defaultModel]); }, [provider.id, defaultModel, t]);
useEffect(() => { useEffect(() => {
void detectModels(); void detectModels();
@@ -203,6 +206,11 @@ function AiProviderEditForm({
placeholder={t("ai.defaultModelPlaceholder")} placeholder={t("ai.defaultModelPlaceholder")}
/> />
)} )}
{detectWarning && (
<p className="text-[11px] leading-snug text-destructive">
{detectWarning}
</p>
)}
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -269,8 +277,8 @@ export function AiProviderSettings({
apiKey: apiKey.trim() || null, apiKey: apiKey.trim() || null,
}); });
setModels(result.models); setModels(result.models);
if (result.source === "fallback") { if (result.source !== "live") {
setDetectWarning(t("ai.modelDetectFailed")); setDetectWarning(result.warning || t("ai.modelDetectFailed"));
} }
// Pick the first suggestion so the field is never left empty. // Pick the first suggestion so the field is never left empty.
setDefaultModel((current) => current || result.models[0] || ""); setDefaultModel((current) => current || result.models[0] || "");
@@ -82,4 +82,17 @@ describe("AiProviderSettings", () => {
screen.getByRole("combobox", { name: "ai.defaultModel" }), screen.getByRole("combobox", { name: "ai.defaultModel" }),
).toBeTruthy(); ).toBeTruthy();
}); });
it("shows the provider error when refreshing models fails", async () => {
api.getAiProviderModels.mockRejectedValue(
new Error("Add llm.internal to the AI endpoint allowlist"),
);
render(<AiProviderSettings providers={[provider]} onChanged={() => {}} />);
fireEvent.click(screen.getByRole("button", { name: "ai.editProvider" }));
expect(
await screen.findByText("Add llm.internal to the AI endpoint allowlist"),
).toBeTruthy();
});
}); });