fix(api): require access codes and limit sizes on helper routes

- Shared checkAccessCode for validate-diagram, validate-model, parse-url, verify-access-code
- parse-url: 5 MB streamed body limit; validate-diagram: 5 MB image limit
- validate-model refuses redirects when private URLs are blocked
- Admin settings state shared across module instances via globalThis
- Server model ids: unique slugs (non-ASCII names encoded), duplicates rejected
- Panel Bedrock credentials stored as ADMIN_AWS_* so the DynamoDB client keeps its own
- Locale redirect keeps basePath and query; EdgeOne function drops open CORS and checks the access code
- Providers payload reports whether .env sets a default model
This commit is contained in:
dayuan.jiang
2026-10-03 17:45:41 +09:00
parent 366480426d
commit 528b6e54c8
16 changed files with 622 additions and 93 deletions
+8 -1
View File
@@ -7,7 +7,11 @@ import {
mergeSecrets,
validateAdminProviders,
} from "@/lib/admin/providers"
import { isSettingsWritable, saveSettings } from "@/lib/admin/settings"
import {
getEnvFallback,
isSettingsWritable,
saveSettings,
} from "@/lib/admin/settings"
import { loadEnvServerModelsConfig } from "@/lib/server-model-config"
export const runtime = "nodejs"
@@ -33,6 +37,9 @@ async function payload() {
models: p.models,
isDefault: !!p.default && !adminHasDefault,
})) ?? [],
// Whether .env sets a default model. getEnvFallback skips the value
// the panel overlays onto process.env, so a panel default doesn't count.
envHasDefaultModel: !!getEnvFallback("AI_MODEL"),
}
}
+40 -1
View File
@@ -1,9 +1,11 @@
import { extractFromHtml } from "@extractus/article-extractor"
import { NextResponse } from "next/server"
import TurndownService from "turndown"
import { checkAccessCode } from "@/lib/access-code"
import { isPrivateUrl } from "@/lib/ssrf-protection"
const MAX_CONTENT_LENGTH = 150000 // Match PDF limit
const MAX_RESPONSE_BYTES = 5 * 1024 * 1024
const EXTRACT_TIMEOUT_MS = 15000
const USER_AGENT = "Mozilla/5.0 (compatible; NextAIDrawio/1.0)"
@@ -32,7 +34,36 @@ 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 accessError = checkAccessCode(req)
if (accessError) return accessError
try {
const { url } = await req.json()
@@ -97,7 +128,15 @@ export async function POST(req: Request) {
)
}
const buffer = await response.arrayBuffer()
const buffer = await readLimitedBody(response)
if (!buffer) {
return NextResponse.json(
{
error: `Page exceeds the ${MAX_RESPONSE_BYTES / 1024 / 1024} MB download limit`,
},
{ status: 413 },
)
}
const charset = detectCharset(contentType, buffer)
html = new TextDecoder(charset).decode(buffer)
} catch (err: any) {
+15
View File
@@ -4,6 +4,7 @@
*/
import { streamObject } from "ai"
import { checkAccessCode } from "@/lib/access-code"
import { getValidationModel } from "@/lib/ai-providers"
import { VALIDATION_SYSTEM_PROMPT } from "@/lib/validation-prompts"
import {
@@ -13,6 +14,9 @@ import {
export const maxDuration = 30
// Data URL length cap (~3.75 MB of PNG), well above a normal diagram capture
const MAX_IMAGE_DATA_LENGTH = 5 * 1024 * 1024
interface ValidateDiagramRequest {
imageData: string // Base64 PNG data URL
sessionId?: string
@@ -44,6 +48,10 @@ function createStreamingResponse(result: ValidationResult): Response {
}
export async function POST(req: Request): Promise<Response> {
// Uses the server's model credentials, so require the access code
const accessError = checkAccessCode(req)
if (accessError) return accessError
try {
// Check if VLM validation is enabled (default: true)
const enableValidation = process.env.ENABLE_VLM_VALIDATION !== "false"
@@ -72,6 +80,13 @@ export async function POST(req: Request): Promise<Response> {
)
}
if (imageData.length > MAX_IMAGE_DATA_LENGTH) {
return Response.json(
{ error: "Image data too large" },
{ status: 413 },
)
}
// Get the validation model
let model
try {
+49 -4
View File
@@ -10,6 +10,7 @@ import { createOpenRouter } from "@openrouter/ai-sdk-provider"
import { generateText } from "ai"
import { NextResponse } from "next/server"
import { createOllama } from "ollama-ai-provider-v2"
import { checkAccessCode } from "@/lib/access-code"
import {
AIHUBMIX_APP_CODE,
isAihubmixStandardBaseURL,
@@ -33,7 +34,24 @@ interface ValidateRequest {
vertexApiKey?: string // Express Mode API key
}
// With private URLs blocked, a public baseUrl could still redirect the
// request to an internal host, so redirects are refused in that case.
function redirectGuardedFetch(): typeof fetch | undefined {
if (allowPrivateUrls()) return undefined
return async (input, init) => {
const response = await fetch(input, { ...init, redirect: "manual" })
if (response.status >= 300 && response.status < 400) {
throw new Error("Redirects are not allowed for custom base URLs")
}
return response
}
}
export async function POST(req: Request) {
// Lets the server send requests to arbitrary URLs, so require the access code
const accessError = checkAccessCode(req)
if (accessError) return accessError
try {
const body: ValidateRequest = await req.json()
const {
@@ -91,6 +109,7 @@ export async function POST(req: Request) {
)
}
const guardedFetch = redirectGuardedFetch()
let model: any
switch (provider) {
@@ -98,6 +117,7 @@ export async function POST(req: Request) {
const openai = createOpenAI({
apiKey,
...(baseUrl && { baseURL: baseUrl }),
fetch: guardedFetch,
})
model = openai.chat(modelId)
break
@@ -107,6 +127,7 @@ export async function POST(req: Request) {
const anthropic = createAnthropic({
apiKey,
baseURL: baseUrl || "https://api.anthropic.com/v1",
fetch: guardedFetch,
})
model = anthropic(modelId)
break
@@ -116,6 +137,7 @@ export async function POST(req: Request) {
const google = createGoogleGenerativeAI({
apiKey,
...(baseUrl && { baseURL: baseUrl }),
fetch: guardedFetch,
})
model = google(modelId)
break
@@ -125,6 +147,7 @@ export async function POST(req: Request) {
const vertex = createVertex({
apiKey: vertexApiKey,
...(baseUrl && { baseURL: baseUrl }),
fetch: guardedFetch,
})
model = vertex(modelId)
break
@@ -134,6 +157,7 @@ export async function POST(req: Request) {
const azure = createOpenAI({
apiKey,
baseURL: baseUrl,
fetch: guardedFetch,
})
model = azure.chat(modelId)
break
@@ -153,6 +177,7 @@ export async function POST(req: Request) {
const openrouter = createOpenRouter({
apiKey,
...(baseUrl && { baseURL: baseUrl }),
fetch: guardedFetch,
})
model = openrouter(modelId)
break
@@ -174,6 +199,7 @@ export async function POST(req: Request) {
const aihubmixCompatible = createOpenAI({
apiKey,
baseURL: baseUrl,
fetch: guardedFetch,
})
model = aihubmixCompatible.chat(modelId)
}
@@ -185,6 +211,7 @@ export async function POST(req: Request) {
const ds = createDeepSeek({
apiKey,
...(baseUrl && { baseURL: baseUrl }),
fetch: guardedFetch,
})
model = ds(modelId)
} else {
@@ -197,6 +224,7 @@ export async function POST(req: Request) {
const sf = createOpenAI({
apiKey,
baseURL: baseUrl || "https://api.siliconflow.cn/v1",
fetch: guardedFetch,
})
model = sf.chat(modelId)
break
@@ -213,6 +241,7 @@ export async function POST(req: Request) {
baseUrl ||
process.env.OLLAMA_BASE_URL ||
"https://ollama.com/api",
fetch: guardedFetch,
...(ollamaApiKey && {
headers: { Authorization: `Bearer ${ollamaApiKey}` },
}),
@@ -225,6 +254,7 @@ export async function POST(req: Request) {
const gw = createGateway({
apiKey,
...(baseUrl && { baseURL: baseUrl }),
fetch: guardedFetch,
})
model = gw(modelId)
break
@@ -232,13 +262,16 @@ export async function POST(req: Request) {
case "edgeone": {
// EdgeOne uses OpenAI-compatible API via Edge Functions
// Need to pass cookies for EdgeOne Pages authentication
// Need to pass cookies for EdgeOne Pages authentication,
// and the access code, which the edge function also checks
const cookieHeader = req.headers.get("cookie") || ""
const edgeone = createOpenAI({
apiKey: "edgeone", // EdgeOne doesn't require API key
baseURL: baseUrl || "/api/edgeai",
fetch: guardedFetch,
headers: {
cookie: cookieHeader,
"x-access-code": req.headers.get("x-access-code") || "",
},
})
model = edgeone.chat(modelId)
@@ -250,6 +283,7 @@ export async function POST(req: Request) {
const sglang = createOpenAI({
apiKey: apiKey || "not-needed",
baseURL: baseUrl || "http://127.0.0.1:8000/v1",
fetch: guardedFetch,
})
model = sglang.chat(modelId)
break
@@ -267,12 +301,14 @@ export async function POST(req: Request) {
const doubao = createDeepSeek({
apiKey,
baseURL: doubaoBaseUrl,
fetch: guardedFetch,
})
model = doubao(modelId)
} else {
const doubao = createOpenAI({
apiKey,
baseURL: doubaoBaseUrl,
fetch: guardedFetch,
})
model = doubao.chat(modelId)
}
@@ -286,7 +322,7 @@ export async function POST(req: Request) {
try {
// Initiate a streaming request (required for QwQ-32B and certain Qwen3 models)
const response = await fetch(
const response = await (guardedFetch ?? fetch)(
`${baseURL}/chat/completions`,
{
method: "POST",
@@ -307,9 +343,15 @@ export async function POST(req: Request) {
)
if (!response.ok) {
const errorText = await response.text()
// Log the body but return only the status: the
// caller chooses baseUrl, so the body may come from
// any host the server can reach
console.error(
"[validate-model] ModelScope error body:",
await response.text(),
)
throw new Error(
`ModelScope API error (${response.status}): ${errorText}`,
`ModelScope API error (${response.status})`,
)
}
@@ -360,12 +402,14 @@ export async function POST(req: Request) {
const minimax = createAnthropic({
apiKey,
baseURL: minimaxBaseUrl,
fetch: guardedFetch,
})
model = minimax.chat(modelId)
} else {
const minimax = createOpenAI({
apiKey,
baseURL: minimaxBaseUrl,
fetch: guardedFetch,
})
model = minimax.chat(modelId)
}
@@ -398,6 +442,7 @@ export async function POST(req: Request) {
const openai = createOpenAI({
apiKey,
baseURL,
fetch: guardedFetch,
})
model = openai.chat(modelId)
break
+4 -24
View File
@@ -1,29 +1,9 @@
import { checkAccessCode } from "@/lib/access-code"
export async function POST(req: Request) {
const accessCodes =
process.env.ACCESS_CODE_LIST?.split(",")
.map((code) => code.trim())
.filter(Boolean) || []
// If no access codes configured, verification always passes
if (accessCodes.length === 0) {
return Response.json({
valid: true,
message: "No access code required",
})
}
const accessCodeHeader = req.headers.get("x-access-code")
if (!accessCodeHeader) {
if (checkAccessCode(req)) {
return Response.json(
{ valid: false, message: "Access code is required" },
{ status: 401 },
)
}
if (!accessCodes.includes(accessCodeHeader)) {
return Response.json(
{ valid: false, message: "Invalid access code" },
{ valid: false, message: "Invalid or missing access code" },
{ status: 401 },
)
}