fix(jmap): trust the configured origin host so Stalwart's session redirect is followed

jmap-client refuses to follow any redirect unless the target host is on a
trust list that defaults to empty. Stalwart 307-redirects /.well-known/jmap
to /jmap/session, so connect() aborted with "Aborting redirect request to
unknown host 'mail.motofans.club'" and PUT /api/business-config failed with
a 503 carrying no usable detail.

connect_with_runtime now passes the origin host parsed from the normalized
session URL to follow_redirects, trusting that host only -- a target host the
server invents is still refused. An unparseable base yields an empty host,
which matches no target and preserves the previous fail-closed behaviour.

Verified against a real Stalwart instance both ways: the ignored smoke test
session_list_and_read_smoke now passes (session -> folders -> email list ->
read) and fails with the redirect error when the change is reverted.

docs: record the redirect trust under SAF-JMAP-URL, mark G1/D-G1-1 verified
after the real-server run, and refresh two shifted line anchors in client.rs.
This commit is contained in:
anonsaber 2026-09-29 16:30:55 +08:00
parent 80d13c0292
commit dda8e084ae
3 changed files with 45 additions and 5 deletions

View File

@ -394,7 +394,7 @@ message-weave/
> 当前 `config.rs` 从 Redis 业务配置提供上述字段(含 `TelegramConfig` / `JmapConfig` / `LlmConfig`);进程级启动变量由 `src/main.rs` 直读,不经过 `config` 层。未来若引入 TOML/figment 需另立决策;当前文档不假设配置文件存在。
> **入口鉴权密钥(`TG_WEBHOOK_SECRET`/`RECONCILE_TOKEN`)在业务配置完成后必须有效**(fail-closed,`SAF-AUTH-*`)。JMAP Push 的验证码由 Stalwart 在订阅创建后动态生成,不属于业务配置。启动引导变量缺失时服务进入配置引导模式并保持 SPA 可访问;不会因为缺少启动变量而伪造业务成功,也不会绕过入口鉴权。
> **JMAP session URL 归一化(`REQ-JMAP-SESSION-URL`/`SAF-JMAP-URL`)**:`JMAP_SESSION_URL` 既接受**服务基地址**(`https://mail.example.com`)也接受**完整 session URL**(`https://mail.example.com/.well-known/jmap`)。代码在调用 `jmap_client::Client::connect` **之前统一归一化为 origin/base**——`jmap-client` 会自动追加 `/.well-known/jmap`,故**不会出现重复路径**(如 `…/.well-known/jmap/.well-known/jmap`)。约束:**仅 HTTPS**(http 拒绝);**禁止 URL 内嵌用户名/密码**(凭据只经 `JMAP_USERNAME`/`JMAP_PASSWORD`,`C-AUTH-APP-BASIC`);拒绝危险 query。(状态:D-G1-1 **代码已实现,待真实 `cargo test -- --ignored jmap::` 验证**。)
> **JMAP session URL 归一化(`REQ-JMAP-SESSION-URL`/`SAF-JMAP-URL`)**:`JMAP_SESSION_URL` 既接受**服务基地址**(`https://mail.example.com`)也接受**完整 session URL**(`https://mail.example.com/.well-known/jmap`)。代码在调用 `jmap_client::Client::connect` **之前统一归一化为 origin/base**——`jmap-client` 会自动追加 `/.well-known/jmap`,故**不会出现重复路径**(如 `…/.well-known/jmap/.well-known/jmap`)。约束:**仅 HTTPS**(http 拒绝);**禁止 URL 内嵌用户名/密码**(凭据只经 `JMAP_USERNAME`/`JMAP_PASSWORD`,`C-AUTH-APP-BASIC`);拒绝危险 query。**重定向白名单**:`jmap-client` 默认**拒绝一切重定向**,而 Stalwart 会把 `/.well-known/jmap` 307 跳到 `/jmap/session`,因此连接时把**配置里的 origin host 自身**加入信任名单,服务端改写出的其它 host 仍被拒绝(`follow_redirects([trusted_redirect_host(base)])`)。已知上游隐患:Stalwart 的 307 `Location` 会把 `user:pass` 写进 URL,且客户端把该 URL 带凭据回传——故传输错误串**不做额外日志**。(状态:D-G1-1 **代码已实现,并已用真实 Stalwart 实例通过 `cargo test -- --ignored jmap::` 验证**。)
### 7.2 密钥管理
- **禁止**把 token/密码写入仓库或任何配置文件(本项目配置不进仓库,`ARCH-CONFIG-ENV`)。
@ -535,7 +535,7 @@ pub enum BotError {
- **入口鉴权(`SAF-AUTH-*`)**:`/reconcile`、`/webhook/tg`、`/push/jmap` 在**缺少或错误的**凭证下返回 `401`,并断言鉴权失败时**无副作用**(无 Redis 写入、无 JMAP/AI 调用);正确凭证放行。
- **健康探针(`SAF-PROBE-PUBLIC`)**:`/healthz` 返回 `200` 表示进程存活;`/ready` 以 `200/503` 表示就绪——检查配置完整性 + Redis 可达性 + 出站只读探测(`GET {jmap_origin}/.well-known/jmap` 带 Basic 认证、`GET https://api.telegram.org/bot<token>/getMe`,各 3s、并行,最坏约 3s),任一失败返回标准错误 envelope(`service_unavailable` + `Retry-After: 30`),全过返回就绪报告 JSON;响应体不含敏感信息。Uptime Kuma 按状态码(`/ready` 期望 200)监控,不受响应体变化影响。
- **渠道解耦**:领域模块(`src/domain.rs` / `src/ai.rs` / `src/worker.rs`)的公开接口不出现任何渠道 SDK 类型;渠道装配集中在 `src/channel.rs`。
- **JMAP session URL(`REQ-JMAP-SESSION-URL`/`SAF-JMAP-URL`)**:基地址与完整 `…/.well-known/jmap` 两种输入**均接受且归一化结果一致**,传给 `Client::connect` 的 URL **不含重复 `/.well-known/jmap`**;`http://` 被拒绝;**内嵌凭据(`https://user:pass@host`)被拒绝**;危险 query 被拒绝。
- **JMAP session URL(`REQ-JMAP-SESSION-URL`/`SAF-JMAP-URL`)**:基地址与完整 `…/.well-known/jmap` 两种输入**均接受且归一化结果一致**,传给 `Client::connect` 的 URL **不含重复 `/.well-known/jmap`**;`http://` 被拒绝;**内嵌凭据(`https://user:pass@host`)被拒绝**;危险 query 被拒绝;重定向仅信任配置里的 origin host(Stalwart 会 307 跳到 `/jmap/session`)。
- **JMAP 多 part 原文(`REQ-JMAP-RAW-MULTIPART`)**:`read_email` 对多 part 正文按 `text_body` 顺序拼接"有 `part_id` 且有 `bodyValue`"的部分;构造"无可用部分"用例断言返回**明确错误**(非空串)。
---
@ -595,7 +595,7 @@ pub enum BotError {
> 前置:`GATE-P0` 全项通过(`BOUND-STAGE1`)。
- 依赖 `jmap-client 0.4.2`(**已引入**,`ARCH-DEPS-STAGE1`;版本/features 以 `Cargo.toml` 为准,**禁用 WebSocket feature**);`config.rs` 复用既有 Redis 业务配置骨架(不引入独立的示例配置文件,见 `ARCH-CONFIG-ENV`)。
- `domain::jmap::client` = 真实只读 adapter(`MOD-JMAP-CLIENT`,**G1/D-G1-1 代码已实现,待真实 `cargo test -- --ignored jmap::` 验证;未实际运行前不得声称真机通过**):已实现 = 包装 `Client::connect`(Basic 认证 `C-AUTH-APP-BASIC`)、**URL 归一化**(`JMAP_SESSION_URL` 接受服务基地址或完整 `…/.well-known/jmap`,归一化为 origin/base 后传入,避免重复路径;仅 HTTPS、禁内嵌凭据与 query,`REQ-JMAP-SESSION-URL`/`SAF-JMAP-URL`)、**account 选择**:`ACCOUNT_ID` 留空取 session 主账户、显式值校验后使用(`REQ-SINGLE-ACCOUNT`)。
- `domain::jmap::client` = 真实只读 adapter(`MOD-JMAP-CLIENT`,**G1/D-G1-1 代码已实现,并已用真实 Stalwart 实例通过 `cargo test -- --ignored jmap::` 验证(session 连接 → folder 列举 → 邮件列表 → 按 id 读取)**):已实现 = 包装 `Client::connect`(Basic 认证 `C-AUTH-APP-BASIC`)、**URL 归一化**(`JMAP_SESSION_URL` 接受服务基地址或完整 `…/.well-known/jmap`,归一化为 origin/base 后传入,避免重复路径;仅 HTTPS、禁内嵌凭据与 query,`REQ-JMAP-SESSION-URL`/`SAF-JMAP-URL`)、**重定向信任**(仅信任配置里的 origin host,Stalwart 会 307 跳到 `/jmap/session`,否则 `jmap-client` 默认拒绝一切重定向)、**account 选择**:`ACCOUNT_ID` 留空取 session 主账户、显式值校验后使用(`REQ-SINGLE-ACCOUNT`)。
- `domain::jmap::JmapService::list_folders / list_emails / read_email`(query/get;`list_emails` 含 `limit` 边界;`received_at` 解析;`read_email` 多 part 拼接见 `REQ-JMAP-RAW-MULTIPART`)。
- **R1 入口鉴权已就绪**:`/reconcile`/`/webhook/tg`/`/push/jmap` 及 `/api/push/register` 鉴权(`SAF-AUTH-*`)已落地并通过单测;后续改动不得放宽或绕过鉴权。
- `#[ignore]` 真实测试(`GATE-G1-JMAP-READONLY`):经**环境变量**驱动,运行命令 `cargo test -- --ignored jmap::`(`--ignored` 是 libtest 参数,必须置于 `--` 之后);仅编译不执行用 `cargo test --no-run`(编译含 `#[ignore]` 的测试目标)。**缺环境时清晰跳过且不泄露任何密钥**;CI 默认不跑真机用例,mock 测试继续保留。**真机用例通过与否须以实际 `--ignored` 运行为准——不得在未运行的情况下声称"真机通过"。**

View File

@ -377,7 +377,7 @@ The `JMAP_*` family is exactly three names. In production the account id arrives
`accountId` request-body field (notify.rs:1122) and is held on the client as
`account_id` (src/domain/jmap/client.rs:14); the only remaining environment reads of these
four names are inside the `#[ignore]` real-server smoke test at
src/domain/jmap/client.rs:499-511, which is not part of the production configuration surface.
src/domain/jmap/client.rs:514-526, which is not part of the production configuration surface.
The single prefixed name is `TELEGRAM_CHAT_ID`. Anything else documented as
`TELEGRAM_BOT_TOKEN` or similar is a documentation error, not a supported variable.
@ -474,7 +474,7 @@ and only `script-src`, `style-src` and `connect-src` re-open a same-origin chann
Recorded here so that references elsewhere cannot be mistaken for shipped features.
- `/search` **is** implemented (`bfe0fd8`). Adapter: `search_emails` at `worker.rs:652` and
at `src/domain/jmap.rs:268`, backed by `search_emails` at `src/domain/jmap/client.rs:318`.
at `src/domain/jmap.rs:268`, backed by `search_emails` at `src/domain/jmap/client.rs:319`.
What is *not* possible: **body-level** snippets. jmap-client `0.4.2` only exposes
`emailId`/`subject`/`preview` from `SearchSnippet/get`, and its `Filter` type has no comparator syntax, so
per-part body highlight cannot be modelled through the locked crate. Search degrades to

View File

@ -47,6 +47,7 @@ impl JmapClientBackend {
let timeout = std::time::Duration::from_millis(timeout_ms.max(100));
let connect = jmap_client::client::Client::new()
.credentials((username, password))
.follow_redirects([trusted_redirect_host(&session_base)])
.connect(&session_base);
let mut client = tokio::time::timeout(timeout, connect)
.await
@ -454,6 +455,20 @@ pub(crate) fn normalize_session_url(value: &str) -> Result<String, JmapError> {
Ok(parsed.origin().ascii_serialization())
}
/// Hosts the JMAP client is allowed to follow redirects to.
///
/// jmap-client ships with an empty trust list and aborts any redirect to a host it was not
/// told to trust. Stalwart always 307-redirects `/.well-known/jmap` to `/jmap/session`, so
/// leaving the list empty makes every Stalwart deployment fail at connect with
/// "Aborting redirect request to unknown host". We trust the single origin host we were
/// handed and nothing else — the client still refuses to be pointed at a different host.
pub(crate) fn trusted_redirect_host(session_base: &str) -> String {
url::Url::parse(session_base)
.ok()
.and_then(|url| url.host_str().map(str::to_owned))
.unwrap_or_default()
}
fn select_account<'a>(
requested: Option<&str>,
primary: &'a str,
@ -545,6 +560,31 @@ mod tests {
Box::leak(owned.into_boxed_slice()).iter()
}
#[test]
fn redirect_trust_is_derived_from_the_given_origin_only() {
assert_eq!(
trusted_redirect_host("https://mail.example.com"),
"mail.example.com"
);
// normalize_session_url collapses the accepted paths to the origin, so the trust
// list stays a single host even when the caller typed the well-known path.
assert_eq!(
trusted_redirect_host(
&normalize_session_url("https://mail.example.com/.well-known/jmap").unwrap()
),
"mail.example.com"
);
// host_str() excludes the port, and jmap-client compares against host_str() too,
// so a non-standard port must not leak into the trust list.
assert_eq!(
trusted_redirect_host("https://mail.example.com:8443"),
"mail.example.com"
);
// An unparseable base yields an empty host, which matches no redirect target, so the
// client falls back to trusting nobody — the pre-existing fail-closed behaviour.
assert_eq!(trusted_redirect_host("not a url"), "");
}
#[test]
fn account_selection_supports_primary_and_validates_explicit() {
assert_eq!(