mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-11-12 21:53:50 +08:00
fix: require Basic Auth for health checks
This commit is contained in:
1 parent
5203176364
commit
f053931a1d
7 files changed
+76
-24
No files matched your search
@@ -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
|
||||
|
||||
@@ -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`。
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"),
|
||||
|
||||
Reference in new issue
Block a user