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 },
)
}
+43 -23
View File
@@ -67,41 +67,62 @@ const MODEL_ALIASES: Record<string, string> = {
"deepseek-v3-0324": "@tx/deepseek-ai/deepseek-v3-0324",
}
const CORS_HEADERS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
}
/**
* Create standardized response with CORS headers
* Create standardized JSON response
*/
function createResponse(body: any, status = 200, extraHeaders = {}): Response {
return new Response(JSON.stringify(body), {
status,
headers: {
"Content-Type": "application/json",
...CORS_HEADERS,
...extraHeaders,
},
})
}
/**
* Handle OPTIONS request for CORS preflight
*/
function handleOptionsRequest(): Response {
return new Response(null, {
headers: {
...CORS_HEADERS,
"Access-Control-Max-Age": "86400",
},
})
// Only the app's own server (/api/chat, /api/validate-model) calls this
// function, so no CORS headers are sent: other sites' pages can't call it
// from a browser and spend the deployment's Edge AI quota.
// Same rule as lib/access-code.ts, but reading the edge function's env.
// No codes configured (or env unavailable) means no check.
function hasValidAccessCode(request: Request, env: any): boolean {
const accessCodes: string[] =
env?.ACCESS_CODE_LIST?.split(",")
.map((code: string) => code.trim())
.filter(Boolean) || []
if (accessCodes.length === 0) return true
const accessCode = request.headers.get("x-access-code")
return !!accessCode && accessCodes.includes(accessCode)
}
export async function onRequest({ request, env: _env }: any) {
if (request.method === "OPTIONS") {
return handleOptionsRequest()
export async function onRequest({ request, env }: any) {
// Requiring JSON also makes any cross-site browser request need a CORS
// preflight, which fails without CORS headers
if (
request.method !== "POST" ||
!request.headers.get("content-type")?.includes("application/json")
) {
return createResponse(
{
error: {
message: "Expected a POST request with a JSON body",
type: "invalid_request_error",
},
},
400,
)
}
if (!hasValidAccessCode(request, env)) {
return createResponse(
{
error: {
message: "Invalid or missing access code",
type: "invalid_request_error",
},
},
401,
)
}
request.headers.delete("accept-encoding")
@@ -153,7 +174,7 @@ export async function onRequest({ request, env: _env }: any) {
type: "invalid_request_error",
},
},
429,
400,
)
}
@@ -216,7 +237,6 @@ export async function onRequest({ request, env: _env }: any) {
"Cache-Control": "no-cache, no-store, no-transform",
"X-Accel-Buffering": "no",
Connection: "keep-alive",
...CORS_HEADERS,
},
})
} catch (error: any) {
+19 -7
View File
@@ -2,6 +2,7 @@ import { z } from "zod"
import {
ProviderNameSchema,
type ServerModelsConfig,
slugify,
} from "@/lib/server-model-config"
import {
FIXED_CRED_PROVIDERS,
@@ -182,12 +183,15 @@ export function validateAdminProviders(
return `${PROVIDER_INFO[single].label} is already configured in AI_MODELS_CONFIG / ai-models.json and shares global credentials. Manage it via the environment configuration instead.`
}
}
// Server model ids are built from the slugified name, so names must
// stay distinct after slugifying ("OpenAI" and "openai" would collide)
const names = list.map((p) => displayName(p))
if (new Set(names).size !== names.length) {
return "Provider display names must be unique."
const slugs = names.map(slugify)
if (new Set(slugs).size !== slugs.length) {
return "Provider display names must be unique (ignoring case and punctuation)."
}
const envNames = new Set(envProviders.map((p) => p.name))
const clash = names.find((n) => envNames.has(n))
const envSlugs = new Set(envProviders.map((p) => slugify(p.name)))
const clash = names.find((_, i) => envSlugs.has(slugs[i]))
if (clash) {
return `"${clash}" is already defined in AI_MODELS_CONFIG / ai-models.json. Use a different display name.`
}
@@ -240,10 +244,14 @@ export function deriveEnvUpdates(
indexByProvider.set(p.provider, index + 1)
if (p.provider === "bedrock") {
if (p.awsAccessKeyId) updates.AWS_ACCESS_KEY_ID = p.awsAccessKeyId
// ADMIN_ names keep the standard AWS_* vars untouched, so other
// AWS clients (e.g. the DynamoDB quota table) keep their own
// credentials instead of picking up the panel's Bedrock keys
if (p.awsAccessKeyId)
updates.ADMIN_AWS_ACCESS_KEY_ID = p.awsAccessKeyId
if (p.awsSecretAccessKey)
updates.AWS_SECRET_ACCESS_KEY = p.awsSecretAccessKey
if (p.awsRegion) updates.AWS_REGION = p.awsRegion
updates.ADMIN_AWS_SECRET_ACCESS_KEY = p.awsSecretAccessKey
if (p.awsRegion) updates.ADMIN_AWS_REGION = p.awsRegion
} else if (p.provider === "vertexai") {
if (p.vertexApiKey) updates.GOOGLE_VERTEX_API_KEY = p.vertexApiKey
if (p.baseUrl) updates.GOOGLE_VERTEX_BASE_URL = p.baseUrl
@@ -284,6 +292,10 @@ function derivedEnvKeys(list: StoredAdminProvider[]): string[] {
const index = indexByProvider.get(p.provider) ?? 0
indexByProvider.set(p.provider, index + 1)
if (p.provider === "bedrock") {
keys.add("ADMIN_AWS_ACCESS_KEY_ID")
keys.add("ADMIN_AWS_SECRET_ACCESS_KEY")
keys.add("ADMIN_AWS_REGION")
// Written by older versions; listed so the next save clears them
keys.add("AWS_ACCESS_KEY_ID")
keys.add("AWS_SECRET_ACCESS_KEY")
keys.add("AWS_REGION")
+34 -19
View File
@@ -10,13 +10,27 @@ interface SettingsFile {
values: Record<string, string>
}
// Original env values snapshotted before the first overlay, so removing a
// key from the settings file restores the env default. null = was unset.
const originalEnv: Record<string, string | null> = {}
// Keys currently overlaid, so we can restore ones removed from the file.
let overlaidKeys = new Set<string>()
interface SettingsState {
// Original env values snapshotted before the first overlay, so removing
// a key from the settings file restores the env default. null = was unset.
originalEnv: Record<string, string | null>
// Keys currently overlaid, so we can restore ones removed from the file.
overlaidKeys: Set<string>
cachedSettings: Record<string, string> | null
}
let cachedSettings: Record<string, string> | null = null
// Kept on globalThis because the build can load this module more than once
// (instrumentation.ts and the API routes get separate copies); per-module
// state would make a route forget what instrumentation overlaid at startup.
const globalState = globalThis as typeof globalThis & {
__adminSettingsState?: SettingsState
}
globalState.__adminSettingsState ??= {
originalEnv: {},
overlaidKeys: new Set(),
cachedSettings: null,
}
const state = globalState.__adminSettingsState
export function getSettingsPath(): string {
const custom = process.env.SETTINGS_FILE
@@ -25,7 +39,7 @@ export function getSettingsPath(): string {
}
export function loadSettings(): Record<string, string> {
if (cachedSettings) return cachedSettings
if (state.cachedSettings) return state.cachedSettings
try {
const raw = fs.readFileSync(getSettingsPath(), "utf8")
const parsed = JSON.parse(raw) as SettingsFile
@@ -43,21 +57,22 @@ export function loadSettings(): Record<string, string> {
for (const [key, value] of Object.entries(rawValues)) {
if (typeof value === "string") values[key] = value
}
cachedSettings = values
state.cachedSettings = values
} catch (err: any) {
if (err?.code !== "ENOENT") {
console.error("[admin-settings] Failed to read settings file:", err)
}
cachedSettings = {}
state.cachedSettings = {}
}
return cachedSettings
return state.cachedSettings
}
export function applyToEnv(): void {
const values = loadSettings()
const { originalEnv } = state
// Restore env for keys that were overlaid before but are now gone
for (const key of overlaidKeys) {
for (const key of state.overlaidKeys) {
if (!(key in values)) {
const original = originalEnv[key]
if (original === null) delete process.env[key]
@@ -72,12 +87,12 @@ export function applyToEnv(): void {
process.env[key] = value
}
overlaidKeys = new Set(Object.keys(values))
state.overlaidKeys = new Set(Object.keys(values))
}
// The effective env value if the file entry were removed (for fallback display)
export function getEnvFallback(key: string): string | null {
if (overlaidKeys.has(key)) return originalEnv[key] ?? null
if (state.overlaidKeys.has(key)) return state.originalEnv[key] ?? null
return process.env[key] ?? null
}
@@ -101,7 +116,7 @@ export function saveSettings(updates: Record<string, string | null>): void {
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2), { mode: 0o600 })
fs.renameSync(tmpPath, filePath)
cachedSettings = current
state.cachedSettings = current
applyToEnv()
}
@@ -122,13 +137,13 @@ export function isSettingsWritable(): boolean {
// Test-only: reset module state
export function _resetForTests(): void {
cachedSettings = null
state.cachedSettings = null
writableCache = null
for (const key of overlaidKeys) {
const original = originalEnv[key]
for (const key of state.overlaidKeys) {
const original = state.originalEnv[key]
if (original === null) delete process.env[key]
else if (original !== undefined) process.env[key] = original
}
overlaidKeys = new Set()
for (const key of Object.keys(originalEnv)) delete originalEnv[key]
state.overlaidKeys = new Set()
state.originalEnv = {}
}
+16 -2
View File
@@ -47,11 +47,14 @@ export interface FlattenedServerModel {
/**
* Convert provider name to URL-safe slug for use in model ID
* e.g., "OpenAI Production" → "openai-production"
* e.g., "OpenAI Production" → "openai-production", "主力" → "4e3b-529b"
* Non-ASCII characters become their hex code point so CJK names stay
* distinct; the id is sent in HTTP headers, which must be ASCII.
*/
function slugify(name: string): string {
export function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^\p{ASCII}]/gu, (c) => `-${c.codePointAt(0)?.toString(16)}-`)
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
}
@@ -189,6 +192,7 @@ export async function loadFlattenedServerModels(): Promise<
const defaultModelId = process.env.AI_MODEL
const flattened: FlattenedServerModel[] = []
const seenIds = new Set<string>()
for (const p of cfg.providers) {
const providerLabel =
@@ -199,6 +203,16 @@ export async function loadFlattenedServerModels(): Promise<
for (const modelId of p.models) {
const id = `server:${nameSlug}:${modelId}`
// Names that differ only in case or punctuation share a slug.
// A repeated id would always resolve to the first provider's
// credentials, so drop it instead.
if (seenIds.has(id)) {
console.warn(
`[server-model-config] Skipping duplicate model id "${id}". Provider names must differ in letters or digits.`,
)
continue
}
seenIds.add(id)
// Default model priority:
// 1. From ai-models.json: first model of provider with default: true
+6 -7
View File
@@ -48,13 +48,12 @@ export function proxy(request: NextRequest) {
if (pathnameIsMissingLocale) {
const locale = getLocale(request)
// Redirect to localized path
return NextResponse.redirect(
new URL(
`/${locale}${pathname.startsWith("/") ? "" : "/"}${pathname}`,
request.url,
),
)
// Redirect to localized path. Cloning nextUrl keeps the basePath
// (NEXT_PUBLIC_BASE_PATH) and query string, which
// new URL("/...", request.url) would drop.
const url = request.nextUrl.clone()
url.pathname = `/${locale}${pathname}`
return NextResponse.redirect(url)
}
}
+71
View File
@@ -0,0 +1,71 @@
// @vitest-environment node
import fs from "fs"
import os from "os"
import path from "path"
import { afterEach, beforeEach, describe, expect, it } from "vitest"
import { GET, PUT } from "@/app/api/admin/providers/route"
import { _resetForTests } from "@/lib/admin/settings"
let tmpDir: string
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "admin-providers-route-"))
process.env.SETTINGS_FILE = path.join(tmpDir, "settings.json")
process.env.ADMIN_PASSWORD = "pw"
process.env.AI_MODELS_CONFIG_PATH = path.join(tmpDir, "none.json")
_resetForTests()
})
afterEach(() => {
_resetForTests()
delete process.env.SETTINGS_FILE
delete process.env.ADMIN_PASSWORD
delete process.env.AI_MODELS_CONFIG_PATH
delete process.env.AI_MODEL
fs.rmSync(tmpDir, { recursive: true, force: true })
})
const headers = { "x-admin-password": "pw" }
async function saveDefaultPanelProvider() {
const res = await PUT(
new Request("http://localhost/api/admin/providers", {
method: "PUT",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify({
providers: [
{
id: "p1",
provider: "openai",
apiKey: "sk-test",
models: ["gpt-panel"],
isDefault: true,
},
],
}),
}),
)
expect(res.status).toBe(200)
}
async function envHasDefaultModel(): Promise<boolean> {
const res = await GET(
new Request("http://localhost/api/admin/providers", { headers }),
)
return (await res.json()).envHasDefaultModel
}
describe("envHasDefaultModel", () => {
it("is true when .env sets AI_MODEL, even after a panel default", async () => {
process.env.AI_MODEL = "gpt-env"
expect(await envHasDefaultModel()).toBe(true)
await saveDefaultPanelProvider()
expect(await envHasDefaultModel()).toBe(true)
})
it("ignores the AI_MODEL the panel default writes", async () => {
await saveDefaultPanelProvider()
expect(process.env.AI_MODEL).toBe("gpt-panel")
expect(await envHasDefaultModel()).toBe(false)
})
})
+47 -4
View File
@@ -69,7 +69,7 @@ describe("deriveEnvUpdates", () => {
expect(updates.ADMIN_OPENAI_API_KEY_2).toBe("sk-second")
})
it("maps bedrock credentials to AWS env vars", () => {
it("maps bedrock credentials to ADMIN_AWS_* env vars", () => {
const updates = deriveEnvUpdates(
[
provider({
@@ -83,9 +83,26 @@ describe("deriveEnvUpdates", () => {
],
[],
)
expect(updates.AWS_ACCESS_KEY_ID).toBe("AKIA123")
expect(updates.AWS_SECRET_ACCESS_KEY).toBe("secret")
expect(updates.AWS_REGION).toBe("us-west-2")
expect(updates.ADMIN_AWS_ACCESS_KEY_ID).toBe("AKIA123")
expect(updates.ADMIN_AWS_SECRET_ACCESS_KEY).toBe("secret")
expect(updates.ADMIN_AWS_REGION).toBe("us-west-2")
// Standard AWS vars are left to the environment
expect(updates.AWS_ACCESS_KEY_ID).toBeUndefined()
})
it("clears AWS_* bedrock keys written by older versions", () => {
const bedrock = provider({
provider: "bedrock",
apiKey: undefined,
awsAccessKeyId: "AKIA123",
awsSecretAccessKey: "secret",
models: ["claude-x"],
})
const updates = deriveEnvUpdates([bedrock], [bedrock])
expect(updates.AWS_ACCESS_KEY_ID).toBeNull()
expect(updates.AWS_SECRET_ACCESS_KEY).toBeNull()
expect(updates.AWS_REGION).toBeNull()
expect(updates.ADMIN_AWS_ACCESS_KEY_ID).toBe("AKIA123")
})
it("clears keys owned by the previous list when providers are removed", () => {
@@ -315,6 +332,32 @@ describe("validateAdminProviders", () => {
expect(validateAdminProviders(list)).toMatch(/unique/)
})
it("rejects names that differ only in case or punctuation", () => {
const list = [
provider({ id: "p1", name: "Open AI" }),
provider({ id: "p2", name: "open-ai" }),
]
expect(validateAdminProviders(list)).toMatch(/unique/)
})
it("rejects a case-only clash with an env-configured name", () => {
expect(
validateAdminProviders([provider({ name: "openai" })], {
providers: [
{ name: "OpenAI", provider: "openai", models: ["gpt-x"] },
],
}),
).toMatch(/already defined/)
})
it("accepts distinct CJK names", () => {
const list = [
provider({ id: "p1", provider: "deepseek", name: "主力" }),
provider({ id: "p2", provider: "deepseek", name: "备用" }),
]
expect(validateAdminProviders(list)).toBeNull()
})
it("rejects multiple defaults", () => {
const list = [
provider({ id: "p1", isDefault: true }),
+19 -1
View File
@@ -1,7 +1,7 @@
import fs from "fs"
import os from "os"
import path from "path"
import { afterEach, beforeEach, describe, expect, it } from "vitest"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import {
_resetForTests,
applyToEnv,
@@ -100,6 +100,24 @@ describe("applyToEnv / saveSettings", () => {
expect(process.env.TEST_ADMIN_VAR).toBeUndefined()
})
it("a second module instance can remove a key the first one overlaid", async () => {
// instrumentation.ts and API routes load separate copies in a build
process.env.TEST_ADMIN_VAR = "from-env"
fs.writeFileSync(
process.env.SETTINGS_FILE!,
JSON.stringify({ version: 1, values: { TEST_ADMIN_VAR: "abc" } }),
)
applyToEnv()
expect(process.env.TEST_ADMIN_VAR).toBe("abc")
vi.resetModules()
const second = await import("@/lib/admin/settings")
expect(second.getValueSource("TEST_ADMIN_VAR")).toBe("file")
second.saveSettings({ TEST_ADMIN_VAR: null })
expect(process.env.TEST_ADMIN_VAR).toBe("from-env")
expect(second.getEnvFallback("TEST_ADMIN_VAR")).toBe("from-env")
})
it("persists across cache reset (file round-trip)", () => {
saveSettings({ TEST_ADMIN_VAR: "persisted" })
_resetForTests()
+156
View File
@@ -0,0 +1,156 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { POST as parseUrl } from "@/app/api/parse-url/route"
import { POST as validateDiagram } from "@/app/api/validate-diagram/route"
import { POST as validateModel } from "@/app/api/validate-model/route"
import { POST as verifyAccessCode } from "@/app/api/verify-access-code/route"
import { checkAccessCode } from "@/lib/access-code"
// Treat every URL as public so no test hits DNS
vi.mock("@/lib/ssrf-protection", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/ssrf-protection")>()),
isPrivateUrl: async () => false,
}))
function post(path: string, body: unknown, accessCode?: string): Request {
return new Request(`http://localhost${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(accessCode ? { "x-access-code": accessCode } : {}),
},
body: JSON.stringify(body),
})
}
beforeEach(() => {
process.env.ACCESS_CODE_LIST = "secret, other"
})
afterEach(() => {
delete process.env.ACCESS_CODE_LIST
delete process.env.ALLOW_PRIVATE_URLS
vi.unstubAllGlobals()
})
describe("checkAccessCode", () => {
it("passes when no access codes are configured", () => {
delete process.env.ACCESS_CODE_LIST
expect(checkAccessCode(post("/x", {}))).toBeNull()
})
it("rejects a missing or wrong code and accepts a listed one", () => {
expect(checkAccessCode(post("/x", {}))?.status).toBe(401)
expect(checkAccessCode(post("/x", {}, "nope"))?.status).toBe(401)
expect(checkAccessCode(post("/x", {}, "other"))).toBeNull()
})
})
describe("routes that spend server resources require the access code", () => {
it("parse-url", async () => {
const res = await parseUrl(
post("/api/parse-url", { url: "https://example.com" }),
)
expect(res.status).toBe(401)
})
it("validate-diagram", async () => {
const res = await validateDiagram(
post("/api/validate-diagram", {
imageData: "data:image/png;base64,AAAA",
}),
)
expect(res.status).toBe(401)
})
it("validate-model", async () => {
const res = await validateModel(
post("/api/validate-model", {
provider: "openai",
apiKey: "sk",
modelId: "m",
}),
)
expect(res.status).toBe(401)
})
it("verify-access-code", async () => {
const bad = await verifyAccessCode(post("/api/verify-access-code", {}))
expect(bad.status).toBe(401)
expect((await bad.json()).valid).toBe(false)
const good = await verifyAccessCode(
post("/api/verify-access-code", {}, "secret"),
)
expect((await good.json()).valid).toBe(true)
})
})
describe("size limits", () => {
it("parse-url stops reading a body over the download limit", async () => {
const chunk = new Uint8Array(1024 * 1024)
let sent = 0
const body = new ReadableStream<Uint8Array>({
pull(controller) {
sent++
controller.enqueue(chunk)
},
})
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(body, {
headers: { "content-type": "text/html" },
}),
),
)
const res = await parseUrl(
post("/api/parse-url", { url: "https://example.com" }, "secret"),
)
expect(res.status).toBe(413)
expect(sent).toBeLessThan(10)
})
it("validate-diagram rejects oversized image data", async () => {
const imageData = `data:image/png;base64,${"A".repeat(6 * 1024 * 1024)}`
const res = await validateDiagram(
post("/api/validate-diagram", { imageData }, "secret"),
)
expect(res.status).toBe(413)
})
})
describe("validate-model redirects", () => {
it("refuses redirects when private URLs are blocked", async () => {
process.env.ALLOW_PRIVATE_URLS = "false"
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(null, {
status: 302,
headers: { location: "http://169.254.169.254/" },
}),
),
)
const res = await validateModel(
post(
"/api/validate-model",
{
provider: "openai",
apiKey: "sk",
modelId: "m",
baseUrl: "https://attacker.example/v1",
},
"secret",
),
)
const data = await res.json()
expect(data.valid).toBe(false)
expect(data.error).toMatch(/Redirects are not allowed/)
expect(fetch).toHaveBeenCalledTimes(1)
})
})
+47
View File
@@ -0,0 +1,47 @@
// @vitest-environment node
import { describe, expect, it } from "vitest"
import { onRequest } from "@/edge-functions/api/edgeai/chat/completions"
function request(headers: Record<string, string>): Request {
return new Request("http://localhost/api/edgeai/chat/completions", {
method: "POST",
headers,
// Non-streaming requests return a mock reply without calling AI
body: JSON.stringify({ messages: [{ role: "user", content: "hi" }] }),
})
}
const json = { "Content-Type": "application/json" }
describe("EdgeOne chat completions function", () => {
it("sends no CORS headers", async () => {
const res = await onRequest({ request: request(json), env: {} })
expect(res.status).toBe(200)
expect(res.headers.get("access-control-allow-origin")).toBeNull()
})
it("rejects non-JSON requests", async () => {
const res = await onRequest({
request: request({ "Content-Type": "text/plain" }),
env: {},
})
expect(res.status).toBe(400)
})
it("checks the access code when ACCESS_CODE_LIST is set", async () => {
const env = { ACCESS_CODE_LIST: "secret" }
const missing = await onRequest({ request: request(json), env })
expect(missing.status).toBe(401)
const ok = await onRequest({
request: request({ ...json, "x-access-code": "secret" }),
env,
})
expect(ok.status).toBe(200)
})
it("lets requests through when env is unavailable", async () => {
const res = await onRequest({ request: request(json) })
expect(res.status).toBe(200)
})
})
+48
View File
@@ -4,6 +4,7 @@ import {
loadFlattenedServerModels,
type ServerModelsConfig,
ServerModelsConfigSchema,
slugify,
} from "@/lib/server-model-config"
const ORIGINAL_ENV = { ...process.env }
@@ -233,3 +234,50 @@ describe("loadFlattenedServerModels", () => {
expect(models[0].apiKeyEnv).toEqual(["OPENAI_KEY_1", "OPENAI_KEY_2"])
})
})
describe("slugify", () => {
it("keeps ASCII names readable", () => {
expect(slugify("OpenAI Production")).toBe("openai-production")
})
it("gives distinct ASCII slugs to distinct CJK names", () => {
const slugs = ["主力", "备用", "DeepSeek 官方", "DeepSeek 备用"].map(
slugify,
)
expect(new Set(slugs).size).toBe(4)
for (const slug of slugs) expect(slug).toMatch(/^[a-z0-9-]+$/)
})
})
describe("loadFlattenedServerModels id collisions", () => {
it("drops a model whose id repeats an earlier provider's", async () => {
const config: ServerModelsConfig = {
providers: [
{ name: "OpenAI", provider: "openai", models: ["gpt-4o"] },
{
name: "openai",
provider: "openai",
models: ["gpt-4o"],
apiKeyEnv: "OTHER_KEY",
},
{
name: "主力",
provider: "deepseek",
models: ["deepseek-chat"],
},
{
name: "备用",
provider: "deepseek",
models: ["deepseek-chat"],
},
],
}
process.env.AI_MODELS_CONFIG = JSON.stringify(config)
const models = await loadFlattenedServerModels()
const ids = models.map((m) => m.id)
expect(new Set(ids).size).toBe(ids.length)
expect(ids).toHaveLength(3)
expect(models[0].apiKeyEnv).toBeUndefined()
})
})