Files
LokiStack/backend/middleware/audit_test.go
T
opsx b2c82da377 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
2026-10-28 21:40:24 +00:00

110 lines
3.4 KiB
Go

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)
}
}
}