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
+16
View File
@@ -2489,3 +2489,19 @@
- 相关记录:BUG-145
- 复发自:无(staging bootstrap deadlock 的独立 health contract 根因)
- 修复版本:待 follow-up commit / gate
## BUG-146 | staging 后台写请求把 Caddy 上游协议误当公开来源
- 状态:resolvedlocal candidate,未 push / deploy
- 首次发现:2026-08-07
- 最近更新:2026-08-07
- 影响面:所有经 `requireAdminMutation` 的后台写接口、`admin.staging.jyotisha.chat` 模型管理写操作;普通 staging 用户域名、其他后台功能的通用 `ReasonActionModal` 与 production 未改动。
- 用户现象:管理员在独立后台域名提交 `POST /api/admin/models` 时,浏览器 `Origin` 为 HTTPS 公开后台域名,但 Caddy 转发后的 Route Handler 请求 URL 使用上游 HTTP 协议,旧校验因此返回 403 `请求来源不可信`
- 触发条件:请求经 staging Caddy `reverse_proxy web:3000` 进入 Next.js,公开 origin 与上游 `request.url` 协议不同;旧 `isSameOriginAdminMutation` 只比较这两个 origin,未核对 Caddy 提供的公开 Host/Proto 头。
- 根因:共享后台 mutation guard 把应用上游 URL 当作浏览器公开来源真值,没有结合既有 `ADMIN_USER_ORIGIN`、原始 `Host` 与 Caddy 的 `X-Forwarded-Host` / `X-Forwarded-Proto`;因此合法后台请求被拒绝,同时也不能安全地仅信任任意 forwarded host。
- 修复:`requireAdminMutation` 统一调用可测试的共享来源策略;配置 `ADMIN_USER_ORIGIN` 时要求浏览器 Origin 精确匹配、`Host` 与规范化后的 forwarded host 一致、公开 host/proto 精确匹配后台 origin,缺失、歧义、畸形或冲突的 forwarded 值全部 fail closed;未配置后台 origin 的既有直连环境继续使用严格 same-origin fallback。复用 identity host 规范化 helper,未新增同义 envstaging Caddy 继续在用户域名对 `/admin*``/api/admin/*` 返回 404。模型管理同时删除 saveProvider/saveDraft/publish/rollback 的客户端“操作原因”字段与交互,服务端分别注入固定中文审计说明后继续传给原 DB procedure 的非空 reason 参数;通用 `ReasonActionModal` 未改动。
- 验证:`npx tsx --test tests/admin-http-origin.test.ts tests/admin-model-management-ui-contract.test.ts tests/identity-host-routing.test.ts tests/admin-reauth.test.ts tests/health-deployment.test.ts`34 passed);相关 ESLint、`git diff --check` 与最终差异审查见本提交验证记录。
- 防复发:后台 mutation 来源测试必须同时覆盖合法 admin Origin + 公开 host/proto、错误 Origin、普通 staging host、Host/forwarded host 冲突、逗号多值、畸形 host、缺失 proto 与非法 `ADMIN_USER_ORIGIN`;模型管理合同必须拒绝客户端 reason,并确认四个固定审计说明仍传入现有 procedure。
- 相关记录:BUG-134、BUG-138、BUG-139
- 复发自:无
- 修复版本:本地候选提交(未 push / deploy
+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";
+1 -1
View File
@@ -225,7 +225,7 @@ test("Refine dependencies and same-origin admin data provider are present", () =
});
test("administrator writes require scoped email OTP reauthentication without mandatory MFA", () => {
assert.match(adminHttp, /isSameOriginAdminMutation/);
assert.match(adminHttp, /isTrustedAdminMutationRequest\(request, process\.env\.ADMIN_USER_ORIGIN\)/);
assert.match(administratorsRoute, /requireHighRiskAdminMutation\(request, "admin\.users\.manage_roles"\)/);
assert.match(adminHttp, /requireAdminMutation\(request, permission\)[\s\S]*verifyHighRiskAdminProof/);
assert.doesNotMatch(adminHttp, /requireAdminMfaIfRequired/);
+123
View File
@@ -0,0 +1,123 @@
import assert from "node:assert/strict";
import test from "node:test";
import { isTrustedAdminMutationRequest } from "../src/lib/admin/auth-policy.ts";
const adminOrigin = "https://admin.staging.jyotisha.chat";
const userOrigin = "https://staging.jyotisha.chat";
function request(
url: string,
headers: HeadersInit,
): Request {
return new Request(url, { method: "POST", headers });
}
test("trusted proxy admin origin accepts the configured host and protocol", () => {
const proxied = request("http://admin.staging.jyotisha.chat/api/admin/models", {
origin: adminOrigin,
host: "admin.staging.jyotisha.chat",
"x-forwarded-host": "admin.staging.jyotisha.chat",
"x-forwarded-proto": "https",
});
assert.equal(isTrustedAdminMutationRequest(proxied, adminOrigin), true);
});
test("configured admin origin rejects wrong browser origins", () => {
const proxied = request("http://admin.staging.jyotisha.chat/api/admin/models", {
origin: "https://evil.example",
host: "admin.staging.jyotisha.chat",
"x-forwarded-host": "admin.staging.jyotisha.chat",
"x-forwarded-proto": "https",
});
assert.equal(isTrustedAdminMutationRequest(proxied, adminOrigin), false);
});
test("ordinary staging host cannot call admin mutations", () => {
const userHost = request(`${userOrigin}/api/admin/models`, {
origin: userOrigin,
host: "staging.jyotisha.chat",
"x-forwarded-host": "staging.jyotisha.chat",
"x-forwarded-proto": "https",
});
const forgedForwardedHost = request(`${userOrigin}/api/admin/models`, {
origin: adminOrigin,
host: "staging.jyotisha.chat",
"x-forwarded-host": "admin.staging.jyotisha.chat",
"x-forwarded-proto": "https",
});
assert.equal(isTrustedAdminMutationRequest(userHost, adminOrigin), false);
assert.equal(isTrustedAdminMutationRequest(forgedForwardedHost, adminOrigin), false);
});
test("malformed or ambiguous forwarded origins fail closed", () => {
const cases: HeadersInit[] = [
{
origin: adminOrigin,
host: "admin.staging.jyotisha.chat",
"x-forwarded-host": "admin.staging.jyotisha.chat, evil.example",
"x-forwarded-proto": "https",
},
{
origin: adminOrigin,
host: "admin.staging.jyotisha.chat",
"x-forwarded-host": "admin.staging.jyotisha.chat",
"x-forwarded-proto": "https, http",
},
{
origin: adminOrigin,
host: "admin.staging.jyotisha.chat",
"x-forwarded-host": "admin.staging.jyotisha.chat/path",
"x-forwarded-proto": "https",
},
{
origin: adminOrigin,
host: "admin.staging.jyotisha.chat",
"x-forwarded-host": "",
"x-forwarded-proto": "https",
},
{
origin: adminOrigin,
host: "admin.staging.jyotisha.chat",
"x-forwarded-host": "admin.staging.jyotisha.chat",
},
];
for (const headers of cases) {
assert.equal(
isTrustedAdminMutationRequest(
request("http://admin.staging.jyotisha.chat/api/admin/models", headers),
adminOrigin,
),
false,
);
}
});
test("direct same-origin requests retain the legacy fallback when no admin origin is configured", () => {
const direct = request("https://admin.example/api/admin/models", {
origin: "https://admin.example",
});
assert.equal(isTrustedAdminMutationRequest(direct), true);
});
test("invalid configured admin origins fail closed", () => {
const proxied = request("http://admin.staging.jyotisha.chat/api/admin/models", {
origin: adminOrigin,
host: "admin.staging.jyotisha.chat",
"x-forwarded-host": "admin.staging.jyotisha.chat",
"x-forwarded-proto": "https",
});
for (const configured of [
"not-an-origin",
`${adminOrigin}/path`,
"ftp://admin.staging.jyotisha.chat",
"http://admin.staging.jyotisha.chat",
]) {
assert.equal(isTrustedAdminMutationRequest(proxied, configured), false);
}
});
@@ -7,6 +7,11 @@ const component = readFileSync(
"utf8",
);
const globals = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const route = readFileSync(new URL("../src/app/api/admin/models/route.ts", import.meta.url), "utf8");
const mutationHandler = readFileSync(
new URL("../src/lib/admin/model-mutation-handler.ts", import.meta.url),
"utf8",
);
test("provider form keeps codes server-owned and API keys write-only", () => {
assert.doesNotMatch(component, /name="code"|code:\s*values\.code/);
@@ -31,11 +36,22 @@ test("model discovery stays searchable with a manual provider model fallback", (
assert.match(component, /!values\.label/);
});
test("model mutations keep audit reasons without requesting email verification", () => {
assert.doesNotMatch(component, /reauthPermission=[^\n]*models\./);
assert.doesNotMatch(component, /验证并(?:保存|获取)/);
assert.match(component, /title="保存模型供应商"[\s\S]*okText="保存"[\s\S]*onSubmit=\{saveProvider\}/);
assert.match(component, /title=\{versionAction\?\.action[\s\S]*onSubmit=\{submitVersionAction\}/);
test("model mutations omit client reasons while the server keeps fixed audit reasons", () => {
assert.doesNotMatch(component, /ReasonActionModal|name="reason"|reason:\s*(?:values\.reason|reason)/);
assert.doesNotMatch(component, /reauthPermission=[^\n]*models\.|验证并(?:保存|获取)/);
assert.match(component, /open=\{providerOpen\} okText="保存"[\s\S]*onFinish=\{saveProvider\}/);
assert.doesNotMatch(component, /继续验证/);
assert.match(component, /<Modal[\s\S]*open=\{Boolean\(versionAction\)\}[\s\S]*onOk=\{\(\) => void submitVersionAction\(\)\}/);
assert.doesNotMatch(route, /reason:\s*z\.string/);
for (const reason of ["保存模型供应商", "保存模型草稿", "发布模型版本", "回滚模型版本"]) {
assert.match(route, new RegExp(reason));
}
assert.match(route, /reason:\s*modelMutationAuditReasons\[body\.data\.action\]/);
assert.match(mutationHandler, /admin_save_model_provider[\s\S]*a\.reason/);
assert.match(mutationHandler, /admin_save_model_draft[\s\S]*a\.reason/);
assert.match(mutationHandler, /admin_publish_model[\s\S]*a\.reason/);
assert.match(mutationHandler, /admin_rollback_model[\s\S]*a\.reason/);
});
test("provider rows expose a direct add-model entry with the provider preselected", () => {