fix: hide admin authorization in setup mode

This commit is contained in:
anonsaber 2026-09-25 10:05:47 +08:00
parent 8a24ec7e9e
commit 9064fc5f1d
12 changed files with 252 additions and 14 deletions

View File

@ -49,7 +49,7 @@
AI 摘要默认关闭。Telegram 用户可发送 `/ai on`(1小时)、`临时一次`、`1小时`、`今天`、`7天` 或 `直到我撤销` 明确授权,再发送 `/summary <email_id>`;授权到期不会自动续期,`/ai off` 立即撤销。未授权时仅返回邮件元数据,LLM 失败则回退为本地截断摘要。
> 缺少 `REDIS_URL` 或 `CONFIG_ENCRYPTION_KEY` 时服务进入 configuration-setup 模式,仍提供 SPA、`/api/status` 和探针;状态页会提示缺少的环境变量,不回显任何密钥,也不会伪造 Redis 持久化成功。
> 缺少 `REDIS_URL` 或 `CONFIG_ENCRYPTION_KEY` 时服务进入 configuration-setup 模式,仍提供 SPA、`/api/status` 和探针;SPA 隐藏管理会话授权区,只显示缺少的环境变量与密钥不保存、不回显说明。配置恢复后 `/api/status` ready=true 才显示授权入口。
---

View File

@ -31,6 +31,7 @@
+ 相同 App Password(各后端实例的 `JMAP` 可不同,§10.2)。
### 路由 safelist(ARCH-LB-WORKER / C-LB-SINGLE-REG-URL)
- 透传公开 `GET /api/status` 启动状态,以及管理 SPA API:`POST /api/admin/session[/revoke]`、`GET|PUT /api/config`、`PUT /api/business-config`。启动状态只包含缺少的环境变量名称;管理 API 的鉴权仍由后端执行。
- 只透传 `POST /webhook/tg`、`POST /push/jmap`、`POST /reconcile`、`GET /ready`。
- 其它路径 → **404**(不透传,避免 Worker 沦为后端任意路径的跳板)。
- method 不符 → **405**。

View File

@ -10,6 +10,7 @@ export const SAFE_ROUTES = Object.freeze([
"/",
"/assets/config.js",
"/assets/styles.css",
"/api/status",
"/api/config",
"/api/business-config",
"/api/admin/session",

View File

@ -12,6 +12,7 @@
*
* 路由模型:
* - GET /、/assets/config.js、/assets/styles.css → 内嵌 SPA 静态资源透传
* - GET /api/status → 后端配置引导状态;只返回缺失变量名称
* - GET|PUT /api/config、PUT /api/business-config、POST /api/admin/session[/revoke]
* → 透传至后端;后端校验 bootstrap 凭据或短期 admin session
* - GET /healthz → LB 聚合健康(MOD-HEALTH-AGG)
@ -40,6 +41,7 @@ const ROUTE_METHODS = Object.freeze({
"/": ["GET"],
"/assets/config.js": ["GET"],
"/assets/styles.css": ["GET"],
"/api/status": ["GET"],
"/api/config": ["GET", "PUT"],
"/api/business-config": ["PUT"],
"/api/admin/session": ["POST"],

View File

@ -44,7 +44,7 @@ test("BACKEND_ORIGINS_JSON parses, dedups, validates all entries", () => {
test("route safelist excludes /healthz and unknown paths (C-LB-SINGLE-REG-URL)", () => {
for (const r of [
"/", "/assets/config.js", "/assets/styles.css", "/ready", "/webhook/tg", "/push/jmap", "/reconcile",
"/", "/assets/config.js", "/assets/styles.css", "/api/status", "/ready", "/webhook/tg", "/push/jmap", "/reconcile",
"/api/config", "/api/business-config", "/api/admin/session", "/api/admin/session/revoke",
]) {
assert.ok(PROXIED_ROUTES.includes(r), `proxied: ${r}`);

View File

@ -80,6 +80,30 @@ test("Worker: business config PUT is proxied with the session bearer and empty 2
}
});
test("Worker: public setup status is proxied without an authorization header", async () => {
const restore = stubFetch(async (url, init) => {
assert.match(url, /^https:\/\/(a|b)\.example\/api\/status$/);
assert.equal(init.method, "GET");
assert.equal(new Headers(init.headers).has("authorization"), false);
return new Response(JSON.stringify({
ready: false,
mode: "configuration-setup",
missing: ["REDIS_URL"],
}), { status: 200, headers: { "content-type": "application/json" } });
});
try {
const res = await handleFetch(new Request("https://lb.example/api/status"), makeEnv());
assert.equal(res.status, 200);
assert.deepEqual(await res.json(), {
ready: false,
mode: "configuration-setup",
missing: ["REDIS_URL"],
});
} finally {
restore();
}
});
test("Worker: misconfigured origins => 503 fail-closed (no env leak)", async () => {
const res = await handleFetch(
new Request("https://lb.example/reconcile", { method: "POST", body: "{}" }),

View File

@ -158,6 +158,7 @@ TLS 必须由 Cloudflare 或受信任反向代理终结;代理到容器的链
# 平台公网 URL: https://bot.example.com → 反代 → 容器 127.0.0.1:8080
GET / 业务配置与运行参数管理 SPA
GET /assets/config.js SPA 脚本;GET /assets/styles.css SPA 样式
GET /api/status 公开启动状态;只返回 ready/mode/missing 环境变量名
POST /api/admin/session Bearer Redis ACL 密码;成功返回 900 秒 admin session
POST /api/admin/session/revoke Bearer admin session;成功返回 204
GET|PUT /api/config Bearer admin session 或 WORKER_TOKEN;Redis 错误返回 503
@ -338,7 +339,7 @@ Stalwart PushSub ────┼─▶ https://lb.<you>.workers.dev ← 唯
### 10.4 路由与故障转移
- **路由 safelist**(`C-LB-SINGLE-REG-URL`):Worker 只透传 `GET /`、SPA 静态资源、`GET|PUT /api/config`、`PUT /api/business-config`、`POST /api/admin/session`、`POST /api/admin/session/revoke`、`POST /webhook/tg`、`POST /push/jmap`、`POST /reconcile`、`GET /ready`;**未知路径 404、method 不符 405**,不透传至后端。所有管理 API 的 Bearer 鉴权由后端执行(`SAF-LB-PASSTHRU`)。
- **路由 safelist**(`C-LB-SINGLE-REG-URL`):Worker 只透传 `GET /`、SPA 静态资源、`GET /api/status`、`GET|PUT /api/config`、`PUT /api/business-config`、`POST /api/admin/session`、`POST /api/admin/session/revoke`、`POST /webhook/tg`、`POST /push/jmap`、`POST /reconcile`、`GET /ready`;**未知路径 404、method 不符 405**,不透传至后端。`/api/status` 只返回启动状态和缺少的环境变量名;所有管理 API 的 Bearer 鉴权由后端执行(`SAF-LB-PASSTHRU`)。
- **健康聚合(`MOD-HEALTH-AGG`)**:Worker 自行承载 `GET /healthz`,按 TTL 缓存(默认 30s,`LB_HEALTH_TTL_MS` 可调)探测各后端 `/healthz`,返回 `{status, available, total, backends:[{origin,up,status}]}`;≥1 后端 up → 200,全 down → 503。`/ready` 仍**透传**给后端,由 `GATE-READY-DEPS` 承担真实依赖探测。
- **故障转移(`proxyWithFailover`)**:每次请求最多 `min(LB_MAX_ATTEMPTS, origins.length)` 次尝试;**仅**超时(AbortError)或 5xx 触发换下一个 origin;4xx/2xx/3xx 直接返回;默认 `LB_MAX_ATTEMPTS=2`(首次 + 1 次故障转移)。
- **随机分摊**:起点 origin 按 `Math.random` 随机化,实现双活;单 origin 配置时退化为确定性。

View File

@ -439,6 +439,7 @@ stalwart-bot/
### 7.3.1 配置管理 API
- `GET /` 提供嵌入 Rust 二进制的 SPA;`/assets/config.js` 与 `/assets/styles.css` 提供页面资源。服务不在运行时读取或写入本地文件(`C-NO-LOCAL-WRITE`)。
- `GET /api/status` 公开返回 `{ "ready": boolean, "mode": "configured" | "configuration-setup", "missing": string[] }`,只列缺少的环境变量名称。缺少 `REDIS_URL` 或 `CONFIG_ENCRYPTION_KEY` 时,SPA 只显示配置引导状态与缺失变量;服务状态确认 ready=true 后才显示管理会话授权区。
- `POST /api/admin/session` 接受 `Authorization: Bearer <REDIS_URL ACL password>`,返回 `{ "session": "<opaque>", "expires_in": 900 }`;admin session 仅存 Redis 中的摘要并在 900 秒后过期。`POST /api/admin/session/revoke` 撤销当前 session,成功返回 `204`。
- 管理页面仅在 JavaScript 内存中保存 session。请求设置 `credentials: omit`、`cache: no-store`,不使用 Cookie、localStorage 或 sessionStorage。管理 API 接受有效 admin session;兼容路径也接受 `WORKER_TOKEN`。Worker 原样透传鉴权头(`SAF-LB-PASSTHRU`)。
- `GET /api/config` 与 `PUT /api/config` 只读取和写入非敏感运行参数:

View File

@ -24,7 +24,13 @@
const bootstrapInput = document.querySelector("#bootstrap-input");
const authForm = document.querySelector("#auth-form");
const intro = document.querySelector("#intro");
const authCard = document.querySelector("#auth-card");
const setupCard = document.querySelector("#setup-card");
const setupMessage = document.querySelector("#setup-message");
const setupMissing = document.querySelector("#setup-missing");
const statusRetryButton = document.querySelector("#status-retry-button");
const privacyNote = document.querySelector("#privacy-note");
const configPanel = document.querySelector("#config-panel");
const businessForm = document.querySelector("#business-form");
const businessFields = document.querySelector("#business-fields");
@ -50,6 +56,7 @@
let expiryTimer = 0;
let busyAction = "";
let runtimeLoaded = false;
let setupMode = true;
class ApiError extends Error {
constructor(status, message) {
@ -59,6 +66,7 @@
}
function showNotice(kind, message) {
notice.hidden = false;
notice.className = `notice notice-${kind}`;
notice.textContent = message;
notice.setAttribute("role", kind === "error" ? "alert" : "status");
@ -122,27 +130,71 @@
}
async function showSetupStatus() {
showSetupView("正在检查服务启动配置…");
statusRetryButton.disabled = true;
statusRetryButton.textContent = "正在检查…";
try {
const status = await request("/api/status", "GET", undefined, "");
if (status && status.ready === false) {
const missing = Array.isArray(status.missing) ? status.missing.join("、") : "启动配置";
showNotice("error", `服务处于配置引导模式。请在部署环境设置:${missing};密钥不会在页面保存或回显。`);
if (status && status.ready === true) {
setupMode = false;
setupCard.hidden = true;
notice.hidden = false;
intro.hidden = false;
authCard.hidden = false;
configPanel.hidden = true;
privacyNote.hidden = false;
showNotice("info", "使用 Redis 管理凭据创建短期管理会话。");
return;
}
const isSetup = status && status.ready === false && status.mode === "configuration-setup";
if (isSetup) {
const knownMissing = Array.isArray(status.missing)
? status.missing.filter((name) => name === "REDIS_URL" || name === "CONFIG_ENCRYPTION_KEY")
: [];
const message = knownMissing.length
? "服务处于配置引导模式。请在部署环境补齐以下启动变量:"
: "服务处于配置引导模式,但状态接口未返回缺少的启动变量名称。";
showSetupView(message, knownMissing);
return;
}
showSetupView("无法确认服务是否已完成启动配置。管理授权入口保持隐藏,请重新检查服务状态。");
} catch {
// The regular connection flow reports unavailable services without exposing details.
showSetupView("暂时无法读取服务启动状态。为保护管理凭据,授权入口保持隐藏;请确认服务可用后重新检查。");
} finally {
statusRetryButton.disabled = false;
statusRetryButton.textContent = "重新检查";
}
}
function showSetupView(message, missing = []) {
setupMode = true;
setupMessage.textContent = message;
setupMissing.textContent = missing.join("、");
setupMissing.hidden = missing.length === 0;
setupCard.hidden = false;
intro.hidden = true;
notice.hidden = true;
authCard.hidden = true;
configPanel.hidden = true;
privacyNote.hidden = true;
}
function showConfigView() {
setupCard.hidden = true;
intro.hidden = false;
privacyNote.hidden = false;
authCard.hidden = true;
configPanel.hidden = false;
}
function showAuthView() {
setupCard.hidden = true;
intro.hidden = false;
privacyNote.hidden = false;
configPanel.hidden = true;
authCard.hidden = false;
authCard.hidden = setupMode;
bootstrapInput.value = "";
bootstrapInput.focus();
if (!setupMode) bootstrapInput.focus();
}
function clearSecretFields() {
@ -490,6 +542,8 @@
showNotice("info", "已退出;管理会话和表单中的密钥已从页面内存清除。");
});
statusRetryButton.addEventListener("click", showSetupStatus);
defaultsButton.addEventListener("click", () => {
if (!runtimeLoaded || busyAction) return;
fillRuntimeFields(RUNTIME_DEFAULTS);
@ -574,7 +628,7 @@
clearForms();
clearSecretFields();
configPanel.hidden = true;
authCard.hidden = false;
authCard.hidden = setupMode;
bootstrapInput.value = "";
setBusy("");
});

137
web/config.test.mjs Normal file
View File

@ -0,0 +1,137 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import vm from "node:vm";
const html = readFileSync(new URL("./index.html", import.meta.url), "utf8");
const source = readFileSync(new URL("./config.js", import.meta.url), "utf8");
class FakeElement {
constructor(hidden = false) {
this.hidden = hidden;
this.disabled = false;
this.required = false;
this.checked = false;
this.value = "";
this.textContent = "";
this.listeners = new Map();
this.attributes = new Map();
this.classList = {
toggle() {},
add() {},
remove() {},
};
this.children = new Map();
}
addEventListener(type, callback) {
this.listeners.set(type, callback);
}
setAttribute(name, value) {
this.attributes.set(name, value);
}
querySelector(selector) {
if (!this.children.has(selector)) this.children.set(selector, new FakeElement());
return this.children.get(selector);
}
querySelectorAll() { return []; }
focus() {}
reset() {}
setCustomValidity() {}
reportValidity() { return true; }
}
async function startPage(statusResponse) {
const elements = new Map();
for (const match of html.matchAll(/<[^>]*\bid="([^"]+)"[^>]*>/gs)) {
const [, id] = match;
const hidden = /\shidden(?:\s|=|>)/.test(match[0]);
elements.set(id, new FakeElement(hidden));
}
const document = {
querySelector(selector) {
const match = selector.match(/^#(.+)$/);
if (!match || !elements.has(match[1])) throw new Error(`Missing selector: ${selector}`);
return elements.get(match[1]);
},
getElementById(id) {
if (!elements.has(id)) throw new Error(`Missing element: ${id}`);
return elements.get(id);
},
querySelectorAll() { return []; },
};
const window = {
addEventListener() {},
setTimeout() { return 1; },
clearTimeout() {},
};
const fetchCalls = [];
const fetch = async (path, options) => {
fetchCalls.push({ path, options });
if (path !== "/api/status") throw new Error(`Unexpected request: ${path}`);
if (statusResponse instanceof Error) throw statusResponse;
const httpStatus = statusResponse.httpStatus ?? 200;
return {
ok: httpStatus >= 200 && httpStatus < 300,
status: httpStatus,
text: async () => JSON.stringify(statusResponse.body),
};
};
vm.runInNewContext(source, { document, window, fetch, Headers });
await new Promise((resolve) => setImmediate(resolve));
await Promise.resolve();
return { elements, fetchCalls };
}
test("configuration-setup hides all authorization UI and lists only missing variable names", async () => {
const { elements, fetchCalls } = await startPage({
body: {
ready: false,
mode: "configuration-setup",
missing: ["REDIS_URL", "CONFIG_ENCRYPTION_KEY", "secret-value-must-not-display"],
},
});
assert.equal(elements.get("setup-card").hidden, false);
assert.equal(elements.get("intro").hidden, true);
assert.equal(elements.get("auth-card").hidden, true);
assert.equal(elements.get("config-panel").hidden, true);
assert.equal(elements.get("notice").hidden, true);
assert.equal(elements.get("privacy-note").hidden, true);
assert.equal(elements.get("setup-missing").textContent, "REDIS_URL、CONFIG_ENCRYPTION_KEY");
assert.equal(elements.get("setup-message").textContent.includes("配置引导模式"), true);
assert.equal(fetchCalls.length, 1);
assert.equal(fetchCalls[0].path, "/api/status");
assert.equal(fetchCalls[0].options.credentials, "omit");
assert.equal(fetchCalls[0].options.cache, "no-store");
assert.equal(fetchCalls[0].options.headers.has("authorization"), false);
});
test("ready status preserves the existing admin-session entry and configuration page", async () => {
const { elements } = await startPage({
body: { ready: true, mode: "configured", missing: [] },
});
assert.equal(elements.get("setup-card").hidden, true);
assert.equal(elements.get("intro").hidden, false);
assert.equal(elements.get("auth-card").hidden, false);
assert.equal(elements.get("config-panel").hidden, true);
assert.equal(elements.get("privacy-note").hidden, false);
assert.equal(elements.get("notice").hidden, false);
});
test("status errors keep the authorization form hidden until readiness is confirmed", async () => {
const { elements } = await startPage({
httpStatus: 503,
body: {},
});
assert.equal(elements.get("setup-card").hidden, false);
assert.equal(elements.get("auth-card").hidden, true);
assert.equal(elements.get("config-panel").hidden, true);
assert.match(elements.get("setup-message").textContent, /授权入口保持隐藏/);
});

View File

@ -22,17 +22,30 @@
</header>
<main class="layout">
<section class="intro" aria-labelledby="page-title">
<section id="intro" class="intro" aria-labelledby="page-title" hidden>
<p class="eyebrow">服务管理 <span>·</span> CONFIGURATION</p>
<h1 id="page-title">管理你的服务连接</h1>
<p class="intro-copy">配置 JMAP、Telegram、LLM、访问白名单和运行参数。业务配置保存在外部 Redis,成功保存后会立即热加载。</p>
</section>
<div id="notice" class="notice notice-info" role="status" aria-live="polite">
<div id="notice" class="notice notice-info" role="status" aria-live="polite" hidden>
使用 Redis 管理凭据创建短期管理会话。
</div>
<section id="auth-card" class="panel auth-panel" aria-labelledby="auth-title">
<section id="setup-card" class="panel auth-panel setup-panel" aria-labelledby="setup-title" aria-live="polite">
<div class="panel-heading">
<div class="icon-tile icon-violet" aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="M12 3 4 7v5c0 4.4 3.1 7.6 8 9 4.9-1.4 8-4.6 8-9V7l-8-4Z"/><path d="M12 8v5M12 16h.01"/></svg>
</div>
<div><p class="eyebrow">配置引导</p><h2 id="setup-title">服务启动配置</h2></div>
</div>
<p id="setup-message" class="panel-copy">正在检查服务启动配置…</p>
<p id="setup-missing" class="setup-missing" hidden></p>
<p class="setup-privacy">启动所需密钥请在部署环境中配置。密钥不会在页面保存或回显。</p>
<button id="status-retry-button" class="button button-quiet" type="button">重新检查</button>
</section>
<section id="auth-card" class="panel auth-panel" aria-labelledby="auth-title" hidden>
<div class="panel-heading">
<div class="icon-tile icon-violet" aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="M7.5 10V7.5a4.5 4.5 0 0 1 9 0V10M6 10h12v10H6z"/><path d="M12 14v2"/></svg>
@ -140,7 +153,7 @@
</section>
</section>
<aside class="privacy-note"><span class="privacy-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M12 3 5 6v5c0 4.5 2.8 8 7 10 4.2-2 7-5.5 7-10V6l-7-3Z"/><path d="m9 12 2 2 4-4"/></svg></span><p><strong>密钥不会回显或持久化到浏览器。</strong>业务配置只提交至同源 API,并由后端加密写入 Redis;本页面不读取邮箱内容、不使用本地文件或数据库。</p></aside>
<aside id="privacy-note" class="privacy-note" hidden><span class="privacy-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M12 3 5 6v5c0 4.5 2.8 8 7 10 4.2-2 7-5.5 7-10V6l-7-3Z"/><path d="m9 12 2 2 4-4"/></svg></span><p><strong>密钥不会回显或持久化到浏览器。</strong>业务配置只提交至同源 API,并由后端加密写入 Redis;本页面不读取邮箱内容、不使用本地文件或数据库。</p></aside>
</main>
<footer class="footer"><span>STALWART BOT</span><span>配置由你的服务端管理</span></footer>

View File

@ -60,6 +60,10 @@ h1 { margin-bottom: 12px; font-size: clamp(29px, 5vw, 40px); line-height: 1.16;
h2 { margin-bottom: 0; font-size: 20px; line-height: 1.3; letter-spacing: -.025em; }
h3 { margin-bottom: 4px; color: #35364e; font-size: 15px; }
.panel-copy { max-width: 760px; margin-bottom: 22px; color: var(--muted); font-size: 13px; line-height: 1.7; }
.setup-panel { max-width: 720px; }
.setup-panel .panel-copy { margin-bottom: 14px; }
.setup-missing { margin: 0 0 17px; padding: 12px 14px; border: 1px solid #f2e9c8; border-radius: 8px; background: #fffbef; color: #796132; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; }
.setup-privacy { margin: 0 0 17px; color: #858697; font-size: 12px; line-height: 1.65; }
code { padding: 2px 5px; border-radius: 4px; background: #f1f0fa; color: #534a9c; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; }
.auth-form > label, .field > label, .setting-card > label { display: block; margin-bottom: 7px; color: #47485f; font-size: 12px; font-weight: 700; }
.token-row { display: flex; gap: 10px; }