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: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.

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…

Loading…

Loading…

Related by attack behaviour

Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.


Loading…