GHSA-QM33-P5P9-F8VG

Vulnerability from github – Published: 2026-06-08 23:35 – Updated: 2026-06-08 23:35
VLAI
Summary
nebula-mesh: GET /api/v1/audit-log discloses all entries to any operator
Details

internal/api/audit.go:12handleGetAuditLog 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.

Affected

All released versions up to v0.3.1.

Reproducer

curl -H "Authorization: Bearer <any-operator-key>" \
  https://server/api/v1/audit-log?limit=1000

Suggested fix

Two options, either acceptable:

  1. if !actorIsAdmin(ctx) { 403 } — strictest; matches the "operator management is admin-only" stance.
  2. 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.

Recommend option 1 unless the UI needs per-operator audit views.

Suggested patch

Verified locally: go vet, go test -race -count=1 ./..., golangci-lint v2.12 all clean.

diff --git a/internal/api/audit.go b/internal/api/audit.go
index 3236631..57b57ce 100644
--- a/internal/api/audit.go
+++ b/internal/api/audit.go
@@ -10,6 +10,10 @@ import (
 const defaultAuditLimit = 100

 func (s *Server) handleGetAuditLog(w http.ResponseWriter, r *http.Request) {
+   if !actorIsAdmin(r.Context()) {
+       writeError(w, http.StatusForbidden, "audit log access requires the admin role")
+       return
+   }
    filter := store.AuditFilter{
        Action: r.URL.Query().Get("action"),
        Limit:  defaultAuditLimit,
diff --git a/internal/api/audit_admin_test.go b/internal/api/audit_admin_test.go
new file mode 100644
index 0000000..47e1ca4
--- /dev/null
+++ b/internal/api/audit_admin_test.go
@@ -0,0 +1,62 @@
+package api
+
+import (
+   "context"
+   "crypto/sha256"
+   "encoding/hex"
+   "net/http"
+   "net/http/httptest"
+   "testing"
+
+   "github.com/google/uuid"
+   "github.com/juev/nebula-mesh/internal/models"
+)
+
+// TestHandleGetAuditLog_NonAdminForbidden confirms a non-admin operator
+// API key cannot read the audit log. The legacy config-key path stays
+// admin and is covered by the happy-path test elsewhere.
+func TestHandleGetAuditLog_NonAdminForbidden(t *testing.T) {
+   srv, _ := newTestServer(t)
+
+   nonAdminKey := uuid.New().String()
+   keyHash := sha256.Sum256([]byte(nonAdminKey))
+   if err := srv.store.CreateOperator(context.Background(), &models.Operator{
+       ID: uuid.New().String(), Username: "non-admin", PasswordHash: "x",
+       Role: "user", Status: models.OperatorStatusActive,
+   }); err != nil {
+       t.Fatal(err)
+   }
+   op, err := srv.store.GetOperatorByUsername(context.Background(), "non-admin")
+   if err != nil {
+       t.Fatal(err)
+   }
+   if err := srv.store.CreateOperatorAPIKey(context.Background(), &models.OperatorAPIKey{
+       ID: uuid.New().String(), OperatorID: op.ID, KeyHash: hex.EncodeToString(keyHash[:]),
+   }); err != nil {
+       t.Fatal(err)
+   }
+
+   req := httptest.NewRequest("GET", "/api/v1/audit-log", nil)
+   req.Header.Set("Authorization", "Bearer "+nonAdminKey)
+   rec := httptest.NewRecorder()
+   srv.ServeHTTP(rec, req)
+
+   if rec.Code != http.StatusForbidden {
+       t.Errorf("non-admin audit-log status = %d, want 403", rec.Code)
+   }
+}
+
+// TestHandleGetAuditLog_LegacyKeyAllowed confirms the legacy config-key
+// path still reaches the handler (preserves backward compatibility).
+func TestHandleGetAuditLog_LegacyKeyAllowed(t *testing.T) {
+   srv, _ := newTestServer(t)
+
+   req := httptest.NewRequest("GET", "/api/v1/audit-log", nil)
+   req.Header.Set("Authorization", "Bearer "+testAPIKey)
+   rec := httptest.NewRecorder()
+   srv.ServeHTTP(rec, req)
+
+   if rec.Code == http.StatusForbidden {
+       t.Errorf("legacy key rejected with 403; want pass-through")
+   }
+}
Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 0.3.1"
      },
      "package": {
        "ecosystem": "Go",
        "name": "github.com/juev/nebula-mesh"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "0.3.2"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-47726"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-285"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-06-08T23:35:55Z",
    "nvd_published_at": null,
    "severity": "HIGH"
  },
  "details": "`internal/api/audit.go:12` \u2014 `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 \u2014 enough surface for a tenant to enumerate the server\u0027s 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 \u003cany-operator-key\u003e\" \\\n  https://server/api/v1/audit-log?limit=1000\n```\n\n## Suggested fix\nTwo options, either acceptable:\n\n1. `if !actorIsAdmin(ctx) { 403 }` \u2014 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\u0027s 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(), \u0026models.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(), \u0026models.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```",
  "id": "GHSA-qm33-p5p9-f8vg",
  "modified": "2026-06-08T23:35:55Z",
  "published": "2026-06-08T23:35:55Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/juev/nebula-mesh/security/advisories/GHSA-qm33-p5p9-f8vg"
    },
    {
      "type": "WEB",
      "url": "https://github.com/forgekeep/nebula-mesh/commit/8baaace54c2a23e7c351b3efab5a31ab07b125dc"
    },
    {
      "type": "WEB",
      "url": "https://github.com/forgekeep/nebula-mesh/releases/tag/v0.3.2"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/juev/nebula-mesh"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N",
      "type": "CVSS_V4"
    }
  ],
  "summary": "nebula-mesh: GET /api/v1/audit-log discloses all entries to any operator"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…