fix(admin): secure proxied model mutations
This commit is contained in:
@@ -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