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
+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);