From 520317636467fe9a45052b7f1ebdcf8f0805924f Mon Sep 17 00:00:00 2001 From: ji ruo <79234113+RuoJi6@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:48:41 +0800 Subject: [PATCH 1/2] fix: remove application fingerprints from public responses --- README.md | 4 +- server/basic_auth.go | 2 +- server/basic_auth_test.go | 2 +- server/health_test.go | 114 ++++++++++++++++++ server/server.go | 13 +- .../app/(main)/_components/main-content.tsx | 2 +- .../settings/_components/update-card.tsx | 21 ++-- web/src/lib/api-version.test.cjs | 52 ++++++++ web/src/lib/api.ts | 5 +- 9 files changed, 195 insertions(+), 20 deletions(-) create mode 100644 server/health_test.go create mode 100644 web/src/lib/api-version.test.cjs diff --git a/README.md b/README.md index 8e67fc8..525ab6d 100644 --- a/README.md +++ b/README.md @@ -320,7 +320,9 @@ CGO_ENABLED=0 go build -tags embedui -o artex ./cmd/artex 在 **系统配置 → HTTP Basic Auth** 中填写独立的验证用户名和密码,打开「启用访问验证」并点击「保存访问验证」。默认关闭,保存后立即生效,无需重启;密码至少 8 位、最多 72 字节,用户名不能包含冒号。 -开启后,访客先完成浏览器原生账号密码验证,再进入原有 ARTEX 登录页。网页、静态资源和 API 均受保护,`/api/health` 健康检查保留公开访问;通过访问验证仍需登录 ARTEX 才能使用业务 API。公网部署请通过 HTTPS 访问。 +开启后,访客先完成浏览器原生账号密码验证,再进入原有 ARTEX 登录页。网页、静态资源和 API 均受保护,`/api/health` 健康检查保留公开访问,但仅返回 `{"ok":true}`,不暴露应用名称或版本。版本号由需要管理员 JWT 的 `/api/version` 提供,启用访问验证后该接口同时受 Basic Auth 保护。未认证请求的 Basic Auth realm 使用通用名称 `Restricted`;通过访问验证仍需登录 ARTEX 才能使用业务 API。公网部署请通过 HTTPS 访问。 + +从仍使用 `/api/health` 读取版本的旧版网页升级到此版本时,旧页面可能无法自动判断升级完成;服务重启后手动刷新页面即可。新版网页的版本显示和升级轮询均使用 `/api/version`。 配置持久化到数据库,密码仅保存 bcrypt 哈希,界面不会回显。保存时密码留空表示不修改;关闭后保留凭据,方便再次开启。独立的 HttpOnly 验证 Cookie 有效期为 12 小时,兼容现有 JWT 和 SSE;保存配置会使其他浏览器的旧验证 Cookie 失效,当前管理员浏览器保持访问。浏览器可能缓存 Basic Auth 凭据,可用新的无痕窗口检查验证弹窗。 diff --git a/server/basic_auth.go b/server/basic_auth.go index e20be2b..b02f284 100644 --- a/server/basic_auth.go +++ b/server/basic_auth.go @@ -140,7 +140,7 @@ func (s *Server) requireBasicAuth(next http.Handler) http.Handler { username, password, ok := r.BasicAuth() if !ok || len(password) > maxPasswordBytes || username != cfg.Username || bcrypt.CompareHashAndPassword([]byte(cfg.PasswordHash), []byte(password)) != nil { - w.Header().Set("WWW-Authenticate", `Basic realm="ARTEX", charset="UTF-8"`) + w.Header().Set("WWW-Authenticate", `Basic realm="Restricted", charset="UTF-8"`) writeErr(w, http.StatusUnauthorized, "请先完成 HTTP Basic Auth 验证") return } diff --git a/server/basic_auth_test.go b/server/basic_auth_test.go index 82cbd0c..8858c54 100644 --- a/server/basic_auth_test.go +++ b/server/basic_auth_test.go @@ -273,7 +273,7 @@ func TestBasicAuthGate(t *testing.T) { if w.Code != tc.want { t.Fatalf("status=%d want %d: %s", w.Code, tc.want, w.Body.String()) } - if w.Code == 401 && w.Header().Get("WWW-Authenticate") != `Basic realm="ARTEX", charset="UTF-8"` { + if w.Code == 401 && w.Header().Get("WWW-Authenticate") != `Basic realm="Restricted", charset="UTF-8"` { t.Fatal("missing browser challenge") } }) diff --git a/server/health_test.go b/server/health_test.go new file mode 100644 index 0000000..66114df --- /dev/null +++ b/server/health_test.go @@ -0,0 +1,114 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestPublicHealthHasNoApplicationFingerprint(t *testing.T) { + for _, enabled := range []bool{false, true} { + name := "basic auth disabled" + if enabled { + name = "basic auth enabled" + } + t.Run(name, func(t *testing.T) { + s, _ := basicAuthTestServer(t) + if enabled { + w := saveBasicAuthForTest(s, `{"enabled":true,"username":"entry","password":"test-pass"}`) + if w.Code != http.StatusOK { + t.Fatalf("enable gate: %d %s", w.Code, w.Body.String()) + } + } + h := s.Handler() + for _, method := range []string{http.MethodGet, http.MethodHead} { + t.Run(method, func(t *testing.T) { + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(method, "/api/health", nil)) + if w.Code != http.StatusOK || w.Header().Get("WWW-Authenticate") != "" { + t.Fatalf("public health unavailable: %d %v", w.Code, w.Header()) + } + if w.Header().Get("Cache-Control") != "no-store" { + t.Fatal("health response must not be cached") + } + var body map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil || len(body) != 1 || body["ok"] != true { + t.Fatalf("health must only expose readiness: %s (err=%v)", w.Body.String(), err) + } + }) + } + }) + } +} + +func TestVersionRequiresAuthentication(t *testing.T) { + for _, tc := range []struct { + name string + gate bool + basic bool + token string + cookie bool + want int + }{ + {name: "anonymous", want: http.StatusUnauthorized}, + {name: "invalid JWT", token: "invalid", want: http.StatusUnauthorized}, + {name: "valid JWT", token: "valid", want: http.StatusOK}, + {name: "valid JWT cookie", token: "valid", cookie: true, want: http.StatusOK}, + {name: "gate anonymous", gate: true, want: http.StatusUnauthorized}, + {name: "gate Basic Auth only", gate: true, basic: true, want: http.StatusUnauthorized}, + {name: "gate JWT only", gate: true, token: "valid", want: http.StatusUnauthorized}, + {name: "gate invalid JWT", gate: true, basic: true, token: "invalid", want: http.StatusUnauthorized}, + {name: "gate both credentials", gate: true, basic: true, token: "valid", cookie: true, want: http.StatusOK}, + } { + t.Run(tc.name, func(t *testing.T) { + s, _ := basicAuthTestServer(t) + if tc.gate { + w := saveBasicAuthForTest(s, `{"enabled":true,"username":"entry","password":"test-pass"}`) + if w.Code != http.StatusOK { + t.Fatalf("enable gate: %d %s", w.Code, w.Body.String()) + } + } + r := httptest.NewRequest(http.MethodGet, "/api/version", nil) + if tc.basic { + r.SetBasicAuth("entry", "test-pass") + } + token := tc.token + if token == "valid" { + var err error + token, err = signJWT(s.jwtKey) + if err != nil { + t.Fatal(err) + } + } + if token != "" { + if tc.cookie { + r.AddCookie(&http.Cookie{Name: "artex_token", Value: token}) + } else { + r.Header.Set("Authorization", "Bearer "+token) + } + } + w := httptest.NewRecorder() + s.Handler().ServeHTTP(w, r) + if w.Code != tc.want { + t.Fatalf("status=%d want=%d: %s", w.Code, tc.want, w.Body.String()) + } + var body map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if tc.want != http.StatusOK { + if _, ok := body["version"]; ok { + t.Fatalf("unauthorized request exposed version: %s", w.Body.String()) + } + return + } + if len(body) != 1 || body["version"] != BuildVersion { + t.Fatalf("unexpected authenticated version: %s", w.Body.String()) + } + if w.Header().Get("Cache-Control") != "no-store" { + t.Fatal("version response must not be cached") + } + }) + } +} diff --git a/server/server.go b/server/server.go index 0c8e32a..41ef3a6 100644 --- a/server/server.go +++ b/server/server.go @@ -34,7 +34,7 @@ import ( // BuildVersion is the backend application version, injected from cmd/artex at // startup (which in turn gets it from -ldflags "-X main.version="). -// Defaults to "dev" for local builds. Exposed to the frontend via GET /api/health. +// Defaults to "dev" for local builds. Exposed via the authenticated GET /api/version. var BuildVersion = "dev" // Server exposes the ARTEX backend over a JSON HTTP API for the shadcn/ui @@ -803,6 +803,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("PUT /api/settings/basic-auth", s.saveBasicAuthSettings) mux.HandleFunc("GET /api/health", s.health) + mux.HandleFunc("GET /api/version", s.version) mux.HandleFunc("GET /api/stats", s.stats) mux.HandleFunc("GET /api/system/metrics", s.systemMetrics) mux.HandleFunc("GET /api/logs", s.getLogs) @@ -1149,8 +1150,16 @@ func (s *Server) Handler() http.Handler { // --- handlers --- +// health stays public for probes and must not expose application fingerprints. func (s *Server) health(w http.ResponseWriter, r *http.Request) { - writeJSON(w, 200, map[string]any{"ok": true, "service": "artex", "version": BuildVersion}) + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, 200, map[string]any{"ok": true}) +} + +// version is protected by the normal JWT middleware and optional Basic Auth gate. +func (s *Server) version(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, 200, map[string]any{"version": BuildVersion}) } func (s *Server) stats(w http.ResponseWriter, r *http.Request) { diff --git a/web/src/app/(main)/_components/main-content.tsx b/web/src/app/(main)/_components/main-content.tsx index d6951cf..4b9d453 100644 --- a/web/src/app/(main)/_components/main-content.tsx +++ b/web/src/app/(main)/_components/main-content.tsx @@ -48,7 +48,7 @@ function MainContentInner({ children }: { children: ReactNode }) { useEffect(() => { api - .health() + .version() .then((h) => setVersion((h.version ?? "").replace(/^v(?=\d)/, ""))) .catch(() => setVersion("")); }, []); diff --git a/web/src/app/(main)/system/settings/_components/update-card.tsx b/web/src/app/(main)/system/settings/_components/update-card.tsx index dc0068b..5847cc6 100644 --- a/web/src/app/(main)/system/settings/_components/update-card.tsx +++ b/web/src/app/(main)/system/settings/_components/update-card.tsx @@ -43,7 +43,7 @@ export function UpdateCard() { const [info, setInfo] = React.useState(null); const [checking, setChecking] = React.useState(true); const [progress, setProgress] = React.useState(null); - // 与 progress 分开:暂存完成后进程就没了,SSE 会断,此时要切到轮询 /api/health。 + // 与 progress 分开:暂存完成后进程就没了,SSE 会断,此时要切到轮询 /api/version。 const [restarting, setRestarting] = React.useState(false); const [busy, setBusy] = React.useState(false); @@ -116,7 +116,7 @@ export function UpdateCard() { } }; - // 轮询 /api/health 直到版本号变化。 + // 携带管理员 JWT 轮询 /api/version 直到版本号变化。 // // 判据必须是"版本变了"而不是"能连上了":换装过程中旧版本会短暂地重新起来一次 // (那一次只负责把 artex.new 换上去然后立刻退出),只看连通性会误判成功。 @@ -126,15 +126,12 @@ export function UpdateCard() { while (Date.now() < deadline) { await sleep(2000); try { - const r = await fetch("/api/health", { cache: "no-store" }); - if (r.ok) { - const j = (await r.json()) as { version?: string }; - if (j.version && j.version !== fromVersion) { - toast.success(`已更新到 ${j.version},正在重新加载页面`); - await sleep(800); - window.location.reload(); - return; - } + const { version } = await api.version(); + if (version && version !== fromVersion) { + toast.success(`已更新到 ${version},正在重新加载页面`); + await sleep(800); + window.location.reload(); + return; } } catch { // 重启窗口内连不上是预期的,继续轮询。 @@ -172,7 +169,7 @@ export function UpdateCard() { streamRef.current = es; es.onerror = () => { // 进程退出时 SSE 必然断开。如果已经进入等待重启,这属于正常现象, - // 交给 /api/health 轮询继续判定即可。 + // 交给 /api/version 轮询继续判定即可。 // EventSource 自动重连,恢复服务端保存的当前进度。 }; return es; diff --git a/web/src/lib/api-version.test.cjs b/web/src/lib/api-version.test.cjs new file mode 100644 index 0000000..1883b3a --- /dev/null +++ b/web/src/lib/api-version.test.cjs @@ -0,0 +1,52 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); +const vm = require("node:vm"); +const ts = require("typescript"); + +const compiled = ts.transpileModule(fs.readFileSync(path.join(__dirname, "api.ts"), "utf8"), { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }, +}).outputText; + +function client(fetch) { + const exports = {}; + vm.runInNewContext(compiled, { + exports, + process: { env: {} }, + URLSearchParams, + window: {}, + localStorage: { getItem: () => "test-admin-token" }, + require: (id) => { + if (id === "@/lib/mock/enabled") return { MOCK: false }; + if (id === "@/lib/mock/handler") return { mockHandle: () => assert.fail("unexpected demo request") }; + throw new Error(`Unexpected runtime import: ${id}`); + }, + fetch, + }); + return exports.api; +} + +test("version queries carry the admin token and request fresh results after restart", async () => { + const versions = ["v1.0.0", "v1.0.1"]; + const api = client(async (url, init) => { + assert.equal(url, "/api/version"); + assert.equal(init.headers.Authorization, "Bearer test-admin-token"); + assert.equal(init.cache, "no-store"); + return { ok: true, status: 200, json: async () => ({ version: versions.shift() }) }; + }); + + assert.equal((await api.version()).version, "v1.0.0"); + assert.equal((await api.version()).version, "v1.0.1"); +}); + +test("version queries can resume after a temporary restart connection failure", async () => { + let attempts = 0; + const api = client(async () => { + if (++attempts === 1) throw new TypeError("connection unavailable during restart"); + return { ok: true, status: 200, json: async () => ({ version: "v1.0.1" }) }; + }); + + await assert.rejects(api.version(), /connection unavailable/); + assert.equal((await api.version()).version, "v1.0.1"); +}); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 2651113..79c58f7 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -318,8 +318,9 @@ function schedulePayload(input: ScheduleInput) { } export const api = { - // 后端应用版本号(release 时由 ldflags 注入,默认 "dev")。 - health: () => get<{ ok: boolean; service: string; version: string }>("/health"), + // 公开健康检查不返回应用名称或版本;版本号仅向已登录管理员提供。 + health: () => get<{ ok: boolean }>("/health"), + version: () => http<{ version: string }>("/version", { cache: "no-store" }), systemMetrics: () => get("/system/metrics"), // ---- auth ---- From f053931a1df57916d5132a42f8920adf2f9aa842 Mon Sep 17 00:00:00 2001 From: ji ruo <79234113+RuoJi6@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:56:39 +0800 Subject: [PATCH 2/2] fix: require Basic Auth for health checks --- .github/workflows/release.yml | 10 +++++- README.md | 2 +- server/basic_auth.go | 7 ++-- server/basic_auth_test.go | 12 +++++-- server/health_test.go | 65 ++++++++++++++++++++++++++++------- server/server.go | 2 +- web/src/lib/api.ts | 2 +- 7 files changed, 76 insertions(+), 24 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a38b0b0..4e8620b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -246,7 +246,15 @@ jobs: trap 'docker logs artex-smoke; docker rm -f artex-smoke' EXIT curl --fail --retry 30 --retry-connrefused --retry-delay 2 --max-time 3 \ http://127.0.0.1:8787/api/health > /tmp/artex-health.json - jq -e --arg version "${RELEASE_TAG#v}" '.ok == true and .version == $version' /tmp/artex-health.json + jq -e '. == {"ok": true}' /tmp/artex-health.json + curl --fail -H 'Content-Type: application/json' \ + --data '{"password":"smoke-test-admin-password"}' \ + http://127.0.0.1:8787/api/auth/init > /tmp/artex-auth.json + token=$(jq -er '.token' /tmp/artex-auth.json) + echo "::add-mask::$token" + curl --fail -H "Authorization: Bearer $token" \ + http://127.0.0.1:8787/api/version > /tmp/artex-version.json + jq -e --arg version "${RELEASE_TAG#v}" '.version == $version' /tmp/artex-version.json curl --fail http://127.0.0.1:8787/ > /tmp/artex-index.html grep -qi ' get<{ ok: boolean }>("/health"), version: () => http<{ version: string }>("/version", { cache: "no-store" }), systemMetrics: () => get("/system/metrics"),