fix(admin): secure proxied model mutations
This commit is contained in:
@@ -2489,3 +2489,19 @@
|
||||
- 相关记录:BUG-145
|
||||
- 复发自:无(staging bootstrap deadlock 的独立 health contract 根因)
|
||||
- 修复版本:待 follow-up commit / gate
|
||||
|
||||
## BUG-146 | staging 后台写请求把 Caddy 上游协议误当公开来源
|
||||
|
||||
- 状态:resolved(local 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,未新增同义 env,staging 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)
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user