fix(security): W4-E1 approvals admin-session only, shared redact table, TMDB key redaction, prod mock gate

- POST /approvals and PATCH /approvals/{id} require a console admin session
  (EndpointDef auth:"session"); API keys incl. admin scope get 403, so an
  organize.execute key can no longer approve its own plan (S1)
- tests for single-use approval checks: expired, revision/payload hash
  mismatch, consumed, revoked, pending/rejected, plan mismatch; re-approve
  after plan change succeeds once and cannot be replayed (S4)
- one redact key table in @umt/contracts shared by server logger and agent
  redactForModel: rsskey, torrent_pass, authkey, downhash, c_secure_pass,
  passkey variants, x-api-key, cookie headers, nested objects/arrays (S2)
- TMDB network errors: message and cause redacted via redactUrl (S3)
- web Agent ?mock=1 only in DEV builds or when pi.provider is fake (S7)
- EndpointDef auth annotations for /auth/*, asserted against
  PUBLIC_AUTH_PATHS (S10); doc drift fixes (S11)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 22:24:44 +08:00
1 parent 79cdd26457
commit f48e1aac96
25 files changed
+825 -235

No files matched your search

+29 -4
View File
@@ -23,10 +23,18 @@ export const API_PREFIX = "/api/v1";
export type HttpMethod = "get" | "post" | "patch" | "put" | "delete";
/**
* 鉴权方式(不写即按 scope 校验,cookie 会话与 API Key 同样适用):
* - public:免鉴权(必须与服务端 http/auth.ts 的 PUBLIC_AUTH_PATHS 一致,测试断言);
* - session:只接受控制台管理员 cookie 会话,API Key(含 admin scope)一律 403。
*/
export type EndpointAuth = "public" | "session";
export interface EndpointDef {
method: HttpMethod;
path: string;
scope: ApiScope;
auth?: EndpointAuth;
summary: string;
params?: z.ZodObject;
query?: z.ZodType;
@@ -793,16 +801,27 @@ export const Api = {
"审批",
{
row: agent.Approval,
update: agent.ApprovalUpdate,
filters: { status: agent.ApprovalStatus.optional(), planId: Id.optional() },
},
{ read: "read", write: "organize.execute" },
R,
),
// 审批的申请与决定只在控制台管理员会话里做:API Key(哪怕持 organize.execute)不能给自己的计划开审批(验收 S1)
update: ep({
method: "patch",
path: "/approvals/{id}",
scope: "admin",
auth: "session",
summary: "批准/拒绝/撤销单次审批(只接受控制台管理员会话)",
params: IdParam,
body: agent.ApprovalUpdate,
response: agent.Approval,
}),
create: ep({
method: "post",
path: "/approvals",
scope: "organize.execute",
summary: "为计划申请单次审批(绑定 plan id + payload hash + revision + 有效期)",
scope: "admin",
auth: "session",
summary: "为计划申请单次审批(绑定 plan id + payload hash + revision + 有效期;只接受控制台管理员会话)",
body: agent.ApprovalRequest,
response: agent.Approval,
}),
@@ -1040,6 +1059,7 @@ export const Api = {
method: "post",
path: "/auth/setup",
scope: "read",
auth: "public",
summary: "首次初始化管理员",
body: auth.AdminSetupRequest,
response: auth.SessionInfo,
@@ -1048,6 +1068,7 @@ export const Api = {
method: "post",
path: "/auth/login",
scope: "read",
auth: "public",
summary: "管理员登录",
body: auth.LoginRequest,
response: auth.LoginResult,
@@ -1057,6 +1078,7 @@ export const Api = {
method: "get",
path: "/auth/session",
scope: "read",
auth: "public",
summary: "当前登录态(免鉴权)",
response: auth.SessionInfo,
}),
@@ -1064,6 +1086,7 @@ export const Api = {
method: "post",
path: "/auth/password",
scope: "admin",
auth: "session",
summary: "修改密码(吊销其它会话)",
body: auth.PasswordChangeRequest,
response: auth.PasswordChangeResult,
@@ -1072,6 +1095,7 @@ export const Api = {
method: "get",
path: "/auth/totp",
scope: "admin",
auth: "session",
summary: "TOTP 状态",
response: auth.TotpStatus,
}),
@@ -1079,6 +1103,7 @@ export const Api = {
method: "post",
path: "/auth/totp",
scope: "admin",
auth: "session",
summary: "TOTP 绑定/启用/停用",
body: auth.TotpAction,
response: auth.TotpActionResult,
+1
View File
@@ -15,5 +15,6 @@ export * from "./instances.ts";
export * from "./ops.ts";
export * from "./organization.ts";
export * from "./policy.ts";
export * from "./redact.ts";
export * from "./settings.ts";
export * from "./sources.ts";
+87
View File
@@ -0,0 +1,87 @@
/**
* 凭证脱敏的唯一键表与字符串规则(服务端 logger、Agent 模型上下文、适配器错误信息共用)。
* 覆盖 cookie / passkey / rsskey / torrent_pass / authkey / downhash / c_secure_pass / token / apiKey /
* authorization / x-api-key 头 / URL 内嵌账号密码。键名先规范化(小写、去掉 - _ 空白)再判断,
* 所以 PassKey、pass-key、pass_key、X-Api-Key 都能命中。
* 宁可多遮不可漏遮:误伤只影响可读性,漏遮会把凭证送进日志或外部模型。
*/
export const REDACTED = "[REDACTED]";
/** 规范化后的键名只要包含其中一项即视为敏感 */
export const SENSITIVE_KEY_PARTS: readonly string[] = [
"cookie",
"passkey",
"authkey",
"torrentpass",
"securepass",
"rsskey",
"downhash",
"apikey",
"authorization",
"secret",
"password",
"passwd",
"credential",
"privatekey",
];
/** 规范化键名(去掉 - _ 空白并小写)后判断。以 token 结尾算敏感,tokens(用量统计)不算。 */
export function isSensitiveKey(key: string): boolean {
const k = key.toLowerCase().replace(/[-_\s]/g, "");
if (k.endsWith("token")) return true;
if (k === "sign" || k === "signature") return true;
return SENSITIVE_KEY_PARTS.some((part) => k.includes(part));
}
/** URL 查询参数/片段中视为凭证的参数名(正则片段,大小写不敏感;- _ 可选的写成 [_-]?) */
export const SENSITIVE_QUERY_PARAMS: readonly string[] = [
"pass[_-]?key",
"auth[_-]?key",
"torrent[_-]?pass",
"rss[_-]?key",
"c[_-]?secure[_-]?pass",
"down[_-]?hash",
"api[_-]?key",
"access[_-]?token",
"refresh[_-]?token",
"token",
"auth",
"sign",
"signature",
"credential",
"secret",
"password",
"uid[_-]?key",
"key",
];
const PARAM_RE = new RegExp(`([?&;#]|\\b)(${SENSITIVE_QUERY_PARAMS.join("|")})=([^&;#\\s"'<>]+)`, "gi");
const URL_USERINFO_RE = /\b([a-z][a-z0-9+.-]*:\/\/)([^/\s:@]+):([^/\s@]+)@/gi;
const BEARER_RE = /\b(Bearer|Basic|Token)\s+[A-Za-z0-9._~+/=-]{6,}/g;
const HEADER_RE = /\b(cookie|set-cookie|authorization|x-api-key)\s*:\s*[^\r\n]+/gi;
const KNOWN_KEY_RE = /\b(sk-[A-Za-z0-9_-]{8,}|sk-ant-[A-Za-z0-9_-]{8,}|gh[pousr]_[A-Za-z0-9]{16,})\b/g;
/** 文本脱敏:请求头行、URL userinfo、query 凭证参数、Bearer/Basic、已知格式的密钥 */
export function redactString(input: string): string {
return input
.replace(HEADER_RE, (_m, name: string) => `${name}: ${REDACTED}`)
.replace(URL_USERINFO_RE, (_m, scheme: string) => `${scheme}${REDACTED}@`)
.replace(PARAM_RE, (_m, lead: string, name: string) => `${lead}${name}=${REDACTED}`)
.replace(BEARER_RE, (_m, scheme: string) => `${scheme} ${REDACTED}`)
.replace(KNOWN_KEY_RE, REDACTED);
}
/**
* URL(或含 URL 的错误信息)脱敏。`secrets` 为调用方已知的明文凭证,出现在任何位置都会被替换
* (兜底 fetch 实现把 URL 编码后再拼进错误信息之类的情况)。
*/
export function redactUrl(input: string, secrets: readonly (string | undefined | null)[] = []): string {
let out = redactString(input);
for (const s of secrets) {
if (!s || s.length < 4) continue;
out = out.split(s).join(REDACTED);
const enc = encodeURIComponent(s);
if (enc !== s) out = out.split(enc).join(REDACTED);
}
return out;
}