fix(security): check request sources, regions and endpoints

- Bedrock: a request's AWS region must be a region name. It becomes part
  of the endpoint's host name, so a value such as
  "us-east-1.attacker.example/" sent the server's bearer token or signed
  request to another host.
- MCP preview server: only the preview page itself (Origin equal to the
  Host) or a non-browser client may call it; a page on another localhost
  port could replace the diagram with a plain text POST. History builds
  its thumbnails element by element and shows only SVG data images, so a
  stored value can no longer run script in the preview.
- chat, validate-model, validate-diagram, provider-models and parse-url
  take JSON bodies only, so another website cannot make the user's own
  server (the desktop app, a local install) run models with their keys;
  the desktop app also refuses a foreign Host (DNS rebinding).
- The model list reads at most 2 MB, also through the Gateway SDK, and
  answers only with its own error texts: the URL is the caller's and may
  be an internal address.
- An admin panel provider with its own key and no URL no longer inherits
  the global <P>_BASE_URL, which may be a proxy for another key; OpenAI
  then gets the official endpoint, as its Test. Azure keeps the server's
  resource.
This commit is contained in:
dayuan.jiang
2026-10-05 17:02:06 +09:00
parent 50c7ad3ec4
commit 4731394f32
19 changed files with 488 additions and 59 deletions
+3 -1
View File
@@ -10,7 +10,7 @@ import {
import { jsonrepair } from "jsonrepair"
import path from "path"
import { z } from "zod"
import { checkAccessCode } from "@/lib/access-code"
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
import {
CACHE_POINT,
getAIModel,
@@ -100,6 +100,8 @@ const modelStreamResponses = new WeakSet<Response>()
const DEBUG_LLM_PAYLOAD = process.env.DEBUG_LLM_PAYLOAD === "true"
async function handleChatRequest(req: Request): Promise<Response> {
const crossSite = rejectCrossSite(req)
if (crossSite) return crossSite
// Check for access code
const accessDenied = checkAccessCode(req)
if (accessDenied) return accessDenied
+5 -28
View File
@@ -1,7 +1,8 @@
import { extractFromHtml } from "@extractus/article-extractor"
import { NextResponse } from "next/server"
import TurndownService from "turndown"
import { checkAccessCode } from "@/lib/access-code"
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
import { readLimitedBody } from "@/lib/read-limited-body"
import { isPrivateUrl } from "@/lib/ssrf-protection"
const MAX_CONTENT_LENGTH = 150000 // Match PDF limit
@@ -34,33 +35,9 @@ function detectCharset(
}
}
// Read the response body, giving up once it passes MAX_RESPONSE_BYTES so a
// huge download can't exhaust server memory. Returns null when too large.
async function readLimitedBody(
response: Response,
): Promise<ArrayBuffer | null> {
if (Number(response.headers.get("content-length")) > MAX_RESPONSE_BYTES) {
return null
}
if (!response.body) return new ArrayBuffer(0)
const reader = response.body.getReader()
const chunks: Uint8Array[] = []
let total = 0
while (true) {
const { done, value } = await reader.read()
if (done) break
total += value.byteLength
if (total > MAX_RESPONSE_BYTES) {
await reader.cancel()
return null
}
chunks.push(value)
}
return new Blob(chunks as BlobPart[]).arrayBuffer()
}
export async function POST(req: Request) {
const crossSite = rejectCrossSite(req)
if (crossSite) return crossSite
const accessError = checkAccessCode(req)
if (accessError) return accessError
@@ -128,7 +105,7 @@ export async function POST(req: Request) {
)
}
const buffer = await readLimitedBody(response)
const buffer = await readLimitedBody(response, MAX_RESPONSE_BYTES)
if (!buffer) {
return NextResponse.json(
{
+23 -4
View File
@@ -1,7 +1,11 @@
import { NextResponse } from "next/server"
import { checkAccessCode } from "@/lib/access-code"
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
import { classifyLLMError } from "@/lib/llm-errors"
import { canListModels, listProviderModels } from "@/lib/provider-models"
import {
canListModels,
listProviderModels,
ModelListError,
} from "@/lib/provider-models"
import {
allowPrivateUrls,
isPrivateUrl,
@@ -24,6 +28,8 @@ const NO_KEY_NEEDED = new Set<ProviderName>([
* so the dialog keeps its suggested models.
*/
export async function POST(req: Request) {
const crossSite = rejectCrossSite(req)
if (crossSite) return crossSite
// Sends requests to a URL the client chose, so require the access code
const accessError = checkAccessCode(req)
if (accessError) return accessError
@@ -56,7 +62,20 @@ export async function POST(req: Request) {
return NextResponse.json({ models })
} catch (error) {
console.warn("[provider-models] Listing failed:", error)
const { code, message } = classifyLLMError(error)
return NextResponse.json({ code, error: message })
// Only our own explanations go back: the URL may be an internal
// address, whose answer or host names must not reach the caller.
// The Gateway SDK wraps them, keeping ours as the cause.
const cause = (error as { cause?: unknown })?.cause
const own =
error instanceof ModelListError
? error
: cause instanceof ModelListError
? cause
: null
const { code } = classifyLLMError(own ?? error)
return NextResponse.json({
code,
error: own?.message ?? "The model list request failed.",
})
}
}
+3 -1
View File
@@ -4,7 +4,7 @@
*/
import { Output, streamText } from "ai"
import { checkAccessCode } from "@/lib/access-code"
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
import { getValidationModel } from "@/lib/ai-providers"
import { VALIDATION_SYSTEM_PROMPT } from "@/lib/validation-prompts"
import {
@@ -37,6 +37,8 @@ function createStreamingResponse(result: ValidationResult): Response {
}
export async function POST(req: Request): Promise<Response> {
const crossSite = rejectCrossSite(req)
if (crossSite) return crossSite
// Uses the server's model credentials, so require the access code
const accessError = checkAccessCode(req)
if (accessError) return accessError
+3 -1
View File
@@ -1,7 +1,7 @@
import { streamText, tool } from "ai"
import { NextResponse } from "next/server"
import { z } from "zod"
import { checkAccessCode } from "@/lib/access-code"
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
import { checkAdminAuth } from "@/lib/admin/auth"
import { getAIModel, usesServerCredentials } from "@/lib/ai-providers"
import { classifyLLMError } from "@/lib/llm-errors"
@@ -35,6 +35,8 @@ const NO_TOOL_CALL_WARNING =
"Connected, but the model answered without calling a tool. It may not support tool calls, which drawing needs."
export async function POST(req: Request) {
const crossSite = rejectCrossSite(req)
if (crossSite) return crossSite
// Lets the server send requests to arbitrary URLs, so require the access
// code, or the admin password (the admin panel's Test button)
const accessError = checkAccessCode(req)