mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-11-03 19:23:51 +08:00
Merge pull request #114 from RuoJi6/codex/hide-public-fingerprints
fix: 保护健康接口并移除未认证响应中的 ARTEX 指纹
This commit is contained in:
10 files changed
+255
-28
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,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` 的 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`。
|
||||
|
||||
配置持久化到数据库,密码仅保存 bcrypt 哈希,界面不会回显。保存时密码留空表示不修改;关闭后保留凭据,方便再次开启。独立的 HttpOnly 验证 Cookie 有效期为 12 小时,兼容现有 JWT 和 SSE;保存配置会使其他浏览器的旧验证 Cookie 失效,当前管理员浏览器保持访问。浏览器可能缓存 Basic Auth 凭据,可用新的无痕窗口检查验证弹窗。
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -140,7 +139,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
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
@@ -273,7 +279,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")
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
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)
|
||||
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, 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 {
|
||||
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())
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+11
-2
@@ -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=<tag>").
|
||||
// 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 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) {
|
||||
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) {
|
||||
|
||||
@@ -48,7 +48,7 @@ function MainContentInner({ children }: { children: ReactNode }) {
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.health()
|
||||
.version()
|
||||
.then((h) => setVersion((h.version ?? "").replace(/^v(?=\d)/, "")))
|
||||
.catch(() => setVersion(""));
|
||||
}, []);
|
||||
|
||||
@@ -43,7 +43,7 @@ export function UpdateCard() {
|
||||
const [info, setInfo] = React.useState<UpdateCheck | null>(null);
|
||||
const [checking, setChecking] = React.useState(true);
|
||||
const [progress, setProgress] = React.useState<UpdateProgress | null>(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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
+3
-2
@@ -318,8 +318,9 @@ function schedulePayload(input: ScheduleInput) {
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// 后端应用版本号(release 时由 ldflags 注入,默认 "dev")。
|
||||
health: () => get<{ ok: boolean; service: string; version: string }>("/health"),
|
||||
// 健康检查受可选 Basic Auth 保护且不返回应用名称或版本;版本号仅向已登录管理员提供。
|
||||
health: () => get<{ ok: boolean }>("/health"),
|
||||
version: () => http<{ version: string }>("/version", { cache: "no-store" }),
|
||||
systemMetrics: () => get<SystemMetrics>("/system/metrics"),
|
||||
|
||||
// ---- auth ----
|
||||
|
||||
Reference in new issue
Block a user