fix: what the batch C review found

- A redirect followed for a custom base URL (quota on) no longer carries
  the user's key or cookies to another origin, as fetch itself does, and
  a private address may redirect to another private one (already counted).
- The admin Test of an Ollama or Vertex AI entry without a URL goes where
  chat sends that entry's key: the environment's own URL variable, for
  Ollama else the local default. The Test of an Ollama Cloud key without
  a URL went to the cloud while chat went to local Ollama.
- Chat saves: each save notes the chat on screen and the order of the
  reads before reading its data. A save read before switching chats no
  longer writes into the chat switched to, and a copy that waited for its
  thumbnail no longer replaces a newer one.
- "Continue without saving" keeps its button when a later auto-save fails,
  and goes away when a new message is sent.
- A screenshot check that was waiting for its image when the user pressed
  Stop stays skipped after the next message.
- MCP History: draw.io's own copy of a diagram (after get_diagram) no
  longer adds an entry without a picture; a change of background is still
  its own version. The tab ignores an edit's answer that arrives after a
  newer AI write loaded.
- Desktop: a deleted preset is not brought back by a failed switch, and a
  request naming no preset does not stop a rollback. An origin keeping
  an access code counts as having settings.
- .env: a quoted value ending in a backslash ("C:\dir\") is read as dotenv
  reads it.
- The tool card no longer crashes on an id that does not turn into text;
  an older Test's success timer no longer ends a newer Test's spinner.
- Tests that passed without their fix now check it.
This commit is contained in:
dayuan.jiang
2026-10-05 20:09:42 +09:00
parent c0fa997186
commit 69c7896002
23 changed files with 461 additions and 63 deletions
+15 -5
View File
@@ -21,6 +21,7 @@ import {
adminProvidersToConfig,
loadAdminProviders,
} from "@/lib/admin/providers"
import { getEnvFallback } from "@/lib/admin/settings"
import { getApiEndpoint } from "@/lib/base-path"
import { redirectGuardedFetch } from "@/lib/ssrf-protection"
import {
@@ -803,8 +804,11 @@ export function getAIModel(clientOverrides?: ClientOverrides): ModelConfig {
// Exception: EdgeOne doesn't require API keys.
// Ollama is exempt only when no server OLLAMA_API_KEY is configured;
// when it IS configured, the outer guard also enforces client apiKey for custom baseUrls.
// A trusted URL is the server's own (the admin Test of an entry without
// one), not a user's
if (
overrides?.baseUrl &&
!overrides?.trustedBaseUrl &&
!overrides?.apiKey &&
!(overrides?.provider === "vertexai" && overrides?.vertexApiKey) &&
overrides?.provider !== "edgeone" &&
@@ -1118,14 +1122,20 @@ export function edgeOneEndpoint(req: Request): string {
/**
* The server's <P>_BASE_URL for a provider, which getAIModel uses for a
* server model without a URL variable of its own (an admin panel entry
* without a URL). None for Bedrock and EdgeOne, and none for Ollama and
* Vertex AI, whose variables the panel writes itself (before a save they
* still hold the entry's previous URL).
* without a URL). None for Bedrock and EdgeOne. Ollama and Vertex AI share
* one variable with the panel, which writes an entry's URL into it: an
* entry without a URL gets the environment's value once saved (before a
* save the variable may still hold the entry's previous URL), and Ollama
* without one goes to the SDK's local default.
*/
export function globalBaseUrl(provider: ProviderName): string | undefined {
if (["bedrock", "edgeone", "ollama", "vertexai"].includes(provider)) {
return undefined
if (provider === "ollama") {
return getEnvFallback("OLLAMA_BASE_URL") || "http://127.0.0.1:11434/api"
}
if (provider === "vertexai") {
return getEnvFallback("GOOGLE_VERTEX_BASE_URL") || undefined
}
if (provider === "bedrock" || provider === "edgeone") return undefined
const name =
provider === "gateway"
? "AI_GATEWAY_BASE_URL"
+28 -6
View File
@@ -131,8 +131,8 @@ const MAX_REDIRECTS = 5
* blocked, a public URL could still redirect the request to an internal
* host, so redirects are refused. With private URLs allowed but the quota
* on (DYNAMODB_QUOTA_TABLE), a request to a private address counts as the
* server's: redirects are followed only to public addresses, or a public
* URL could reach the server's own network uncounted. Undefined otherwise.
* server's: a public URL's redirects are followed only to public addresses,
* or it could reach the server's own network uncounted. Undefined otherwise.
*/
export function redirectGuardedFetch(): typeof fetch | undefined {
const blockAll = !allowPrivateUrls()
@@ -140,6 +140,8 @@ export function redirectGuardedFetch(): typeof fetch | undefined {
return async (input, init) => {
let url = input instanceof Request ? input.url : String(input)
let next = init
// A request to a private address already counts as the server's
let startsPrivate: boolean | undefined
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
const response = await fetch(url, { ...next, redirect: "manual" })
const location = response.headers.get("location")
@@ -147,20 +149,40 @@ export function redirectGuardedFetch(): typeof fetch | undefined {
return response
}
if (blockAll) throw new RedirectRefusedError()
startsPrivate ??= await isPrivateUrl(url)
const from = new URL(url)
url = new URL(location, url).toString()
if (await isPrivateUrl(url)) {
if (!startsPrivate && (await isPrivateUrl(url))) {
throw new RedirectRefusedError(
"Redirects to private addresses are not allowed",
)
}
// As fetch itself does: 303, and 301 or 302 after a POST, go on
// as a GET without the body
const method = (next?.method ?? "GET").toUpperCase()
// The rest as fetch itself does it. Another origin gets no
// credentials (the user's key, EdgeOne's cookies)
const headers = new Headers(next?.headers)
if (new URL(url).origin !== from.origin) {
headers.delete("authorization")
headers.delete("proxy-authorization")
headers.delete("cookie")
}
next = { ...next, headers }
// 303, and 301 or 302 after a POST, go on as a GET without the
// body
const method = (next.method ?? "GET").toUpperCase()
if (
response.status === 303 ||
((response.status === 301 || response.status === 302) &&
method === "POST")
) {
for (const name of [
"content-type",
"content-length",
"content-encoding",
"content-language",
"content-location",
]) {
headers.delete(name)
}
next = { ...next, method: "GET", body: undefined }
}
}