{"id":"CVE-2026-47726","aliases":["GHSA-qm33-p5p9-f8vg"],"title":"nebula-mesh: GET /api/v1/audit-log discloses all entries to any operator","summary":"nebula-mesh: GET /api/v1/audit-log discloses all entries to any operator","severity":"high","cwe":["CWE-285"],"vendor":"juev","product":"github.com/juev/nebula-mesh","ecosystem":"go","affected":["github.com/juev/nebula-mesh <= 0.3.1"],"patched":["github.com/juev/nebula-mesh 0.3.2"],"published":"2026-06-08","updated":"2026-06-08","source":"GHSA","sourceUrl":"https://github.com/advisories/GHSA-qm33-p5p9-f8vg","references":[{"url":"https://github.com/juev/nebula-mesh/security/advisories/GHSA-qm33-p5p9-f8vg"},{"url":"https://github.com/forgekeep/nebula-mesh/commit/8baaace54c2a23e7c351b3efab5a31ab07b125dc"},{"url":"https://github.com/forgekeep/nebula-mesh/releases/tag/v0.3.2"},{"url":"https://github.com/advisories/GHSA-qm33-p5p9-f8vg"}],"tags":["ghsa","go"],"ingestedAt":"2026-07-07T15:41:59.617Z","epss":0.00238,"epssPercentile":0.15109,"slug":"CVE-2026-47726","body":"## Overview\n\n`internal/api/audit.go:12` — `handleGetAuditLog` does no admin check. The route is bearer-auth gated only; any operator API key returns the full audit log via `store.ListAuditEntries` (up to limit=1000). This includes cross-tenant actor names, host/CA/operator IDs, action timestamps, and masked-IP entries from rate-limit refusals — enough surface for a tenant to enumerate the server's activity, infer staffing patterns, or identify high-value targets.\n\n## Affected\nAll released versions up to v0.3.1.\n\n## Reproducer\n```\ncurl -H \"Authorization: Bearer <any-operator-key>\" \\\n  https://server/api/v1/audit-log?limit=1000\n```\n\n## Suggested fix\nTwo options, either acceptable:\n\n1. `if !actorIsAdmin(ctx) { 403 }` — strictest; matches the \"operator management is admin-only\" stance.\n2. Scope to actor: filter `store.ListAuditEntries` by `actor.Username` plus a subquery of CA IDs the actor owns. Operators see their own audit entries plus entries against their CA's resources.\n\nRecommend option 1 unless the UI needs per-operator audit views.\n\n## Suggested patch\n\nVerified locally: `go vet`, `go test -race -count=1 ./...`, `golangci-lint v2.12` all clean.\n\n```diff\ndiff --git a/internal/api/audit.go b/internal/api/audit.go\nindex 3236631..57b57ce 100644\n--- a/internal/api/audit.go\n+++ b/internal/api/audit.go\n@@ -10,6 +10,10 @@ import (\n const defaultAuditLimit = 100\n \n func (s *Server) handleGetAuditLog(w http.ResponseWriter, r *http.Request) {\n+\tif !actorIsAdmin(r.Context()) {\n+\t\twriteError(w, http.StatusForbidden, \"audit log access requires the admin role\")\n+\t\treturn\n+\t}\n \tfilter := store.AuditFilter{\n \t\tAction: r.URL.Query().Get(\"action\"),\n \t\tLimit:  defaultAuditLimit,\ndiff --git a/internal/api/audit_admin_test.go b/internal/api/audit_admin_test.go\nnew file mode 100644\nindex 0000000..47e1ca4\n--- /dev/null\n+++ b/internal/api/audit_admin_test.go\n@@ -0,0 +1,62 @@\n+package api\n+\n+import (\n+\t\"context\"\n+\t\"crypto/sha256\"\n+\t\"encoding/hex\"\n+\t\"net/http\"\n+\t\"net/http/httptest\"\n+\t\"testing\"\n+\n+\t\"github.com/google/uuid\"\n+\t\"github.com/juev/nebula-mesh/internal/models\"\n+)\n+\n+// TestHandleGetAuditLog_NonAdminForbidden confirms a non-admin operator\n+// API key cannot read the audit log. The legacy config-key path stays\n+// admin and is covered by the happy-path test elsewhere.\n+func TestHandleGetAuditLog_NonAdminForbidden(t *testing.T) {\n+\tsrv, _ := newTestServer(t)\n+\n+\tnonAdminKey := uuid.New().String()\n+\tkeyHash := sha256.Sum256([]byte(nonAdminKey))\n+\tif err := srv.store.CreateOperator(context.Background(), &models.Operator{\n+\t\tID: uuid.New().String(), Username: \"non-admin\", PasswordHash: \"x\",\n+\t\tRole: \"user\", Status: models.OperatorStatusActive,\n+\t}); err != nil {\n+\t\tt.Fatal(err)\n+\t}\n+\top, err := srv.store.GetOperatorByUsername(context.Background(), \"non-admin\")\n+\tif err != nil {\n+\t\tt.Fatal(err)\n+\t}\n+\tif err := srv.store.CreateOperatorAPIKey(context.Background(), &models.OperatorAPIKey{\n+\t\tID: uuid.New().String(), OperatorID: op.ID, KeyHash: hex.EncodeToString(keyHash[:]),\n+\t}); err != nil {\n+\t\tt.Fatal(err)\n+\t}\n+\n+\treq := httptest.NewRequest(\"GET\", \"/api/v1/audit-log\", nil)\n+\treq.Header.Set(\"Authorization\", \"Bearer \"+nonAdminKey)\n+\trec := httptest.NewRecorder()\n+\tsrv.ServeHTTP(rec, req)\n+\n+\tif rec.Code != http.StatusForbidden {\n+\t\tt.Errorf(\"non-admin audit-log status = %d, want 403\", rec.Code)\n+\t}\n+}\n+\n+// TestHandleGetAuditLog_LegacyKeyAllowed confirms the legacy config-key\n+// path still reaches the handler (preserves backward compatibility).\n+func TestHandleGetAuditLog_LegacyKeyAllowed(t *testing.T) {\n+\tsrv, _ := newTestServer(t)\n+\n+\treq := httptest.NewRequest(\"GET\", \"/api/v1/audit-log\", nil)\n+\treq.Header.Set(\"Authorization\", \"Bearer \"+testAPIKey)\n+\trec := httptest.NewRecorder()\n+\tsrv.ServeHTTP(rec, req)\n+\n+\tif rec.Code == http.StatusForbidden {\n+\t\tt.Errorf(\"legacy key rejected with 403; want pass-through\")\n+\t}\n+}\n```\n\n## Affected packages\n\n- `github.com/juev/nebula-mesh <= 0.3.1`\n\n## Remediation\n\nUpgrade to a patched release:\n\n- `github.com/juev/nebula-mesh 0.3.2`","depth":"twilight","depthScore":41,"depthScoreParts":{"impact":41.3,"likelihood":0,"exploitation":0,"ransomware":0},"changes":[]}