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
+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", () => {