fix(audit): inferAuditFields 下标越界导致进程级 panic(4 段路径全部 500)
=== 现象(线上实际日志)===
http: panic serving 172.19.16.138:35256: runtime error: index out of range [4] with length 4
lokistack/middleware.inferAuditFields(...)
lokistack/middleware/audit.go:179
=== 根因 ===
segments := strings.Split(strings.Trim(path, "/"), "/")
if len(segments) >= 4 {
resource = segments[3]
resourceID = segments[4] // ← 4 段时越界
}
/api/v1/hosts/2 正好拆成 4 段 [api v1 hosts 2],取 segments[4] 直接 panic。
影响面比想象大:**所有 4 段路径的写请求**(PUT/DELETE /hosts/:id、
/storage/:id、/clusters/:id 等)都 500,且中间件 panic 后审计也写不进去
—— 等于审计功能在最常用的更新/删除接口上完全失效。
=== 修法 ===
每个下标单独判断长度:
if len(segments) >= 3 { module = segments[2] }
if len(segments) >= 4 { resource = segments[3] }
if len(segments) >= 5 { resourceID = segments[4] }
=== 回归测试(middleware/audit_test.go)===
TestInferAuditFieldsNoPanic : 0/1/2/3/4/5 段的 12 种路径 × 3 种方法,
全部不得 panic(原 bug 的 4 段路径在列)
TestInferAuditFieldsFourSegments : 4 段时 module/resource 正确、resourceID 为空
TestInferAuditFieldsFiveSegments : 5 段时能解出 resourceID
TestInferAuditFieldsSemanticActions: deploy/stop/install/uninstall/reset-password/
validate/test 语义动作不被位置解析覆盖
TestShouldAudit : 只读接口跳过、部署安装类必须记
=== 顺带修:CLI 模式下的两处误报 ===
1) backup 的 manifest.recordCount 恒为 0
原因:CLI(CronJob 里跑 lokistack backup)没 init DB,GetDB() 返回 nil。
改为 countRecords() 直接从备份出的字节数集合。
2) self-check 报「数据库: 未初始化」
同上原因,误报会让巡检脚本以为库坏了。
改为 CLI 模式直接解析数据文件统计(fileDBStats)。
=== 实测(修复后)===
修复前:POST /api/v1/hosts/2/validate → panic,无审计记录
修复后:POST /api/v1/hosts/2/validate → 200
audit-logs total=5,含
[hosts/validate] admin POST /api/v1/hosts/2/validate -> 200
[auth/login] POST /api/v1/auth/login -> 200
panic serving 计数 = 0
用户数据完好(initial-admin-password.txt 不存在,说明不是首次部署)
镜像:lghost/lokistack:latest + v0.1 = 2493a288758d
This commit is contained in:
1 parent
c44ffad61f
commit
b2c82da377
4 files changed
+179
-10
No files matched your search
@@ -171,11 +171,19 @@ func inferAuditFields(method, path string) (module, action, resource, resourceID
|
||||
|
||||
segments := strings.Split(strings.Trim(path, "/"), "/")
|
||||
// /api/v1/<module>/<resource>/<id>
|
||||
//
|
||||
// ⚠️ 每个下标必须单独判断长度。曾经写成:
|
||||
// if len(segments) >= 4 { resource = segments[3]; resourceID = segments[4] }
|
||||
// 而 /api/v1/hosts/2 正好只有 4 段([api v1 hosts 2]),
|
||||
// segments[4] 直接 index out of range → 中间件 panic →
|
||||
// 所有「4 段路径」的写请求 500 且不落审计(线上实际发生过)。
|
||||
if len(segments) >= 3 {
|
||||
module = segments[2]
|
||||
}
|
||||
if len(segments) >= 4 {
|
||||
resource = segments[3]
|
||||
}
|
||||
if len(segments) >= 5 {
|
||||
resourceID = segments[4]
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 回归:inferAuditFields 不能越界。
|
||||
//
|
||||
// 线上事故:/api/v1/hosts/2 拆出 4 段(api v1 hosts 2),
|
||||
// 旧实现 `if len>=4 { resource=segments[3]; resourceID=segments[4] }`
|
||||
// 直接 index out of range [4] with length 4 → 中间件 panic →
|
||||
// 该路径上的所有写请求 500 且不落审计。
|
||||
// 这类 bug 编译期发现不了,必须用表驱动测试把各段数都覆盖一遍。
|
||||
func TestInferAuditFieldsNoPanic(t *testing.T) {
|
||||
cases := []string{
|
||||
"/api/v1", // 2 段
|
||||
"/api/v1/hosts", // 3 段
|
||||
"/api/v1/hosts/2", // 4 段 ← 原 bug 触发点
|
||||
"/api/v1/hosts/2/validate", // 5 段
|
||||
"/api/v1/deployments/1/deploy", // 5 段
|
||||
"/api/v1/logs/policy-groups/3", // 5 段
|
||||
"/api/v1/collectors/2/install", // 5 段
|
||||
"/", // 1 段
|
||||
"", // 0 段
|
||||
}
|
||||
for _, path := range cases {
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("inferAuditFields(%q) panic: %v", path, r)
|
||||
}
|
||||
}()
|
||||
inferAuditFields(http.MethodPost, path)
|
||||
inferAuditFields(http.MethodPut, path)
|
||||
inferAuditFields(http.MethodDelete, path)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// 4 段路径必须能解析出 module 与 resource,且 resourceID 为空(不是越界值)
|
||||
func TestInferAuditFieldsFourSegments(t *testing.T) {
|
||||
module, _, resource, resourceID := inferAuditFields(http.MethodPut, "/api/v1/hosts/2")
|
||||
if module != "hosts" {
|
||||
t.Fatalf("module = %q,期望 hosts", module)
|
||||
}
|
||||
if resource != "2" {
|
||||
// 4 段时第 4 段按位置是 resource
|
||||
t.Fatalf("resource = %q,期望 2", resource)
|
||||
}
|
||||
if resourceID != "" {
|
||||
t.Fatalf("4 段路径不应解析出 resourceID,实际 %q", resourceID)
|
||||
}
|
||||
}
|
||||
|
||||
// 5 段路径要正确解出 resourceID
|
||||
func TestInferAuditFieldsFiveSegments(t *testing.T) {
|
||||
module, action, resource, resourceID := inferAuditFields(http.MethodPost, "/api/v1/logs/sources/7")
|
||||
if module != "logs" || resource != "sources" || resourceID != "7" {
|
||||
t.Fatalf("解析错误: module=%q resource=%q resourceID=%q", module, resource, resourceID)
|
||||
}
|
||||
if action != "create" {
|
||||
t.Fatalf("action = %q,期望 create", action)
|
||||
}
|
||||
}
|
||||
|
||||
// 语义化动作不能被位置解析覆盖
|
||||
func TestInferAuditFieldsSemanticActions(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"/api/v1/deployments/1/deploy": "deploy",
|
||||
"/api/v1/deployments/1/stop": "stop",
|
||||
"/api/v1/collectors/2/install": "install",
|
||||
"/api/v1/collectors/2/uninstall": "uninstall",
|
||||
"/api/v1/users/3/reset-password": "reset-password",
|
||||
"/api/v1/hosts/2/validate": "validate",
|
||||
"/api/v1/storage/1/test": "test",
|
||||
}
|
||||
for path, want := range cases {
|
||||
if _, action, _, _ := inferAuditFields(http.MethodPost, path); action != want {
|
||||
t.Fatalf("%s action = %q,期望 %q", path, action, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// shouldAudit:只读接口不记,但部署/安装类动作必须记
|
||||
func TestShouldAudit(t *testing.T) {
|
||||
mustAudit := []string{
|
||||
"/api/v1/deployments/1/deploy",
|
||||
"/api/v1/collectors/2/install",
|
||||
"/api/v1/hosts/2/validate",
|
||||
"/api/v1/storage",
|
||||
}
|
||||
for _, p := range mustAudit {
|
||||
if !shouldAudit(p) {
|
||||
t.Fatalf("%q 应当被审计", p)
|
||||
}
|
||||
}
|
||||
mustSkip := []string{
|
||||
"/api/v1/logs/query",
|
||||
"/api/v1/logs/stats",
|
||||
"/api/v1/health",
|
||||
"/api/v1/metrics",
|
||||
}
|
||||
for _, p := range mustSkip {
|
||||
if shouldAudit(p) {
|
||||
t.Fatalf("%q 不应被审计(只读/噪音)", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,15 +61,11 @@ func CreateBackup(w io.Writer) (*BackupManifest, error) {
|
||||
}
|
||||
sum := sha256.Sum256(dbBytes)
|
||||
|
||||
db := repository.GetDB()
|
||||
recordCount := 0
|
||||
if db != nil {
|
||||
db.RLock()
|
||||
recordCount = len(db.Users) + len(db.Clusters) + len(db.Hosts) + len(db.Storages) +
|
||||
len(db.Deployments) + len(db.Collectors) + len(db.LogSources) +
|
||||
len(db.AlertRules) + len(db.NotifyChannels) + len(db.LogPolicyGroups) + len(db.AuditLogs)
|
||||
db.RUnlock()
|
||||
}
|
||||
// 记录数从**备份出来的字节**里数,不用 repository.GetDB():
|
||||
// CLI 模式(lokistack backup,跑在 CronJob 里)根本没初始化 DB,
|
||||
// 用 GetDB() 会得到 nil,manifest 里的 recordCount 恒为 0,
|
||||
// 恢复时就看不出"这份备份里有没有东西"。
|
||||
recordCount := countRecords(dbBytes)
|
||||
|
||||
manifest := &BackupManifest{
|
||||
Version: "2.0.0",
|
||||
@@ -249,6 +245,22 @@ func ValidateAndStageBackup(r io.Reader) (*RestoreResult, error) {
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// countRecords 从数据库 JSON 字节里统计各集合的记录总数。
|
||||
func countRecords(dbBytes []byte) int {
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(dbBytes, &raw); err != nil {
|
||||
return 0
|
||||
}
|
||||
total := 0
|
||||
for _, v := range raw {
|
||||
var arr []json.RawMessage
|
||||
if err := json.Unmarshal(v, &arr); err == nil {
|
||||
total += len(arr)
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// encryptionKeyFingerprint 返回当前 ENCRYPTION_KEY 的指纹(sha256 前 16 位),不泄露密钥。
|
||||
func encryptionKeyFingerprint() string {
|
||||
key := os.Getenv("ENCRYPTION_KEY")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -50,8 +51,25 @@ func SelfCheck() SelfCheckResult {
|
||||
db := repository.GetDB()
|
||||
|
||||
// 1) 数据库
|
||||
//
|
||||
// 注意:CLI 模式(lokistack self-check,CronJob/运维脚本用)不会 init DB,
|
||||
// repository.GetDB() 返回 nil。此时不能报"未初始化"——那是误报,
|
||||
// 改为直接解析数据文件统计,让 CLI 与 API 结论一致。
|
||||
if db == nil {
|
||||
add(CheckItem{Name: "数据库", Status: "error", Message: "未初始化"})
|
||||
dbPath := databasePath()
|
||||
fi, err := os.Stat(dbPath)
|
||||
if err != nil {
|
||||
add(CheckItem{Name: "数据库", Status: "error", Message: "数据文件读不到", Detail: err.Error()})
|
||||
} else {
|
||||
stats := fileDBStats(dbPath)
|
||||
status := "ok"
|
||||
if fi.Size() > 16<<20 {
|
||||
status = "warn"
|
||||
}
|
||||
add(CheckItem{Name: "数据库", Status: status,
|
||||
Message: fmt.Sprintf("%.1f KB(CLI 模式读取)", float64(fi.Size())/1024),
|
||||
Detail: stats})
|
||||
}
|
||||
} else {
|
||||
dbPath := databasePath()
|
||||
fi, err := os.Stat(dbPath)
|
||||
@@ -223,6 +241,28 @@ func probeDeploymentReady(d *models.LokiDeployment) (bool, error) {
|
||||
return resp.StatusCode == 200 && strings.Contains(strings.ToLower(string(buf[:n])), "ready"), nil
|
||||
}
|
||||
|
||||
// fileDBStats 直接解析数据文件给出各集合记录数(CLI 模式用)。
|
||||
func fileDBStats(path string) string {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var m map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return "数据文件不是合法 JSON"
|
||||
}
|
||||
count := func(key string) int {
|
||||
var arr []json.RawMessage
|
||||
if v, ok := m[key]; ok && json.Unmarshal(v, &arr) == nil {
|
||||
return len(arr)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
return fmt.Sprintf("集群 %d / 主机 %d / 存储 %d / 部署 %d / 采集器 %d / 审计 %d",
|
||||
count("clusters"), count("hosts"), count("storages"),
|
||||
count("deployments"), count("collectors"), count("auditLogs"))
|
||||
}
|
||||
|
||||
// diskUsage 返回剩余与总空间(字节)
|
||||
func diskUsage(path string) (free, total uint64, err error) {
|
||||
var st syscall.Statfs_t
|
||||
|
||||
Reference in new issue
Block a user