fix(admin): secure proxied model mutations
Staging Backend Quality Gate / validate (push) Successful in 19m41s
Staging Backend Quality Gate / publish (push) Successful in 28m13s

This commit is contained in:
Jesse_Chen
2026-08-08 01:26:21 +08:00
parent c30b4b906b
commit 99e2abe9bc
9 changed files with 275 additions and 58 deletions
+13 -5
View File
@@ -28,7 +28,6 @@ const providerSchema = z.object({
baseUrl: z.string().url().startsWith("https://").nullable().optional(),
apiKey: z.string().max(4096).optional(),
enabled: z.boolean(),
reason: z.string().trim().min(1).max(500),
}).strict();
const settingsSchema = z.record(z.string(), z.unknown()).superRefine((value, context) => {
if (modelSettingsContainSecrets(value)) {
@@ -52,20 +51,26 @@ const draftSchema = z.object({
isDefault: z.boolean(),
fallbackModelId: z.string().regex(/^[a-z0-9][a-z0-9._-]{0,63}$/).nullable().optional(),
settings: settingsSchema.default({}),
reason: z.string().trim().min(1).max(500),
}).strict();
const actionSchema = z.discriminatedUnion("action", [
providerSchema,
draftSchema,
z.object({ action: z.literal("test"), versionId: z.string().uuid() }).strict(),
z.object({ action: z.literal("publish"), versionId: z.string().uuid(), reason: z.string().trim().min(1).max(500) }).strict(),
z.object({ action: z.literal("rollback"), configId: z.string().uuid(), targetVersion: z.number().int().positive(), reason: z.string().trim().min(1).max(500) }).strict(),
z.object({ action: z.literal("publish"), versionId: z.string().uuid() }).strict(),
z.object({ action: z.literal("rollback"), configId: z.string().uuid(), targetVersion: z.number().int().positive() }).strict(),
]).superRefine((value, context) => {
if (value.action === "saveDraft" && value.isDefault && !value.enabled) {
context.addIssue({ code: "custom", path: ["isDefault"], message: "默认模型必须启用" });
}
});
const modelMutationAuditReasons = {
saveProvider: "保存模型供应商",
saveDraft: "保存模型草稿",
publish: "发布模型版本",
rollback: "回滚模型版本",
} as const;
type ProviderRow = {
id: string;
code: string;
@@ -178,8 +183,11 @@ export async function POST(request: Request) {
: "models.write";
const session = await requireAdminMutation(request, permission);
const rid = requestId(request);
const mutation: AdminModelMutation = body.data.action === "test"
? body.data
: { ...body.data, reason: modelMutationAuditReasons[body.data.action] };
return await handleAdminModelMutation(
body.data as AdminModelMutation,
mutation,
{ actorUserId: session.user.id, requestId: rid },
{
queryRows: (sql, values) => queryAdminRows<Record<string, unknown>>(sql, values),
@@ -24,7 +24,6 @@ import {
import { useCallback, useEffect, useState } from "react";
import { adminRequestJson, type AdminIdentity } from "@/lib/admin/providers";
import { ReasonActionModal } from "./reason-action-modal";
import { formatAdminDate } from "./resource-table";
const { Text } = Typography;
@@ -76,7 +75,6 @@ type ProviderForm = {
type ModelForm = Omit<ModelVersion, "id" | "configId" | "version" | "providerCode" | "status" | "createdAt" | "publishedAt" | "settings"> & {
versionId?: string;
settingsJson: string;
reason: string;
};
type ModelsPayload = { data: ModelVersion[]; total: number; providers: Provider[] };
@@ -121,7 +119,6 @@ export default function ModelManagement() {
const [discoveredModels, setDiscoveredModels] = useState<DiscoveredModel[]>([]);
const [actingId, setActingId] = useState<string | null>(null);
const [versionAction, setVersionAction] = useState<VersionAction | null>(null);
const [pendingProvider, setPendingProvider] = useState<Record<string, unknown> | null>(null);
const [filters, setFilters] = useState<ModelFilters>({});
const canWrite = Boolean(identity?.permissions.includes("models.write"));
const canTest = Boolean(identity?.permissions.includes("models.test"));
@@ -187,7 +184,6 @@ export default function ModelManagement() {
isDefault: model.isDefault,
fallbackModelId: model.fallbackModelId,
settingsJson: JSON.stringify(model.settings, null, 2),
reason: "",
} : {
modelId: "",
providerId: providerId ?? providers[0]?.id,
@@ -203,37 +199,32 @@ export default function ModelManagement() {
isDefault: false,
fallbackModelId: null,
settingsJson: "{}",
reason: "",
});
setModelOpen(true);
}
function prepareProviderSave(values: ProviderForm) {
async function saveProvider(values: ProviderForm) {
const apiKey = values.apiKey?.trim();
setPendingProvider({
action: "saveProvider",
id: editingProvider?.id ?? null,
name: values.name.trim(),
providerType: values.providerType,
baseUrl: values.providerType === "openai-compatible" ? values.baseUrl?.trim() : null,
...(apiKey ? { apiKey } : {}),
enabled: values.enabled,
});
}
async function saveProvider(reason: string) {
if (!pendingProvider) return;
setSaving(true);
try {
await adminRequestJson("/api/admin/models", {
method: "POST",
body: JSON.stringify({ ...pendingProvider, reason }),
body: JSON.stringify({
action: "saveProvider",
id: editingProvider?.id ?? null,
name: values.name.trim(),
providerType: values.providerType,
baseUrl: values.providerType === "openai-compatible" ? values.baseUrl?.trim() : null,
...(apiKey ? { apiKey } : {}),
enabled: values.enabled,
}),
});
message.success("供应商配置已保存");
setPendingProvider(null);
setProviderOpen(false);
providerForm.resetFields();
await load();
} catch (error) {
message.error(error instanceof Error ? error.message : "保存失败");
} finally {
setSaving(false);
}
@@ -296,7 +287,6 @@ export default function ModelManagement() {
isDefault: values.isDefault,
fallbackModelId: values.fallbackModelId?.trim() || null,
settings,
reason: values.reason.trim(),
}),
});
message.success("模型草稿已保存");
@@ -320,14 +310,18 @@ export default function ModelManagement() {
}
}
async function submitVersionAction(reason: string) {
async function submitVersionAction() {
if (!versionAction) return;
const { action, model } = versionAction;
await act(action === "publish"
? { action, versionId: model.id, reason }
: { action, configId: model.configId, targetVersion: model.version, reason },
action === "publish" ? "模型已发布" : "模型已回滚", model.id);
setVersionAction(null);
try {
await act(action === "publish"
? { action, versionId: model.id }
: { action, configId: model.configId, targetVersion: model.version },
action === "publish" ? "模型已发布" : "模型已回滚", model.id);
setVersionAction(null);
} catch (error) {
message.error(error instanceof Error ? error.message : action === "publish" ? "发布失败" : "回滚失败");
}
}
async function testVersion(item: ModelVersion) {
@@ -411,8 +405,8 @@ export default function ModelManagement() {
</Card>
</Space>
<Modal title={editingProvider ? "编辑供应商" : "新增供应商"} open={providerOpen} okText="继续验证" cancelText="取消" confirmLoading={saving} onOk={() => providerForm.submit()} onCancel={() => { setProviderOpen(false); providerForm.resetFields(); }} destroyOnHidden>
<Form<ProviderForm> form={providerForm} layout="vertical" onFinish={prepareProviderSave}>
<Modal title={editingProvider ? "编辑供应商" : "新增供应商"} open={providerOpen} okText="保存" cancelText="取消" confirmLoading={saving} onOk={() => providerForm.submit()} onCancel={() => { setProviderOpen(false); providerForm.resetFields(); }} destroyOnHidden>
<Form<ProviderForm> form={providerForm} layout="vertical" onFinish={saveProvider}>
<Row gutter={16}><Col xs={24} md={12}><Form.Item name="name" label="名称" rules={[{ required: true }]}><Input /></Form.Item></Col><Col xs={24} md={12}><Form.Item label="代码预览(服务端生成)"><Input readOnly value={editingProvider?.code ?? "保存后由服务端自动生成"} /></Form.Item></Col></Row>
<Form.Item name="providerType" label="类型" rules={[{ required: true }]}><Select options={Object.entries(providerTypeLabels).map(([value, label]) => ({ value, label }))} /></Form.Item>
<Form.Item noStyle shouldUpdate={(before, after) => before.providerType !== after.providerType}>{({ getFieldValue }) => getFieldValue("providerType") === "openai-compatible" ? <Form.Item name="baseUrl" label="Base URL" rules={[{ required: true }, { type: "url" }]}><Input /></Form.Item> : null}</Form.Item>
@@ -454,25 +448,23 @@ export default function ModelManagement() {
<Form.Item name="fallbackModelId" label="回退模型 ID"><Input allowClear /></Form.Item>
<Form.Item name="settingsJson" label="设置 JSON" rules={[{ required: true }]}><Input.TextArea rows={5} spellCheck={false} /></Form.Item>
<Space size="large"><Form.Item name="enabled" label="启用" valuePropName="checked"><Switch /></Form.Item><Form.Item name="isDefault" label="默认模型" valuePropName="checked" dependencies={["enabled"]} rules={[({ getFieldValue }) => ({ validator(_, value) { return value && !getFieldValue("enabled") ? Promise.reject(new Error("默认模型必须启用")) : Promise.resolve(); } })]}><Switch /></Form.Item></Space>
<Form.Item name="reason" label="修改原因" rules={[{ required: true }, { max: 500 }]}><Input.TextArea rows={2} /></Form.Item>
</Form>
</Modal>
<ReasonActionModal
open={Boolean(pendingProvider)}
title="保存模型供应商"
okText="保存"
confirmLoading={saving}
onCancel={() => setPendingProvider(null)}
onSubmit={saveProvider}
/>
<ReasonActionModal
<Modal
open={Boolean(versionAction)}
title={versionAction?.action === "rollback" ? `回滚到 v${versionAction.model.version}` : "发布模型版本"}
okText={versionAction?.action === "rollback" ? "确认回滚" : "确认发布"}
danger={versionAction?.action === "rollback"}
okButtonProps={{ danger: versionAction?.action === "rollback" }}
confirmLoading={Boolean(actingId)}
onCancel={() => setVersionAction(null)}
onSubmit={submitVersionAction}
/>
onOk={() => void submitVersionAction()}
destroyOnHidden
>
<Text>
{versionAction?.action === "rollback"
? "确认将此历史版本恢复为新的已发布版本?"
: "确认发布此模型版本?"}
</Text>
</Modal>
</List>;
}
+62
View File
@@ -1,6 +1,7 @@
import { createHmac, timingSafeEqual } from "node:crypto";
import type { IdentityUser } from "@/modules/identity/contracts";
import { normalizeIdentityHost } from "@/modules/identity/host";
export type AdminRole =
| "owner"
@@ -95,6 +96,67 @@ export function isSameOriginAdminMutation(
}
}
function singleForwardedValue(value: string | null): string | null {
const normalized = value?.trim();
return normalized && !normalized.includes(",") ? normalized : null;
}
function configuredAdminOrigin(value: string): URL | null {
try {
const url = new URL(value);
const isLocalhost = url.hostname === "localhost" || url.hostname.endsWith(".localhost");
if (
(url.protocol !== "https:"
&& !(isLocalhost && url.protocol === "http:"))
|| url.username
|| url.password
|| url.pathname !== "/"
|| url.search
|| url.hash
) {
return null;
}
return url;
} catch {
return null;
}
}
export function isTrustedAdminMutationRequest(
request: Request,
adminOriginValue?: string,
): boolean {
const origin = request.headers.get("origin");
const configuredValue = adminOriginValue?.trim();
if (!configuredValue) {
return isSameOriginAdminMutation(origin, request.url);
}
const adminOrigin = configuredAdminOrigin(configuredValue);
if (!adminOrigin || origin !== adminOrigin.origin) return false;
const hasForwardedHost = request.headers.has("x-forwarded-host");
const hasForwardedProto = request.headers.has("x-forwarded-proto");
if (!hasForwardedHost && !hasForwardedProto) {
return isSameOriginAdminMutation(origin, request.url);
}
const forwardedHostValue = request.headers.get("x-forwarded-host");
const forwardedProtoValue = request.headers.get("x-forwarded-proto");
const host = normalizeIdentityHost(request.headers.get("host"));
const forwardedHost = normalizeIdentityHost(forwardedHostValue);
const forwardedProto = singleForwardedValue(forwardedProtoValue)?.toLowerCase();
return Boolean(
host
&& forwardedHost
&& forwardedProto
&& host === forwardedHost
&& forwardedHost === adminOrigin.host.toLowerCase()
&& `${forwardedProto}:` === adminOrigin.protocol,
);
}
export function resolveAdminMfaStatus(
required: boolean,
enrolled: boolean,
+2 -2
View File
@@ -10,7 +10,7 @@ import {
import {
ADMIN_MFA_PROOF_COOKIE,
HIGH_RISK_ADMIN_PROOF_COOKIE,
isSameOriginAdminMutation,
isTrustedAdminMutationRequest,
resolveAdminMfaStatus,
verifyAdminMfaProof,
verifyHighRiskAdminProof,
@@ -43,7 +43,7 @@ export function requestId(request: Request): string {
}
export async function requireAdminMutation(request: Request, permission: AdminPermission) {
if (!isSameOriginAdminMutation(request.headers.get("origin"), request.url)) {
if (!isTrustedAdminMutationRequest(request, process.env.ADMIN_USER_ORIGIN)) {
throw new AdminAuthorizationError("请求来源不可信", 403);
}
return requirePermission(permission, request.headers);
+2 -2
View File
@@ -9,7 +9,7 @@ export interface IdentityAuthHandlers {
POST: IdentityRequestHandler;
}
function normalizeHost(value: string | null): string | null {
export function normalizeIdentityHost(value: string | null): string | null {
if (!value || value !== value.trim() || /[\s,@/\\]/.test(value)) return null;
try {
@@ -33,7 +33,7 @@ export function resolveIdentitySurface(
hostHeader: string | null,
config: SelfHostedIdentityConfig,
): "user" | "admin" | null {
const host = normalizeHost(hostHeader);
const host = normalizeIdentityHost(hostHeader);
if (!host) return null;
if (host === new URL(config.userOrigin).host.toLowerCase()) return "user";