fix: require Basic Auth for health checks

This commit is contained in:
ji ruo committed 2026-11-03 14:56:39 +08:00
1 parent 5203176364
commit f053931a1d
7 files changed
+76 -24

No files matched your search

+9 -1
View File
@@ -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 '<!doctype html' /tmp/artex-index.html
- name: Push tested architecture image
+1 -1
View File
@@ -320,7 +320,7 @@ CGO_ENABLED=0 go build -tags embedui -o artex ./cmd/artex
在 **系统配置 → HTTP Basic Auth** 中填写独立的验证用户名和密码,打开「启用访问验证」并点击「保存访问验证」。默认关闭,保存后立即生效,无需重启;密码至少 8 位、最多 72 字节,用户名不能包含冒号。
开启后,访客先完成浏览器原生账号密码验证,再进入原有 ARTEX 登录页。网页、静态资源和 API 均受保护,`/api/health` 健康检查保留公开访问,但仅返回 `{"ok":true}`,不暴露应用名称或版本。版本号由需要管理员 JWT 的 `/api/version` 提供,启用访问验证后该接口同时受 Basic Auth 保护。未认证请求的 Basic Auth realm 使用通用名称 `Restricted`;通过访问验证仍需登录 ARTEX 才能使用业务 API。公网部署请通过 HTTPS 访问。
开启后,访客先完成浏览器原生账号密码验证,再进入原有 ARTEX 登录页。网页、静态资源和 API 均受保护,`/api/health` 的 GET/HEAD 健康检查同样需要通过 Basic Auth,未认证时返回 `401`;通过后仅返回 `{"ok":true}`,不暴露应用名称或版本。健康检查不额外要求管理员 JWT;外部探针需要配置相同的 Basic Auth 凭据,例如使用 `curl --fail --user '验证用户名' http://127.0.0.1:8787/api/health` 并按提示输入密码。未启用 Basic Auth 时保留原有的无需凭据探活行为。版本号由需要管理员 JWT 的 `/api/version` 提供,启用访问验证后该接口同时受 Basic Auth 保护。未认证请求的 Basic Auth realm 使用通用名称 `Restricted`;通过访问验证仍需登录 ARTEX 才能使用业务 API。公网部署请通过 HTTPS 访问。
从仍使用 `/api/health` 读取版本的旧版网页升级到此版本时,旧页面可能无法自动判断升级完成;服务重启后手动刷新页面即可。新版网页的版本显示和升级轮询均使用 `/api/version`。
+3 -4
View File
@@ -125,10 +125,9 @@ func (s *Server) setBasicAuthCookie(w http.ResponseWriter, r *http.Request, cfg
func (s *Server) requireBasicAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cfg := s.basicAuth.snapshot()
// Health probes stay public; CORS preflight contains no credentials and
// the API CORS handler returns 204 without invoking any business handler.
if !cfg.Enabled || (r.URL.Path == "/api/health" && (r.Method == "GET" || r.Method == "HEAD")) ||
(r.Method == "OPTIONS" && strings.HasPrefix(r.URL.Path, "/api/")) {
// CORS preflight contains no credentials; the API CORS handler returns
// 204 without invoking any business handler, including health probes.
if !cfg.Enabled || (r.Method == "OPTIONS" && strings.HasPrefix(r.URL.Path, "/api/")) {
next.ServeHTTP(w, r)
return
}
+9 -3
View File
@@ -115,7 +115,7 @@ func TestBasicAuthBlockedSaveDoesNotBlockRequests(t *testing.T) {
h := s.requireBasicAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) }))
for _, path := range []string{"/", "/_next/static/app.js", "/api/tasks", "/api/health"} {
r := httptest.NewRequest(http.MethodGet, path, nil)
if enabled && path != "/api/health" {
if enabled {
r.AddCookie(initial.Result().Cookies()[0])
}
w := httptest.NewRecorder()
@@ -126,7 +126,11 @@ func TestBasicAuthBlockedSaveDoesNotBlockRequests(t *testing.T) {
}
}
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/health", nil))
r := httptest.NewRequest(http.MethodGet, "/api/health", nil)
if enabled {
r.AddCookie(initial.Result().Cookies()[0])
}
s.Handler().ServeHTTP(w, r)
if w.Code != http.StatusOK {
reads <- errors.New("health handler unavailable during save")
return
@@ -247,7 +251,9 @@ func TestBasicAuthGate(t *testing.T) {
{name: "asset challenge", path: "/_next/static/app.js", want: 401},
{name: "auth API challenge", path: "/api/auth/status", want: 401},
{name: "check challenge", path: "/api/basic-auth/check", want: 401},
{name: "health exempt", path: "/api/health", want: 204},
{name: "health challenge", path: "/api/health", want: 401},
{name: "health credentials", path: "/api/health", user: "entry", pass: "密码:test-pass", want: 204},
{name: "health gate cookie", path: "/api/health", cookie: cookie, want: 204},
{name: "health prefix not exempt", path: "/api/health/anything", want: 401},
{name: "wrong password", path: "/login", user: "entry", pass: "wrong", want: 401},
{name: "wrong user", path: "/login", user: "wrong", pass: "密码:test-pass", want: 401},
+52 -13
View File
@@ -7,34 +7,73 @@ import (
"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) {
func TestHealthGateAndResponse(t *testing.T) {
for _, tc := range []struct {
name string
gate bool
auth string
want int
}{
{name: "gate disabled", want: http.StatusOK},
{name: "anonymous", gate: true, want: http.StatusUnauthorized},
{name: "wrong credentials", gate: true, auth: "wrong", want: http.StatusUnauthorized},
{name: "valid credentials", gate: true, auth: "basic", want: http.StatusOK},
{name: "valid gate cookie", gate: true, auth: "cookie", want: http.StatusOK},
{name: "admin JWT only", gate: true, auth: "jwt", want: http.StatusUnauthorized},
} {
t.Run(tc.name, func(t *testing.T) {
s, _ := basicAuthTestServer(t)
if enabled {
var cookie *http.Cookie
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())
}
cookie = w.Result().Cookies()[0]
}
h := s.Handler()
for _, method := range []string{http.MethodGet, http.MethodHead} {
t.Run(method, func(t *testing.T) {
r := httptest.NewRequest(method, "/api/health", nil)
switch tc.auth {
case "wrong":
r.SetBasicAuth("entry", "wrong-password")
case "basic":
r.SetBasicAuth("entry", "test-pass")
case "cookie":
r.AddCookie(cookie)
case "jwt":
token, err := signJWT(s.jwtKey)
if err != nil {
t.Fatal(err)
}
r.Header.Set("Authorization", "Bearer "+token)
}
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())
h.ServeHTTP(w, r)
if w.Code != tc.want {
t.Fatalf("status=%d want=%d: %s", w.Code, tc.want, w.Body.String())
}
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)
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if tc.want == http.StatusUnauthorized {
if w.Header().Get("WWW-Authenticate") != `Basic realm="Restricted", charset="UTF-8"` {
t.Fatal("health must require Basic Auth")
}
for _, field := range []string{"ok", "service", "version"} {
if _, exists := body[field]; exists {
t.Fatalf("unauthenticated health exposed %s: %s", field, w.Body.String())
}
}
return
}
if w.Header().Get("WWW-Authenticate") != "" || len(body) != 1 || body["ok"] != true {
t.Fatalf("unexpected health response: %v %s", w.Header(), w.Body.String())
}
})
}
+1 -1
View File
@@ -1150,7 +1150,7 @@ func (s *Server) Handler() http.Handler {
// --- handlers ---
// health stays public for probes and must not expose application fingerprints.
// health requires the optional Basic Auth gate but no admin JWT, and exposes no application fingerprints.
func (s *Server) health(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, 200, map[string]any{"ok": true})
+1 -1
View File
@@ -318,7 +318,7 @@ function schedulePayload(input: ScheduleInput) {
}
export const api = {
// 公开健康检查不返回应用名称或版本;版本号仅向已登录管理员提供。
// 健康检查受可选 Basic Auth 保护且不返回应用名称或版本;版本号仅向已登录管理员提供。
health: () => get<{ ok: boolean }>("/health"),
version: () => http<{ version: string }>("/version", { cache: "no-store" }),
systemMetrics: () => get<SystemMetrics>("/system/metrics"),