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,3 +1,17 @@
|
||||
# @umt/adapters
|
||||
|
||||
外部系统适配层:qBittorrent、RSS、站点(观众 NexusPHP / 馒头 API)、TMDB、Emby 兼容服务器与文件系统。对上只暴露端口接口,凭证经 secret reference 注入,日志一律脱敏。
|
||||
外部系统适配层:qBittorrent、RSS、站点(观众 NexusPHP / 馒头 API)、TMDB、Emby 兼容服务器、浏览器服务与文件系统。对上只暴露端口接口,凭证经 secret reference 注入,日志一律脱敏(键表与规则取 `@umt/contracts` 的 `redact.ts`)。
|
||||
|
||||
包入口 `src/index.ts` 逐个 `export *` 下列子模块:
|
||||
|
||||
| 子模块 | 主要入口 | 说明 |
|
||||
|---|---|---|
|
||||
| [`qbittorrent`](src/qbittorrent/README.md) | `createQbClient` / `QbClient`、`DownloaderRegistry`、`assertSafeToDeleteData`、`mapQbState` | qB WebUI v2:提交、标签接管、暂停/恢复、带安全校验的删数据 |
|
||||
| [`rss`](src/rss/README.md) | `createRssFetcher`、`applyFeedItems`、`redactUrl`、`buildRssLinkRequest` | RSS 2.0 / RDF / Atom 抓取解析、条件请求、退避熔断、去重 |
|
||||
| [`sites`](src/sites/README.md) | `createSiteAdapter` / `getSiteAdapter`、`listSiteDefinitions`、`siteFetch` | 观众(NexusPHP 配置驱动 spider)与馒头 M-Team API |
|
||||
| [`tmdb`](src/tmdb/README.md) | `createTmdbClient` / `TmdbClient`、`matchTmdb`、`matchTmdbMulti`、`TmdbError` | 多语言搜索、详情、季集;缓存限流;错误信息不带 api_key |
|
||||
| [`emby`](src/emby/README.md) | `createEmbyClient` / `EmbyClient`、`MediaServerRegistry` | Emby / Jellyfin 库存查询,失败返回 `unknown` 而不是"不存在" |
|
||||
| [`fs`](src/fs/README.md) | `copyWithVerify`、`moveSafe`、`crossDeviceMove`、`checkRoot`、`scanIncremental`、`diskUsage`、`ffprobe` | 校验复制、跨卷移动、库根哨兵、增量扫描 |
|
||||
| [`browser`](src/browser/README.md) | `BrowserProvider` 合同、`createNoneBrowserProvider`、camofox 适配骨架、`redactBrowserUrl` | 浏览器服务接口(M1 只有接口与骨架) |
|
||||
|
||||
测试:`deno task test`(vitest,外部服务全部用 `@umt/testkit` 的 mock)。
|
||||
@@ -7,6 +7,7 @@
|
||||
* match/match_multi 的匹配链见 match.ts。
|
||||
* 调整:网络/鉴权/限流错误以 TmdbError 抛出而非返回 None,调用方不得把"查询失败"当成"没有结果"。
|
||||
*/
|
||||
import { redactUrl } from "@umt/contracts";
|
||||
import { RequestLimiter, TtlLruCache } from "./cache.ts";
|
||||
|
||||
export type TmdbErrorKind = "auth" | "network" | "timeout" | "notfound" | "rate" | "protocol";
|
||||
@@ -122,6 +123,11 @@ export class TmdbClient {
|
||||
this.fetchImpl = cfg.fetch ?? fetch;
|
||||
}
|
||||
|
||||
/** 错误信息里去掉 api_key / access token(query 参数与明文两种形态都遮) */
|
||||
private redact(text: string): string {
|
||||
return redactUrl(text, [this.cfg.apiKey, this.cfg.accessToken]);
|
||||
}
|
||||
|
||||
cacheStats() {
|
||||
return { size: this.cache.size, hits: this.cache.hits, misses: this.cache.misses, coalesced: this.cache.coalesced };
|
||||
}
|
||||
@@ -173,7 +179,12 @@ export class TmdbClient {
|
||||
const name = (e as { name?: string })?.name;
|
||||
if (name === "TimeoutError" || name === "AbortError")
|
||||
throw new TmdbError("timeout", "TMDB request timed out", { path });
|
||||
throw new TmdbError("network", `TMDB request failed: ${(e as Error)?.message}`, { path, cause: e });
|
||||
// Deno fetch 的错误信息带完整 URL(含 api_key):message 先脱敏,cause 只留脱敏后的名字与信息,不透传原对象
|
||||
const msg = this.redact(String((e as Error)?.message ?? e));
|
||||
throw new TmdbError("network", `TMDB request failed: ${msg}`, {
|
||||
path,
|
||||
cause: { name: (e as Error)?.name ?? "Error", message: msg },
|
||||
});
|
||||
}
|
||||
if (res.status === 401) {
|
||||
await res.body?.cancel();
|
||||
@@ -199,7 +210,10 @@ export class TmdbClient {
|
||||
try {
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
throw new TmdbError("protocol", "TMDB returned invalid JSON", { path, cause: e });
|
||||
throw new TmdbError("protocol", "TMDB returned invalid JSON", {
|
||||
path,
|
||||
cause: { name: (e as Error)?.name ?? "Error", message: this.redact(String((e as Error)?.message ?? e)) },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -142,6 +142,37 @@ describe("TmdbClient 缓存/限流/错误分类", () => {
|
||||
expect((await dead.details("movie", 1).catch((e) => e)).kind).toBe("network");
|
||||
});
|
||||
|
||||
it("网络/HTTP 错误的 message 与 cause 不含 api_key(验收 S3)", async () => {
|
||||
const KEY = "tmdbSecretKey0123456789abcdef";
|
||||
// 真实连接失败:Deno fetch 的错误信息带完整 URL
|
||||
const dead = createTmdbClient({ baseUrl: "http://127.0.0.1:1/3", apiKey: KEY, retries: 0 });
|
||||
const e1 = await dead.details("movie", 1).catch((x) => x);
|
||||
expect(e1.kind).toBe("network");
|
||||
// 模拟把 URL(含 api_key 参数与编码后的明文)拼进信息的 fetch 实现
|
||||
const leaky: typeof fetch = async (input) => {
|
||||
throw new TypeError(`error sending request for url (${String(input)}): key ${encodeURIComponent(KEY)}`);
|
||||
};
|
||||
const e2 = await createTmdbClient({ baseUrl: "http://tmdb.local/3", apiKey: KEY, retries: 0, fetch: leaky })
|
||||
.details("movie", 1)
|
||||
.catch((x) => x);
|
||||
expect(e2.kind).toBe("network");
|
||||
expect(e2.message).toContain("api_key=[REDACTED]");
|
||||
// HTTP 错误
|
||||
const { mock } = await setup();
|
||||
mock.fault("/3/movie/872585", { status: 503 });
|
||||
const e3 = await createTmdbClient({ baseUrl: `${mock.url}/3`, apiKey: KEY, retries: 0 })
|
||||
.details("movie", 872585)
|
||||
.catch((x) => x);
|
||||
expect(e3.kind).toBe("protocol");
|
||||
for (const e of [e1, e2, e3]) {
|
||||
expect(e).toBeInstanceOf(TmdbError);
|
||||
expect(e.message).not.toContain(KEY);
|
||||
expect(e.stack ?? "").not.toContain(KEY);
|
||||
expect(JSON.stringify(e.detail)).not.toContain(KEY);
|
||||
expect(e.detail.cause instanceof Error).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("Bearer token 鉴权可用", async () => {
|
||||
mock = await createMockTmdb({ accessToken: "v4-token" });
|
||||
const tmdb = createTmdbClient({ baseUrl: `${mock.url}/3`, accessToken: "v4-token" });
|
||||
|
||||
Reference in new issue
Block a user