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:
1 parent
79cdd26457
commit
f48e1aac96
25 files changed
+825
-235
No files matched your search
@@ -1,16 +1,12 @@
|
||||
import { isSensitiveKey, REDACTED, redactString } from "@umt/contracts";
|
||||
import pino, { type Logger } from "pino";
|
||||
|
||||
export const REDACTED = "[REDACTED]";
|
||||
// 键表与 URL query / 请求头 / Bearer 规则与 Agent 的 redactForModel 共用一份(@umt/contracts redact.ts):
|
||||
// 键名规范化后命中 passkey / rsskey / torrent_pass / authkey / downhash / c_secure_pass / cookie / x-api-key / *token …
|
||||
// 即整体替换(数值型如 token 计数除外);其余字符串按 redactString 处理。
|
||||
export { REDACTED, redactString };
|
||||
|
||||
// 字段名包含这些词即视为敏感(数值型如 token 计数除外)
|
||||
const SENSITIVE_KEY = /passkey|token|cookie|authorization|password|secret|api[-_]?key/i;
|
||||
// URL query 中的敏感参数
|
||||
const SENSITIVE_QUERY = /([?&](?:passkey|token|access_token|api_key|apikey|auth|sign)=)[^&#\s"'<>]*/gi;
|
||||
|
||||
/** 脱敏字符串中的 URL query 参数 */
|
||||
export function redactString(s: string): string {
|
||||
return s.replace(SENSITIVE_QUERY, `$1${REDACTED}`);
|
||||
}
|
||||
const sensitive = (k: string, v: unknown) => isSensitiveKey(k) && typeof v !== "number" && v != null;
|
||||
|
||||
/** 递归脱敏:敏感字段整体替换,其余字符串做 URL query 脱敏 */
|
||||
export function redact(value: unknown, depth = 0): unknown {
|
||||
@@ -21,7 +17,7 @@ export function redact(value: unknown, depth = 0): unknown {
|
||||
if (value && typeof value === "object") {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
if (SENSITIVE_KEY.test(k) && typeof v !== "number" && v != null) out[k] = REDACTED;
|
||||
if (sensitive(k, v)) out[k] = REDACTED;
|
||||
else out[k] = redact(v, depth + 1);
|
||||
}
|
||||
return out;
|
||||
@@ -37,7 +33,7 @@ function redactError(err: Error, depth: number): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = { type: err.name, message: redactString(err.message) };
|
||||
if (err.stack) out.stack = redactString(err.stack);
|
||||
for (const [k, v] of Object.entries(err)) {
|
||||
out[k] = SENSITIVE_KEY.test(k) && typeof v !== "number" && v != null ? REDACTED : redact(v, depth + 1);
|
||||
out[k] = sensitive(k, v) ? REDACTED : redact(v, depth + 1);
|
||||
}
|
||||
if (err.cause !== undefined) out.cause = redact(err.cause, depth + 1);
|
||||
return out;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createRoute, type RouteConfig } from "@hono/zod-openapi";
|
||||
import { type Actor, type ApiScope, apiResponse, type EndpointDef } from "@umt/contracts";
|
||||
import type { Context } from "hono";
|
||||
import type { z } from "zod";
|
||||
import { type AuthEnv, requireScope } from "../../http/auth.ts";
|
||||
import { type AuthEnv, requireAdminSession, requireScope } from "../../http/auth.ts";
|
||||
import type { ModuleApp } from "../types.ts";
|
||||
|
||||
// 本模块路由封装:吃 contracts 的 EndpointDef(Api.xxx),createRoute 进 OpenAPI + requireScope + 入参校验,
|
||||
@@ -44,7 +44,11 @@ export function route<D extends EndpointDef>(
|
||||
path: def.path,
|
||||
summary: def.summary,
|
||||
tags: [opts.tag ?? "agent"],
|
||||
middleware: [requireScope(def.scope as ApiScope)] as never,
|
||||
// auth: "session" 的端点(审批申请/决定)在 scope 之外再要求控制台管理员会话,API Key 一律 403
|
||||
middleware: [
|
||||
requireScope(def.scope as ApiScope),
|
||||
...(def.auth === "session" ? [requireAdminSession()] : []),
|
||||
] as never,
|
||||
request,
|
||||
responses: {
|
||||
[status]: { description: def.summary, content: { "application/json": { schema: apiResponse(def.response) } } },
|
||||
|
||||
@@ -290,7 +290,7 @@ export function registerAgentRoutes(
|
||||
},
|
||||
{ status: 201 },
|
||||
);
|
||||
route(app, Api.approvals.update!, async ({ params, body, actorId }) => {
|
||||
route(app, Api.approvals.update, async ({ params, body, actorId }) => {
|
||||
const r = await db()
|
||||
.transaction()
|
||||
.execute(async (trx) => {
|
||||
|
||||
@@ -1,20 +1,7 @@
|
||||
import {
|
||||
AdminSetupRequest,
|
||||
CSRF_COOKIE,
|
||||
LoginRequest,
|
||||
LoginResult,
|
||||
PasswordChangeRequest,
|
||||
PasswordChangeResult,
|
||||
SESSION_COOKIE,
|
||||
SessionInfo,
|
||||
TotpAction,
|
||||
TotpActionResult,
|
||||
TotpStatus,
|
||||
} from "@umt/contracts";
|
||||
import { Api, CSRF_COOKIE, SESSION_COOKIE } from "@umt/contracts";
|
||||
import type { Context } from "hono";
|
||||
import { deleteCookie, setCookie } from "hono/cookie";
|
||||
import { z } from "zod";
|
||||
import { type AuthEnv, deriveCsrfToken, requireAdminSession } from "../../http/auth.ts";
|
||||
import { type AuthEnv, deriveCsrfToken } from "../../http/auth.ts";
|
||||
import { ApiException } from "../../http/errors.ts";
|
||||
import type { ModuleApp, ServerContext } from "../types.ts";
|
||||
import {
|
||||
@@ -38,56 +25,18 @@ import { audit, type Def, endpoint } from "./endpoint.ts";
|
||||
// 其余需要已登录;改密与 TOTP 只接受控制台 cookie 会话。
|
||||
|
||||
const tags = ["auth"];
|
||||
const OkResult = z.object({ ok: z.literal(true) });
|
||||
|
||||
// 端点定义直接取 contracts 注册表(scope 与 auth 标注只维护一份):setup/login/session 为 auth:"public",
|
||||
// password/totp 为 auth:"session"(endpoint() 据此跳过 requireScope 或追加 requireAdminSession)。
|
||||
const D = {
|
||||
setup: {
|
||||
method: "post",
|
||||
path: "/auth/setup",
|
||||
scope: "read",
|
||||
summary: "首次初始化管理员",
|
||||
body: AdminSetupRequest,
|
||||
response: SessionInfo,
|
||||
tags,
|
||||
},
|
||||
login: {
|
||||
method: "post",
|
||||
path: "/auth/login",
|
||||
scope: "read",
|
||||
summary: "管理员登录",
|
||||
body: LoginRequest,
|
||||
response: LoginResult,
|
||||
tags,
|
||||
},
|
||||
logout: { method: "post", path: "/auth/logout", scope: "read", summary: "注销当前会话", response: OkResult, tags },
|
||||
session: {
|
||||
method: "get",
|
||||
path: "/auth/session",
|
||||
scope: "read",
|
||||
summary: "当前登录态(免鉴权)",
|
||||
response: SessionInfo,
|
||||
tags,
|
||||
},
|
||||
password: {
|
||||
method: "post",
|
||||
path: "/auth/password",
|
||||
scope: "admin",
|
||||
summary: "修改密码(吊销其它会话)",
|
||||
body: PasswordChangeRequest,
|
||||
response: PasswordChangeResult,
|
||||
tags,
|
||||
},
|
||||
totpGet: { method: "get", path: "/auth/totp", scope: "admin", summary: "TOTP 状态", response: TotpStatus, tags },
|
||||
totpPost: {
|
||||
method: "post",
|
||||
path: "/auth/totp",
|
||||
scope: "admin",
|
||||
summary: "TOTP 绑定/启用/停用",
|
||||
body: TotpAction,
|
||||
response: TotpActionResult,
|
||||
tags,
|
||||
},
|
||||
} as const satisfies Record<string, Def>;
|
||||
setup: { ...Api.auth.setup, tags },
|
||||
login: { ...Api.auth.login, tags },
|
||||
logout: { ...Api.auth.logout, tags },
|
||||
session: { ...Api.auth.session, tags },
|
||||
password: { ...Api.auth.password, tags },
|
||||
totpGet: { ...Api.auth.totpStatus, tags },
|
||||
totpPost: { ...Api.auth.totp, tags },
|
||||
} satisfies Record<string, Def>;
|
||||
|
||||
const PRIVATE_IP = /^(127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|::1$|fc|fd|::ffff:(127|10|192\.168)\.)/i;
|
||||
|
||||
@@ -142,53 +91,38 @@ export function registerAuthRoutes(app: ModuleApp, ctx: ServerContext) {
|
||||
return s;
|
||||
};
|
||||
|
||||
endpoint(
|
||||
app,
|
||||
D.setup,
|
||||
async ({ body, c }) => {
|
||||
const admin = await setupAdmin(deps(), body, clientIp(c));
|
||||
const s = await startSession(c, admin.id);
|
||||
const actor = adminActor(admin);
|
||||
await audit(ctx, actor, "admin.setup", { type: "admin_user", id: admin.id }, { username: admin.username });
|
||||
ctx.logger.info({ username: admin.username }, "管理员已初始化");
|
||||
return await sessionInfo(ctx.db, actor, s.expiresAt);
|
||||
},
|
||||
{ public: true },
|
||||
);
|
||||
endpoint(app, D.setup, async ({ body, c }) => {
|
||||
const admin = await setupAdmin(deps(), body, clientIp(c));
|
||||
const s = await startSession(c, admin.id);
|
||||
const actor = adminActor(admin);
|
||||
await audit(ctx, actor, "admin.setup", { type: "admin_user", id: admin.id }, { username: admin.username });
|
||||
ctx.logger.info({ username: admin.username }, "管理员已初始化");
|
||||
return await sessionInfo(ctx.db, actor, s.expiresAt);
|
||||
});
|
||||
|
||||
endpoint(
|
||||
app,
|
||||
D.login,
|
||||
async ({ body, c }) => {
|
||||
const r = await verifyLogin(deps(), body, clientIp(c));
|
||||
if (r.status === "totp_required") return { status: "totp_required" as const };
|
||||
const s = await startSession(c, r.admin.id);
|
||||
const actor = adminActor(r.admin);
|
||||
await audit(ctx, actor, "admin.login_success", { type: "admin_session", id: s.id }, { ip: clientIp(c) });
|
||||
return { status: "ok" as const, session: await sessionInfo(ctx.db, actor, s.expiresAt) };
|
||||
},
|
||||
{ public: true },
|
||||
);
|
||||
endpoint(app, D.login, async ({ body, c }) => {
|
||||
const r = await verifyLogin(deps(), body, clientIp(c));
|
||||
if (r.status === "totp_required") return { status: "totp_required" as const };
|
||||
const s = await startSession(c, r.admin.id);
|
||||
const actor = adminActor(r.admin);
|
||||
await audit(ctx, actor, "admin.login_success", { type: "admin_session", id: s.id }, { ip: clientIp(c) });
|
||||
return { status: "ok" as const, session: await sessionInfo(ctx.db, actor, s.expiresAt) };
|
||||
});
|
||||
|
||||
endpoint(
|
||||
app,
|
||||
D.session,
|
||||
async ({ c }) => {
|
||||
const session = c.get("session");
|
||||
// CSRF cookie 丢了(被清)时顺手补发
|
||||
if (session) {
|
||||
setCookie(c, CSRF_COOKIE, deriveCsrfToken(session.token), {
|
||||
httpOnly: false,
|
||||
sameSite: "Lax",
|
||||
path: "/",
|
||||
secure: isHttps(c),
|
||||
expires: session.expiresAt,
|
||||
});
|
||||
}
|
||||
return await sessionInfo(ctx.db, c.get("actor"), session?.expiresAt ?? null);
|
||||
},
|
||||
{ public: true },
|
||||
);
|
||||
endpoint(app, D.session, async ({ c }) => {
|
||||
const session = c.get("session");
|
||||
// CSRF cookie 丢了(被清)时顺手补发
|
||||
if (session) {
|
||||
setCookie(c, CSRF_COOKIE, deriveCsrfToken(session.token), {
|
||||
httpOnly: false,
|
||||
sameSite: "Lax",
|
||||
path: "/",
|
||||
secure: isHttps(c),
|
||||
expires: session.expiresAt,
|
||||
});
|
||||
}
|
||||
return await sessionInfo(ctx.db, c.get("actor"), session?.expiresAt ?? null);
|
||||
});
|
||||
|
||||
endpoint(app, D.logout, async ({ c, actor }) => {
|
||||
const session = c.get("session");
|
||||
@@ -200,44 +134,34 @@ export function registerAuthRoutes(app: ModuleApp, ctx: ServerContext) {
|
||||
return { ok: true as const };
|
||||
});
|
||||
|
||||
endpoint(
|
||||
app,
|
||||
D.password,
|
||||
async ({ body, c, actor }) => {
|
||||
const session = c.get("session");
|
||||
if (!session) throw new ApiException("forbidden", "该操作只能在控制台登录后进行");
|
||||
const revoked = await changePassword(deps(), body, {
|
||||
adminUserId: session.adminUserId,
|
||||
sessionId: session.id,
|
||||
ip: clientIp(c),
|
||||
});
|
||||
await audit(ctx, actor, "admin.password_change", { type: "admin_user", id: session.adminUserId }, { revoked });
|
||||
return { revokedSessions: revoked };
|
||||
},
|
||||
{ middleware: [requireAdminSession()] },
|
||||
);
|
||||
endpoint(app, D.password, async ({ body, c, actor }) => {
|
||||
const session = c.get("session");
|
||||
if (!session) throw new ApiException("forbidden", "该操作只能在控制台登录后进行");
|
||||
const revoked = await changePassword(deps(), body, {
|
||||
adminUserId: session.adminUserId,
|
||||
sessionId: session.id,
|
||||
ip: clientIp(c),
|
||||
});
|
||||
await audit(ctx, actor, "admin.password_change", { type: "admin_user", id: session.adminUserId }, { revoked });
|
||||
return { revokedSessions: revoked };
|
||||
});
|
||||
|
||||
endpoint(app, D.totpGet, async () => totpStatus(await loadAdmin(ctx.db)), { middleware: [requireAdminSession()] });
|
||||
endpoint(app, D.totpGet, async () => totpStatus(await loadAdmin(ctx.db)));
|
||||
|
||||
endpoint(
|
||||
app,
|
||||
D.totpPost,
|
||||
async ({ body, actor }) => {
|
||||
const admin = await loadAdmin(ctx.db);
|
||||
if (!admin?.password_hash) throw new ApiException("precondition_failed", "管理员尚未初始化");
|
||||
if (body.action === "begin") {
|
||||
const r = await totpBegin(deps(), admin);
|
||||
return { status: totpStatus(await loadAdmin(ctx.db)), otpauthUri: r.uri, secret: r.secret };
|
||||
}
|
||||
if (body.action === "enable") {
|
||||
await totpEnable(deps(), admin, body.code);
|
||||
await audit(ctx, actor, "admin.totp_enable", { type: "admin_user", id: admin.id });
|
||||
} else {
|
||||
await totpDisable(deps(), admin, body);
|
||||
await audit(ctx, actor, "admin.totp_disable", { type: "admin_user", id: admin.id });
|
||||
}
|
||||
return { status: totpStatus(await loadAdmin(ctx.db)) };
|
||||
},
|
||||
{ middleware: [requireAdminSession()] },
|
||||
);
|
||||
endpoint(app, D.totpPost, async ({ body, actor }) => {
|
||||
const admin = await loadAdmin(ctx.db);
|
||||
if (!admin?.password_hash) throw new ApiException("precondition_failed", "管理员尚未初始化");
|
||||
if (body.action === "begin") {
|
||||
const r = await totpBegin(deps(), admin);
|
||||
return { status: totpStatus(await loadAdmin(ctx.db)), otpauthUri: r.uri, secret: r.secret };
|
||||
}
|
||||
if (body.action === "enable") {
|
||||
await totpEnable(deps(), admin, body.code);
|
||||
await audit(ctx, actor, "admin.totp_enable", { type: "admin_user", id: admin.id });
|
||||
} else {
|
||||
await totpDisable(deps(), admin, body);
|
||||
await audit(ctx, actor, "admin.totp_disable", { type: "admin_user", id: admin.id });
|
||||
}
|
||||
return { status: totpStatus(await loadAdmin(ctx.db)) };
|
||||
});
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import { type DB, type Kysely, sql } from "@umt/db";
|
||||
import type { Context, MiddlewareHandler } from "hono";
|
||||
import type { Logger } from "pino";
|
||||
import type { z } from "zod";
|
||||
import { type AuthEnv, requireScope } from "../../http/auth.ts";
|
||||
import { type AuthEnv, requireAdminSession, requireScope } from "../../http/auth.ts";
|
||||
import { ApiException } from "../../http/errors.ts";
|
||||
import type { ModuleApp } from "../types.ts";
|
||||
|
||||
@@ -39,7 +39,7 @@ export function endpoint<D extends Def>(
|
||||
extra: {
|
||||
middleware?: MiddlewareHandler<AuthEnv>[];
|
||||
status?: 200 | 201;
|
||||
/** 免鉴权端点(必须同时列在 http/auth.ts 的 PUBLIC_AUTH_PATHS),不挂 requireScope */
|
||||
/** 免鉴权端点(必须同时列在 http/auth.ts 的 PUBLIC_AUTH_PATHS),不挂 requireScope;等价于 def.auth = "public" */
|
||||
public?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
@@ -53,7 +53,11 @@ export function endpoint<D extends Def>(
|
||||
path: def.path,
|
||||
summary: def.summary,
|
||||
tags: def.tags ?? ["auth-settings"],
|
||||
middleware: [...(extra.public ? [] : [requireScope(def.scope)]), ...(extra.middleware ?? [])] as never,
|
||||
middleware: [
|
||||
...(extra.public || def.auth === "public" ? [] : [requireScope(def.scope)]),
|
||||
...(def.auth === "session" ? [requireAdminSession()] : []),
|
||||
...(extra.middleware ?? []),
|
||||
] as never,
|
||||
request,
|
||||
responses: {
|
||||
[status]: {
|
||||
|
||||
Reference in new issue
Block a user