nebula-mesh: GET /api/v1/audit-log discloses all entries to any operator
Description
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 " \
https://server/api/v1/audit-log?limit=1000
Suggested fix
Two options, either acceptable:
if !actorIsAdmin(ctx) { 403 }— strictest; matches the "operator management is admin-only" stance.- Scope to actor: filter
store.ListAuditEntriesbyactor.Usernameplus 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")
+ }
+}
Affected products
1Patches
18baaace54c2aMerge commit from fork
14 files changed · +1600 −3
internal/api/audit_authz_test.go+33 −0 added@@ -0,0 +1,33 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestGetAuditLog_RequiresAdminRole(t *testing.T) { + srv, _ := newTestServer(t) + userKey := createUserWithAPIKey(t, srv, "user") + + req := httptest.NewRequest("GET", "/api/v1/audit-log", nil) + req.Header.Set("Authorization", "Bearer "+userKey) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Errorf("non-admin status = %d, want 403", rec.Code) + } +} + +func TestGetAuditLog_AdminSucceeds(t *testing.T) { + srv, _ := newTestServer(t) + adminKey := createUserWithAPIKey(t, srv, "admin") + + req := httptest.NewRequest("GET", "/api/v1/audit-log", nil) + req.Header.Set("Authorization", "Bearer "+adminKey) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Errorf("admin status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } +}
internal/api/audit.go+4 −0 modified@@ -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,
internal/api/authz.go+52 −0 added@@ -0,0 +1,52 @@ +package api + +import ( + "context" + "errors" + + "github.com/juev/nebula-mesh/internal/models" + "github.com/juev/nebula-mesh/internal/store" +) + +// actorOwnsCA returns true if the actor in ctx is admin, or owns the CA with caID. +// Returns (false, nil) for empty caID or ErrNotFound. Errors only for unexpected DB errors. +func (s *Server) actorOwnsCA(ctx context.Context, caID string) (bool, error) { + if actorIsAdmin(ctx) { + return true, nil + } + if caID == "" { + return false, nil + } + actor := ActorOf(ctx) + if actor == nil { + // actorIsAdmin returned false but ActorOf is nil — defensive; treat as + // forbidden. Unreachable on the current bearerAuth path; if it ever + // fires it usually means middleware ordering regressed, so log loud. + s.logger.Warn("authz: nil actor with non-admin context — bearerAuth ordering regression?", "ca_id", caID) + return false, nil + } + ca, err := s.store.GetCA(ctx, caID) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + return false, nil + } + return false, err + } + return ca.OwnerOperatorID == actor.ID, nil +} + +// canAccessHost returns true if the actor in ctx is admin, or owns the host's CA. +func (s *Server) canAccessHost(ctx context.Context, h *models.Host) (bool, error) { + if h == nil { + return false, nil + } + return s.actorOwnsCA(ctx, h.CAID) +} + +// canAccessNetwork returns true if the actor in ctx is admin, or owns the network's CA. +func (s *Server) canAccessNetwork(ctx context.Context, n *models.Network) (bool, error) { + if n == nil { + return false, nil + } + return s.actorOwnsCA(ctx, n.CAID) +}
internal/api/authz_test.go+284 −0 added@@ -0,0 +1,284 @@ +package api + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "io" + "log/slog" + "testing" + "time" + + "github.com/google/uuid" + "github.com/juev/nebula-mesh/internal/keystore" + "github.com/juev/nebula-mesh/internal/models" + "github.com/juev/nebula-mesh/internal/pki" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// createOperatorWithCA creates a non-admin operator with an API key and a CA owned by that operator. +// Returns (plaintext apiKey, operator, ca). +func createOperatorWithCA(t *testing.T, srv *Server) (string, *models.Operator, *models.CA) { + t.Helper() + ctx := context.Background() + + // Create non-admin operator + op := &models.Operator{ + ID: uuid.New().String(), + Username: "user-" + uuid.New().String()[:6], + PasswordHash: "test-hash", + Role: "user", + Status: models.OperatorStatusActive, + AuthProvider: models.OperatorAuthLocal, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + err := srv.store.CreateOperator(ctx, op) + require.NoError(t, err) + + // Create API key for operator + rawKey := uuid.New().String() + keySum := sha256.Sum256([]byte(rawKey)) + err = srv.store.CreateOperatorAPIKey(ctx, &models.OperatorAPIKey{ + ID: uuid.New().String(), + OperatorID: op.ID, + KeyHash: hex.EncodeToString(keySum[:]), + }) + require.NoError(t, err) + + // Create CA owned by operator using pki.MintAndStoreCA + // Requires master keystore and logger (reuse from srv if available, or create minimal ones) + master := srv.master + if master == nil { + // Fallback: create a temporary master for this test + rawMaster := bytes.Repeat([]byte{0x77}, keystore.MasterKeySize) + master, err = keystore.NewMaster(rawMaster) + require.NoError(t, err) + } + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + ca, _, err := pki.MintAndStoreCA(ctx, srv.store, master, logger, pki.MintRequest{ + Operator: op, + Name: "test-ca-" + uuid.New().String()[:6], + Duration: 365 * 24 * time.Hour, + }) + require.NoError(t, err) + + return rawKey, op, ca +} + +func TestActorOwnsCA_Admin(t *testing.T) { + srv, st := newTestServer(t) + ctx := context.Background() + + // Admin operator + adminOp := &models.Operator{ + ID: "admin-op", + Username: "admin-test", + PasswordHash: "hash", + Role: "admin", + Status: models.OperatorStatusActive, + AuthProvider: models.OperatorAuthLocal, + } + ctx = context.WithValue(ctx, actorContextKey, adminOp) + + // Get the default CA from newTestServer (owned by test-admin, not admin-op) + cas, err := st.ListCAs(ctx) + require.NoError(t, err) + require.True(t, len(cas) > 0, "should have at least one CA from newTestServer") + ca := cas[0] + + // Admin should have access to any CA, even one owned by another operator + ok, err := srv.actorOwnsCA(ctx, ca.ID) + require.NoError(t, err) + assert.True(t, ok, "admin should have access to any CA") +} + +func TestActorOwnsCA_Owner(t *testing.T) { + srv, _ := newTestServer(t) + apiKey, op, ca := createOperatorWithCA(t, srv) + + // Authenticate as owner + ctx := context.Background() + ctx = context.WithValue(ctx, actorContextKey, op) + + // Owner should have access + ok, err := srv.actorOwnsCA(ctx, ca.ID) + require.NoError(t, err) + assert.True(t, ok, "owner should have access to owned CA") + _ = apiKey // prevent unused warning +} + +func TestActorOwnsCA_NonOwner(t *testing.T) { + srv, _ := newTestServer(t) + _, owner, ca := createOperatorWithCA(t, srv) + + // Create different non-admin operator + ctx := context.Background() + other := &models.Operator{ + ID: uuid.New().String(), + Username: "other-user", + PasswordHash: "hash", + Role: "user", + Status: models.OperatorStatusActive, + AuthProvider: models.OperatorAuthLocal, + } + err := srv.store.CreateOperator(ctx, other) + require.NoError(t, err) + + // Other operator should not have access + ctx = context.WithValue(ctx, actorContextKey, other) + ok, err := srv.actorOwnsCA(ctx, ca.ID) + require.NoError(t, err) + assert.False(t, ok, "non-owner should not have access") + _ = owner // prevent unused warning +} + +func TestActorOwnsCA_EmptyCAID(t *testing.T) { + srv, _ := newTestServer(t) + ctx := context.Background() + + op := &models.Operator{ + ID: uuid.New().String(), + Username: "test-user", + PasswordHash: "hash", + Role: "user", + Status: models.OperatorStatusActive, + AuthProvider: models.OperatorAuthLocal, + } + ctx = context.WithValue(ctx, actorContextKey, op) + + // Empty CA ID should return false + ok, err := srv.actorOwnsCA(ctx, "") + require.NoError(t, err) + assert.False(t, ok, "empty caID should return false") +} + +func TestActorOwnsCA_CANotFound(t *testing.T) { + srv, _ := newTestServer(t) + ctx := context.Background() + + op := &models.Operator{ + ID: uuid.New().String(), + Username: "test-user", + PasswordHash: "hash", + Role: "user", + Status: models.OperatorStatusActive, + AuthProvider: models.OperatorAuthLocal, + } + err := srv.store.CreateOperator(ctx, op) + require.NoError(t, err) + + ctx = context.WithValue(ctx, actorContextKey, op) + + // Non-existent CA should return false without error + ok, err := srv.actorOwnsCA(ctx, "nonexistent-ca-id") + require.NoError(t, err) + assert.False(t, ok, "non-existent CA should return false") +} + +func TestCanAccessHost_Owner(t *testing.T) { + srv, _ := newTestServer(t) + _, op, ca := createOperatorWithCA(t, srv) + + ctx := context.Background() + ctx = context.WithValue(ctx, actorContextKey, op) + + // Create host in operator's CA + host := &models.Host{ + ID: uuid.New().String(), + NetworkID: "test-network", + CAID: ca.ID, + Name: "test-host", + } + + ok, err := srv.canAccessHost(ctx, host) + require.NoError(t, err) + assert.True(t, ok, "owner should have access to host in their CA") +} + +func TestCanAccessHost_NonOwner(t *testing.T) { + srv, _ := newTestServer(t) + _, op1, ca := createOperatorWithCA(t, srv) + + // Create another operator + op2 := &models.Operator{ + ID: uuid.New().String(), + Username: "other-user", + PasswordHash: "hash", + Role: "user", + Status: models.OperatorStatusActive, + AuthProvider: models.OperatorAuthLocal, + } + err := srv.store.CreateOperator(context.Background(), op2) + require.NoError(t, err) + + ctx := context.Background() + ctx = context.WithValue(ctx, actorContextKey, op2) + + // Try to access host in other operator's CA + host := &models.Host{ + ID: uuid.New().String(), + NetworkID: "test-network", + CAID: ca.ID, + Name: "test-host", + } + + ok, err := srv.canAccessHost(ctx, host) + require.NoError(t, err) + assert.False(t, ok, "non-owner should not have access to host in other's CA") + _ = op1 // prevent unused warning +} + +func TestCanAccessNetwork_Owner(t *testing.T) { + srv, _ := newTestServer(t) + _, op, ca := createOperatorWithCA(t, srv) + + ctx := context.Background() + ctx = context.WithValue(ctx, actorContextKey, op) + + // Create network in operator's CA + network := &models.Network{ + ID: uuid.New().String(), + Name: "test-network", + CAID: ca.ID, + } + + ok, err := srv.canAccessNetwork(ctx, network) + require.NoError(t, err) + assert.True(t, ok, "owner should have access to network in their CA") +} + +func TestCanAccessNetwork_NonOwner(t *testing.T) { + srv, _ := newTestServer(t) + _, op1, ca := createOperatorWithCA(t, srv) + + // Create another operator + op2 := &models.Operator{ + ID: uuid.New().String(), + Username: "other-user", + PasswordHash: "hash", + Role: "user", + Status: models.OperatorStatusActive, + AuthProvider: models.OperatorAuthLocal, + } + err := srv.store.CreateOperator(context.Background(), op2) + require.NoError(t, err) + + ctx := context.Background() + ctx = context.WithValue(ctx, actorContextKey, op2) + + // Try to access network in other operator's CA + network := &models.Network{ + ID: uuid.New().String(), + Name: "test-network", + CAID: ca.ID, + } + + ok, err := srv.canAccessNetwork(ctx, network) + require.NoError(t, err) + assert.False(t, ok, "non-owner should not have access to network in other's CA") + _ = op1 // prevent unused warning +}
internal/api/firewall_authz_test.go+124 −0 added@@ -0,0 +1,124 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/juev/nebula-mesh/internal/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestGetFirewall_RequiresOwnership verifies that a non-owner cannot read +// firewall rules of another operator's network. +func TestGetFirewall_RequiresOwnership(t *testing.T) { + srv, testDB := newTestServer(t) + + _, _, ca1 := createOperatorWithCA(t, srv) + op2Key, _, _ := createOperatorWithCA(t, srv) + + net1 := &models.Network{ + ID: "net-fw-get-1", + Name: "Net FW Get 1", + CAID: ca1.ID, + CIDRs: []string{"10.0.0.0/8"}, + } + require.NoError(t, testDB.CreateNetwork(context.Background(), net1)) + + req := httptest.NewRequest("GET", fmt.Sprintf("/api/v1/networks/%s/firewall", net1.ID), nil) + req.Header.Set("Authorization", "Bearer "+op2Key) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code, "non-owner should not read firewall rules of foreign network") +} + +// TestUpdateFirewall_RequiresOwnership verifies that a non-owner cannot +// mutate firewall rules of another operator's network. +func TestUpdateFirewall_RequiresOwnership(t *testing.T) { + srv, testDB := newTestServer(t) + + _, _, ca1 := createOperatorWithCA(t, srv) + op2Key, _, _ := createOperatorWithCA(t, srv) + + net1 := &models.Network{ + ID: "net-fw-upd-1", + Name: "Net FW Upd 1", + CAID: ca1.ID, + CIDRs: []string{"10.0.0.0/8"}, + } + require.NoError(t, testDB.CreateNetwork(context.Background(), net1)) + + body, _ := json.Marshal(map[string]any{ + "inbound": []map[string]string{ + {"port": "22", "proto": "tcp", "group": "admin"}, + }, + "outbound": []map[string]string{ + {"port": "any", "proto": "any", "group": "any"}, + }, + }) + req := httptest.NewRequest("PUT", fmt.Sprintf("/api/v1/networks/%s/firewall", net1.ID), bytes.NewBuffer(body)) + req.Header.Set("Authorization", "Bearer "+op2Key) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code, "non-owner should not update firewall rules of foreign network") +} + +// TestGetFirewall_OwnerSucceeds verifies the happy path: the owner of the +// network's CA can read firewall rules. +func TestGetFirewall_OwnerSucceeds(t *testing.T) { + srv, testDB := newTestServer(t) + + op1Key, _, ca1 := createOperatorWithCA(t, srv) + + net1 := &models.Network{ + ID: "net-fw-get-owner", + Name: "Net FW Get Owner", + CAID: ca1.ID, + CIDRs: []string{"10.0.0.0/8"}, + } + require.NoError(t, testDB.CreateNetwork(context.Background(), net1)) + + req := httptest.NewRequest("GET", fmt.Sprintf("/api/v1/networks/%s/firewall", net1.ID), nil) + req.Header.Set("Authorization", "Bearer "+op1Key) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code, "owner should read firewall rules of their network") +} + +// TestUpdateFirewall_OwnerSucceeds verifies the happy path for mutation. +func TestUpdateFirewall_OwnerSucceeds(t *testing.T) { + srv, testDB := newTestServer(t) + + op1Key, _, ca1 := createOperatorWithCA(t, srv) + + net1 := &models.Network{ + ID: "net-fw-upd-owner", + Name: "Net FW Upd Owner", + CAID: ca1.ID, + CIDRs: []string{"10.0.0.0/8"}, + } + require.NoError(t, testDB.CreateNetwork(context.Background(), net1)) + + body, _ := json.Marshal(map[string]any{ + "inbound": []map[string]string{ + {"port": "22", "proto": "tcp", "group": "admin"}, + }, + "outbound": []map[string]string{ + {"port": "any", "proto": "any", "group": "any"}, + }, + }) + req := httptest.NewRequest("PUT", fmt.Sprintf("/api/v1/networks/%s/firewall", net1.ID), bytes.NewBuffer(body)) + req.Header.Set("Authorization", "Bearer "+op1Key) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code, "owner should update firewall rules of their network") +}
internal/api/firewall.go+42 −0 modified@@ -29,6 +29,27 @@ var defaultFirewallRules = firewallRulesRequest{ func (s *Server) handleGetFirewall(w http.ResponseWriter, r *http.Request) { networkID := chi.URLParam(r, "id") + network, err := s.store.GetNetwork(r.Context(), networkID) + if errors.Is(err, store.ErrNotFound) { + writeError(w, http.StatusNotFound, "network not found") + return + } + if err != nil { + s.logger.Error("get network for firewall", "error", err) + writeError(w, http.StatusInternalServerError, "failed to load network") + return + } + ok, err := s.canAccessNetwork(r.Context(), network) + if err != nil { + s.logger.Error("authz check", "error", err) + writeError(w, http.StatusInternalServerError, "authz check failed") + return + } + if !ok { + writeError(w, http.StatusForbidden, "forbidden") + return + } + rules, err := s.getFirewallRules(r, networkID) if err != nil { s.logger.Error("get firewall rules", "error", err) @@ -42,6 +63,27 @@ func (s *Server) handleGetFirewall(w http.ResponseWriter, r *http.Request) { func (s *Server) handleUpdateFirewall(w http.ResponseWriter, r *http.Request) { networkID := chi.URLParam(r, "id") + network, err := s.store.GetNetwork(r.Context(), networkID) + if errors.Is(err, store.ErrNotFound) { + writeError(w, http.StatusNotFound, "network not found") + return + } + if err != nil { + s.logger.Error("get network for firewall update", "error", err) + writeError(w, http.StatusInternalServerError, "failed to load network") + return + } + ok, err := s.canAccessNetwork(r.Context(), network) + if err != nil { + s.logger.Error("authz check", "error", err) + writeError(w, http.StatusInternalServerError, "authz check failed") + return + } + if !ok { + writeError(w, http.StatusForbidden, "forbidden") + return + } + var req firewallRulesRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body")
internal/api/hosts_authz_test.go+517 −0 added@@ -0,0 +1,517 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/juev/nebula-mesh/internal/models" +) + +// TestCreateHost_RequiresNetworkOwnership verifies that non-owners cannot create hosts in foreign networks +func TestCreateHost_RequiresNetworkOwnership(t *testing.T) { + srv, testDB := newTestServer(t) + + // Create two operators with different CAs + _, _, ca1 := createOperatorWithCA(t, srv) + op2Key, _, _ := createOperatorWithCA(t, srv) + + // op1 creates a network under ca1 + network := &models.Network{ + ID: "test-net-1", + Name: "Test Network 1", + CAID: ca1.ID, + CIDRs: []string{"10.0.0.0/8"}, + } + require.NoError(t, testDB.CreateNetwork(context.Background(), network)) + + // op2 tries to create a host in op1's network → should fail with 403 + reqBody := createHostRequest{ + Name: "attacker-host", + NetworkID: network.ID, + NebulaIPs: []string{"10.0.0.5"}, + } + body, _ := json.Marshal(reqBody) + req := httptest.NewRequest("POST", "/api/v1/hosts", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+op2Key) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code, "non-owner should not be able to create host in foreign network") +} + +// TestCreateHost_OwnerSucceeds verifies that owners can create hosts in their +// own networks and that the resulting host inherits the network's CAID rather +// than the server's defaultCAID. The newTestServer fixture's defaultCAID +// belongs to a different operator, so a stamping bug would surface here. +func TestCreateHost_OwnerSucceeds(t *testing.T) { + srv, testDB := newTestServer(t) + + // Create operator with CA + opKey, _, ca := createOperatorWithCA(t, srv) + require.NotEqual(t, srv.defaultCAID, ca.ID, + "fixture invariant: operator CA must differ from defaultCAID") + + // op creates a network under their CA + network := &models.Network{ + ID: "test-net-owner", + Name: "Owner Network", + CAID: ca.ID, + CIDRs: []string{"10.0.0.0/8"}, + } + require.NoError(t, testDB.CreateNetwork(context.Background(), network)) + + // op creates a host in their own network → should succeed + reqBody := createHostRequest{ + Name: "owner-host", + NetworkID: network.ID, + NebulaIPs: []string{"10.0.0.5"}, + } + body, _ := json.Marshal(reqBody) + req := httptest.NewRequest("POST", "/api/v1/hosts", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+opKey) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + require.Equal(t, http.StatusCreated, w.Code, "owner should be able to create host in own network") + + var resp createHostResponse + require.NoError(t, json.NewDecoder(w.Body).Decode(&resp)) + assert.Equal(t, network.CAID, resp.Host.CAID, + "host must inherit network.CAID, not server defaultCAID") +} + +// TestRotateCert_RequiresOwnership verifies that non-owners cannot trigger +// cert rotation on hosts they don't own. Covers both new_key=true (sets +// pending_rekey) and new_key=false (re-signs immediately). Without an +// ownership gate, this endpoint reproduces the GHSA-598g class of bug on +// the most destructive primitive in the host surface. +func TestRotateCert_RequiresOwnership(t *testing.T) { + srv, testDB := newTestServer(t) + + _, _, ca1 := createOperatorWithCA(t, srv) + op2Key, _, _ := createOperatorWithCA(t, srv) + + net1 := &models.Network{ + ID: "net-rotate-1", Name: "Net Rotate 1", + CAID: ca1.ID, CIDRs: []string{"10.0.0.0/8"}, + } + require.NoError(t, testDB.CreateNetwork(context.Background(), net1)) + + host1 := &models.Host{ + ID: "host-rotate-1", Name: "Host Rotate 1", + NetworkID: net1.ID, CAID: ca1.ID, NebulaIPs: []string{"10.0.0.5"}, + } + require.NoError(t, testDB.CreateHost(context.Background(), host1)) + + // new_key=true (pending_rekey path) + reqPending := httptest.NewRequest("POST", + fmt.Sprintf("/api/v1/hosts/%s/rotate-cert?new_key=true", host1.ID), nil) + reqPending.Header.Set("Authorization", "Bearer "+op2Key) + wPending := httptest.NewRecorder() + srv.ServeHTTP(wPending, reqPending) + assert.Equal(t, http.StatusForbidden, wPending.Code, + "non-owner should not set pending_rekey on foreign host") + + // new_key=false (immediate re-sign path) + reqResign := httptest.NewRequest("POST", + fmt.Sprintf("/api/v1/hosts/%s/rotate-cert?new_key=false", host1.ID), nil) + reqResign.Header.Set("Authorization", "Bearer "+op2Key) + wResign := httptest.NewRecorder() + srv.ServeHTTP(wResign, reqResign) + assert.Equal(t, http.StatusForbidden, wResign.Code, + "non-owner should not re-sign foreign host cert") +} + +// TestGetHost_RequiresOwnership verifies that non-owners cannot access hosts they don't own +// TestGetHost_OwnerSucceeds verifies that the operator who owns a host's CA +// can read it (happy path). +func TestGetHost_OwnerSucceeds(t *testing.T) { + srv, testDB := newTestServer(t) + + op1Key, _, ca1 := createOperatorWithCA(t, srv) + + net1 := &models.Network{ + ID: "net-getown-1", + Name: "Net GetOwn 1", + CAID: ca1.ID, + CIDRs: []string{"10.0.0.0/8"}, + } + require.NoError(t, testDB.CreateNetwork(context.Background(), net1)) + + host1 := &models.Host{ + ID: "host-getown-1", + Name: "Host GetOwn 1", + NetworkID: net1.ID, + CAID: ca1.ID, + NebulaIPs: []string{"10.0.0.5"}, + } + require.NoError(t, testDB.CreateHost(context.Background(), host1)) + + req := httptest.NewRequest("GET", fmt.Sprintf("/api/v1/hosts/%s", host1.ID), nil) + req.Header.Set("Authorization", "Bearer "+op1Key) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code, "owner should read their host") +} + +func TestGetHost_RequiresOwnership(t *testing.T) { + srv, testDB := newTestServer(t) + + // Create two operators + _, _, ca1 := createOperatorWithCA(t, srv) + op2Key, _, _ := createOperatorWithCA(t, srv) + + // Create network for op1 + net1 := &models.Network{ + ID: "net-get-1", + Name: "Net Get 1", + CAID: ca1.ID, + CIDRs: []string{"10.0.0.0/8"}, + } + require.NoError(t, testDB.CreateNetwork(context.Background(), net1)) + + // Create host for op1 + host1 := &models.Host{ + ID: "host-get-1", + Name: "Host Get 1", + NetworkID: net1.ID, + CAID: ca1.ID, + NebulaIPs: []string{"10.0.0.5"}, + } + require.NoError(t, testDB.CreateHost(context.Background(), host1)) + + // op2 tries to GET op1's host → should fail with 403 + req := httptest.NewRequest("GET", fmt.Sprintf("/api/v1/hosts/%s", host1.ID), nil) + req.Header.Set("Authorization", "Bearer "+op2Key) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code, "non-owner should not access foreign host") +} + +// TestUpdateHost_RequiresOwnership verifies that non-owners cannot modify hosts they don't own +func TestUpdateHost_RequiresOwnership(t *testing.T) { + srv, testDB := newTestServer(t) + + // Create two operators + _, _, ca1 := createOperatorWithCA(t, srv) + op2Key, _, _ := createOperatorWithCA(t, srv) + + // Create network for op1 + net1 := &models.Network{ + ID: "net-update-1", + Name: "Net Update 1", + CAID: ca1.ID, + CIDRs: []string{"10.0.0.0/8"}, + } + require.NoError(t, testDB.CreateNetwork(context.Background(), net1)) + + // Create host for op1 + host1 := &models.Host{ + ID: "host-update-1", + Name: "Host Update 1", + NetworkID: net1.ID, + CAID: ca1.ID, + NebulaIPs: []string{"10.0.0.5"}, + } + require.NoError(t, testDB.CreateHost(context.Background(), host1)) + + // op2 tries to PATCH op1's host → should fail with 403 + name := "renamed" + reqBody := updateHostRequest{Name: &name} + body, _ := json.Marshal(reqBody) + req := httptest.NewRequest("PATCH", fmt.Sprintf("/api/v1/hosts/%s", host1.ID), bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+op2Key) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code, "non-owner should not update foreign host") +} + +// TestDeleteHost_RequiresOwnership verifies that non-owners cannot delete hosts they don't own +func TestDeleteHost_RequiresOwnership(t *testing.T) { + srv, testDB := newTestServer(t) + + // Create two operators + _, _, ca1 := createOperatorWithCA(t, srv) + op2Key, _, _ := createOperatorWithCA(t, srv) + + // Create network for op1 + net1 := &models.Network{ + ID: "net-delete-1", + Name: "Net Delete 1", + CAID: ca1.ID, + CIDRs: []string{"10.0.0.0/8"}, + } + require.NoError(t, testDB.CreateNetwork(context.Background(), net1)) + + // Create host for op1 + host1 := &models.Host{ + ID: "host-delete-1", + Name: "Host Delete 1", + NetworkID: net1.ID, + CAID: ca1.ID, + NebulaIPs: []string{"10.0.0.5"}, + } + require.NoError(t, testDB.CreateHost(context.Background(), host1)) + + // op2 tries to DELETE op1's host → should fail with 403 + req := httptest.NewRequest("DELETE", fmt.Sprintf("/api/v1/hosts/%s", host1.ID), nil) + req.Header.Set("Authorization", "Bearer "+op2Key) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code, "non-owner should not delete foreign host") +} + +// TestBlockHost_RequiresOwnership verifies that non-owners cannot block hosts they don't own +func TestBlockHost_RequiresOwnership(t *testing.T) { + srv, testDB := newTestServer(t) + + // Create two operators + _, _, ca1 := createOperatorWithCA(t, srv) + op2Key, _, _ := createOperatorWithCA(t, srv) + + // Create network for op1 + net1 := &models.Network{ + ID: "net-block-1", + Name: "Net Block 1", + CAID: ca1.ID, + CIDRs: []string{"10.0.0.0/8"}, + } + require.NoError(t, testDB.CreateNetwork(context.Background(), net1)) + + // Create host for op1 + host1 := &models.Host{ + ID: "host-block-1", + Name: "Host Block 1", + NetworkID: net1.ID, + CAID: ca1.ID, + NebulaIPs: []string{"10.0.0.5"}, + } + require.NoError(t, testDB.CreateHost(context.Background(), host1)) + + // op2 tries to BLOCK op1's host → should fail with 403 + req := httptest.NewRequest("POST", fmt.Sprintf("/api/v1/hosts/%s/block", host1.ID), nil) + req.Header.Set("Authorization", "Bearer "+op2Key) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code, "non-owner should not block foreign host") +} + +// TestUnblockHost_RequiresOwnership verifies that non-owners cannot unblock hosts they don't own +func TestUnblockHost_RequiresOwnership(t *testing.T) { + srv, testDB := newTestServer(t) + + // Create two operators + _, _, ca1 := createOperatorWithCA(t, srv) + op2Key, _, _ := createOperatorWithCA(t, srv) + + // Create network for op1 + net1 := &models.Network{ + ID: "net-unblock-1", + Name: "Net Unblock 1", + CAID: ca1.ID, + CIDRs: []string{"10.0.0.0/8"}, + } + require.NoError(t, testDB.CreateNetwork(context.Background(), net1)) + + // Create host for op1 + host1 := &models.Host{ + ID: "host-unblock-1", + Name: "Host Unblock 1", + NetworkID: net1.ID, + CAID: ca1.ID, + Status: models.HostStatusBlocked, + NebulaIPs: []string{"10.0.0.5"}, + } + require.NoError(t, testDB.CreateHost(context.Background(), host1)) + + // op2 tries to UNBLOCK op1's host → should fail with 403 + req := httptest.NewRequest("POST", fmt.Sprintf("/api/v1/hosts/%s/unblock", host1.ID), nil) + req.Header.Set("Authorization", "Bearer "+op2Key) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code, "non-owner should not unblock foreign host") +} + +// TestReenroll_OwnershipAuthz verifies that non-owners cannot reenroll hosts they don't own +// This reproduces the vulnerability from the advisory +func TestReenroll_OwnershipAuthz(t *testing.T) { + srv, testDB := newTestServer(t) + + // Create two operators + _, _, ca1 := createOperatorWithCA(t, srv) + op2Key, _, _ := createOperatorWithCA(t, srv) + + // Create network for op1 + net1 := &models.Network{ + ID: "net-reenroll-1", + Name: "Net Reenroll 1", + CAID: ca1.ID, + CIDRs: []string{"10.0.0.0/8"}, + } + require.NoError(t, testDB.CreateNetwork(context.Background(), net1)) + + // Create host for op1 + host1 := &models.Host{ + ID: "host-reenroll-1", + Name: "Host Reenroll 1", + NetworkID: net1.ID, + CAID: ca1.ID, + NebulaIPs: []string{"10.0.0.5"}, + } + require.NoError(t, testDB.CreateHost(context.Background(), host1)) + + // op2 tries to REENROLL op1's host → should fail with 403 + req := httptest.NewRequest("POST", fmt.Sprintf("/api/v1/hosts/%s/reenroll", host1.ID), nil) + req.Header.Set("Authorization", "Bearer "+op2Key) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code, "non-owner should not reenroll foreign host (advisory reproducer)") +} + +// TestRegenerateEnrollmentToken_OwnershipAuthz verifies that non-owners cannot regenerate tokens for hosts they don't own +func TestRegenerateEnrollmentToken_OwnershipAuthz(t *testing.T) { + srv, testDB := newTestServer(t) + + // Create two operators + _, _, ca1 := createOperatorWithCA(t, srv) + op2Key, _, _ := createOperatorWithCA(t, srv) + + // Create network for op1 + net1 := &models.Network{ + ID: "net-regen-1", + Name: "Net Regen 1", + CAID: ca1.ID, + CIDRs: []string{"10.0.0.0/8"}, + } + require.NoError(t, testDB.CreateNetwork(context.Background(), net1)) + + // Create host for op1 + host1 := &models.Host{ + ID: "host-regen-1", + Name: "Host Regen 1", + NetworkID: net1.ID, + CAID: ca1.ID, + NebulaIPs: []string{"10.0.0.5"}, + } + require.NoError(t, testDB.CreateHost(context.Background(), host1)) + + // op2 tries to REGENERATE enrollment token for op1's host → should fail with 403 + req := httptest.NewRequest("POST", fmt.Sprintf("/api/v1/hosts/%s/enrollment-token", host1.ID), nil) + req.Header.Set("Authorization", "Bearer "+op2Key) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code, "non-owner should not regenerate token for foreign host") +} + +// TestListHosts_ScopedToOwnedCAs verifies that non-admin operators see only hosts under their own CAs +// This demonstrates the information-disclosure fix: non-owners must not see foreign hosts in list +func TestListHosts_ScopedToOwnedCAs(t *testing.T) { + srv, testDB := newTestServer(t) + + // Create two non-admin operators with different CAs + opAKey, _, caA := createOperatorWithCA(t, srv) + opBKey, _, caB := createOperatorWithCA(t, srv) + + // Create network for operator A + netA := &models.Network{ + ID: "net-list-a", + Name: "Network A", + CAID: caA.ID, + CIDRs: []string{"10.0.0.0/8"}, + } + require.NoError(t, testDB.CreateNetwork(context.Background(), netA)) + + // Create network for operator B + netB := &models.Network{ + ID: "net-list-b", + Name: "Network B", + CAID: caB.ID, + CIDRs: []string{"10.1.0.0/8"}, + } + require.NoError(t, testDB.CreateNetwork(context.Background(), netB)) + + // Create host under CA A + hostA := &models.Host{ + ID: "host-list-a", + Name: "Host A", + NetworkID: netA.ID, + CAID: caA.ID, + NebulaIPs: []string{"10.0.0.5"}, + } + require.NoError(t, testDB.CreateHost(context.Background(), hostA)) + + // Create host under CA B + hostB := &models.Host{ + ID: "host-list-b", + Name: "Host B", + NetworkID: netB.ID, + CAID: caB.ID, + NebulaIPs: []string{"10.1.0.5"}, + } + require.NoError(t, testDB.CreateHost(context.Background(), hostB)) + + // Operator A lists hosts with their token + req := httptest.NewRequest("GET", "/api/v1/hosts", nil) + req.Header.Set("Authorization", "Bearer "+opAKey) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code, "list hosts should succeed") + + // Parse response + var hosts []*models.Host + err := json.NewDecoder(w.Body).Decode(&hosts) + require.NoError(t, err, "should parse JSON response") + + // Verify operator A sees only hostA + hostIDs := make([]string, len(hosts)) + for i, h := range hosts { + hostIDs[i] = h.ID + } + + assert.Contains(t, hostIDs, hostA.ID, "operator A should see their own host") + assert.NotContains(t, hostIDs, hostB.ID, "operator A should not see operator B's host") + + // Operator B lists hosts with their token + req = httptest.NewRequest("GET", "/api/v1/hosts", nil) + req.Header.Set("Authorization", "Bearer "+opBKey) + w = httptest.NewRecorder() + srv.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code, "list hosts should succeed") + + // Parse response + hosts = []*models.Host{} + err = json.NewDecoder(w.Body).Decode(&hosts) + require.NoError(t, err, "should parse JSON response") + + // Verify operator B sees only hostB + hostIDs = make([]string, len(hosts)) + for i, h := range hosts { + hostIDs[i] = h.ID + } + + assert.Contains(t, hostIDs, hostB.ID, "operator B should see their own host") + assert.NotContains(t, hostIDs, hostA.ID, "operator B should not see operator A's host") +}
internal/api/hosts.go+165 −3 modified@@ -51,6 +51,28 @@ func (s *Server) handleCreateHost(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "name, nebula_ips, and network_id are required") return } + + network, err := s.store.GetNetwork(r.Context(), req.NetworkID) + if errors.Is(err, store.ErrNotFound) { + writeError(w, http.StatusBadRequest, "network not found") + return + } + if err != nil { + s.logger.Error("get network for host create", "error", err) + writeError(w, http.StatusInternalServerError, "failed to load network") + return + } + netOK, err := s.canAccessNetwork(r.Context(), network) + if err != nil { + s.logger.Error("authz check for host create", "error", err) + writeError(w, http.StatusInternalServerError, "authz check failed") + return + } + if !netOK { + writeError(w, http.StatusForbidden, "forbidden") + return + } + if err := validateHostIPs(r.Context(), s.store, req.NetworkID, req.NebulaIPs, ""); err != nil { if IsHostIPValidationError(err) { writeError(w, http.StatusBadRequest, err.Error()) @@ -90,6 +112,16 @@ func (s *Server) handleCreateHost(w http.ResponseWriter, r *http.Request) { return } + // Host inherits the network's CA. Combined with the canAccessNetwork + // gate above, this binds new hosts to the same trust domain as the + // network's operator (closes the GHSA-598g class of cross-tenant cert + // issuance via host create + rotate-cert). The defaultCAID fallback + // preserves backward compatibility for admin-created networks via the + // CAID-less /api/v1/networks endpoint (out-of-scope follow-up). + caID := network.CAID + if caID == "" { + caID = s.defaultCAID + } now := time.Now() host := &models.Host{ ID: uuid.New().String(), @@ -104,7 +136,7 @@ func (s *Server) handleCreateHost(w http.ResponseWriter, r *http.Request) { ListenPort: req.ListenPort, Status: models.HostStatusPending, Advanced: req.Advanced, - CAID: s.defaultCAID, + CAID: caID, CreatedAt: now, UpdatedAt: now, } @@ -152,6 +184,34 @@ func (s *Server) handleListHosts(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "failed to list hosts") return } + + // For non-admin, scope to owned CAs + if !actorIsAdmin(r.Context()) { + actor := ActorOf(r.Context()) + if actor == nil { + writeJSON(w, http.StatusOK, []*models.Host{}) + return + } + cas, err := s.store.ListCAsByOwner(r.Context(), actor.ID) + if err != nil { + s.logger.Error("list cas by owner", "error", err) + writeError(w, http.StatusInternalServerError, "failed to check permissions") + return + } + ownedCAIDs := make(map[string]struct{}, len(cas)) + for _, ca := range cas { + ownedCAIDs[ca.ID] = struct{}{} + } + // Filter hosts to only those under owned CAs + filtered := hosts[:0] + for _, h := range hosts { + if _, ok := ownedCAIDs[h.CAID]; ok { + filtered = append(filtered, h) + } + } + hosts = filtered + } + writeJSON(w, http.StatusOK, hosts) } @@ -167,12 +227,42 @@ func (s *Server) handleGetHost(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "failed to get host") return } + ok, err := s.canAccessHost(r.Context(), host) + if err != nil { + s.logger.Error("authz check", "error", err) + writeError(w, http.StatusInternalServerError, "authz check failed") + return + } + if !ok { + writeError(w, http.StatusForbidden, "forbidden") + return + } writeJSON(w, http.StatusOK, host) } func (s *Server) handleDeleteHost(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") + host, err := s.store.GetHost(r.Context(), id) + if errors.Is(err, store.ErrNotFound) { + writeError(w, http.StatusNotFound, "host not found") + return + } + if err != nil { + s.logger.Error("get host for delete", "error", err) + writeError(w, http.StatusInternalServerError, "failed to get host") + return + } + ok, err := s.canAccessHost(r.Context(), host) + if err != nil { + s.logger.Error("authz check", "error", err) + writeError(w, http.StatusInternalServerError, "authz check failed") + return + } + if !ok { + writeError(w, http.StatusForbidden, "forbidden") + return + } if err := s.store.DeleteHostAndBlockCert(r.Context(), id, "host deleted"); err != nil { if errors.Is(err, store.ErrNotFound) { writeError(w, http.StatusNotFound, "host not found") @@ -189,6 +279,26 @@ func (s *Server) handleDeleteHost(w http.ResponseWriter, r *http.Request) { func (s *Server) handleBlockHost(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") + existing, err := s.store.GetHost(r.Context(), id) + if errors.Is(err, store.ErrNotFound) { + writeError(w, http.StatusNotFound, "host not found") + return + } + if err != nil { + s.logger.Error("get host for block", "error", err) + writeError(w, http.StatusInternalServerError, "failed to get host") + return + } + ok, err := s.canAccessHost(r.Context(), existing) + if err != nil { + s.logger.Error("authz check", "error", err) + writeError(w, http.StatusInternalServerError, "authz check failed") + return + } + if !ok { + writeError(w, http.StatusForbidden, "forbidden") + return + } host, err := s.store.BlockHostAndAddToBlocklist(r.Context(), id, "manually blocked") if errors.Is(err, store.ErrNotFound) { writeError(w, http.StatusNotFound, "host not found") @@ -206,6 +316,26 @@ func (s *Server) handleBlockHost(w http.ResponseWriter, r *http.Request) { func (s *Server) handleUnblockHost(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") + existing, err := s.store.GetHost(r.Context(), id) + if errors.Is(err, store.ErrNotFound) { + writeError(w, http.StatusNotFound, "host not found") + return + } + if err != nil { + s.logger.Error("get host for unblock", "error", err) + writeError(w, http.StatusInternalServerError, "failed to get host") + return + } + ok, err := s.canAccessHost(r.Context(), existing) + if err != nil { + s.logger.Error("authz check", "error", err) + writeError(w, http.StatusInternalServerError, "authz check failed") + return + } + if !ok { + writeError(w, http.StatusForbidden, "forbidden") + return + } host, err := s.store.UnblockHostAndRemoveFromBlocklist(r.Context(), id) if errors.Is(err, store.ErrNotFound) { writeError(w, http.StatusNotFound, "host not found") @@ -222,6 +352,10 @@ func (s *Server) handleUnblockHost(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleGetBlocklist(w http.ResponseWriter, r *http.Request) { + if !actorIsAdmin(r.Context()) { + writeError(w, http.StatusForbidden, "blocklist access requires the admin role") + return + } list, err := s.store.GetBlocklist(r.Context()) if err != nil { s.logger.Error("get blocklist", "error", err) @@ -269,6 +403,16 @@ func (s *Server) handleRotateCert(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "failed to get host") return } + ok, err := s.canAccessHost(r.Context(), host) + if err != nil { + s.logger.Error("authz check for rotate-cert", "error", err) + writeError(w, http.StatusInternalServerError, "authz check failed") + return + } + if !ok { + writeError(w, http.StatusForbidden, "forbidden") + return + } if newKey { if err := s.store.SetPendingRekey(r.Context(), host.ID); err != nil { @@ -346,6 +490,16 @@ func (s *Server) mintEnrollmentTokenForHost(w http.ResponseWriter, r *http.Reque writeError(w, http.StatusInternalServerError, "failed to get host") return } + ok, err := s.canAccessHost(r.Context(), host) + if err != nil { + s.logger.Error("authz check", "error", err) + writeError(w, http.StatusInternalServerError, "authz check failed") + return + } + if !ok { + writeError(w, http.StatusForbidden, "forbidden") + return + } tokenStr := uuid.New().String() expiresAt := time.Now().Add(s.tokenTTLFor(r.Context(), host.NetworkID)) @@ -380,8 +534,16 @@ func (s *Server) handleUpdateHost(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "failed to get host") return } - - // API trusts the bearer token for authorisation; per-CA ownership is enforced only in the Web layer (handleHostUpdate). Matches handleCreateHost/handleDeleteHost behaviour. + ok, err := s.canAccessHost(r.Context(), host) + if err != nil { + s.logger.Error("authz check", "error", err) + writeError(w, http.StatusInternalServerError, "authz check failed") + return + } + if !ok { + writeError(w, http.StatusForbidden, "forbidden") + return + } // Decode request body (lenient for PATCH, forward-compatible) var req updateHostRequest
internal/api/mobile_bundle_authz_test.go+50 −0 added@@ -0,0 +1,50 @@ +package api + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/juev/nebula-mesh/internal/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestMobileBundle_RequiresOwnership verifies that non-owners cannot request +// a mobile bundle for a host they don't own (GHSA-598g-h2vc-h5vg, public +// issue #119). +func TestMobileBundle_RequiresOwnership(t *testing.T) { + srv, testDB := newTestServer(t) + + // op1 owns ca1 and host1; op2 has its own CA and tries to fetch op1's bundle. + _, _, ca1 := createOperatorWithCA(t, srv) + op2Key, _, _ := createOperatorWithCA(t, srv) + + net1 := &models.Network{ + ID: "net-mb-1", + Name: "Net MB 1", + CAID: ca1.ID, + CIDRs: []string{"10.0.0.0/8"}, + } + require.NoError(t, testDB.CreateNetwork(context.Background(), net1)) + + host1 := &models.Host{ + ID: "host-mb-1", + Name: "Host MB 1", + NetworkID: net1.ID, + CAID: ca1.ID, + Kind: models.HostKindMobile, + NebulaIPs: []string{"10.0.0.5"}, + } + require.NoError(t, testDB.CreateHost(context.Background(), host1)) + + // op2 tries to request mobile bundle for op1's host → 403 + req := httptest.NewRequest("POST", fmt.Sprintf("/api/v1/hosts/%s/mobile-bundle", host1.ID), nil) + req.Header.Set("Authorization", "Bearer "+op2Key) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code, "non-owner should not get mobile bundle for foreign host") +}
internal/api/mobile_bundle.go+10 −0 modified@@ -29,6 +29,16 @@ func (s *Server) handleMobileBundle(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "failed to get host") return } + ok, err := s.canAccessHost(r.Context(), host) + if err != nil { + s.logger.Error("authz check", "error", err) + writeError(w, http.StatusInternalServerError, "authz check failed") + return + } + if !ok { + writeError(w, http.StatusForbidden, "forbidden") + return + } // Check that host is mobile if host.Kind != models.HostKindMobile {
internal/api/networks_authz_test.go+140 −0 added@@ -0,0 +1,140 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/juev/nebula-mesh/internal/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestCreateNetwork_RequiresAdmin verifies that handleCreateNetwork is +// admin-only: non-admin operators cannot create networks (createNetworkRequest +// has no CAID field, so non-admin creation would produce orphan networks). +func TestCreateNetwork_RequiresAdmin(t *testing.T) { + srv, _ := newTestServer(t) + userKey := createUserWithAPIKey(t, srv, "user") + + body, _ := json.Marshal(map[string]any{ + "name": "n", + "cidrs": []string{"10.0.0.0/8"}, + }) + req := httptest.NewRequest("POST", "/api/v1/networks", bytes.NewBuffer(body)) + req.Header.Set("Authorization", "Bearer "+userKey) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code, "non-admin should not create networks") +} + +// TestGetNetwork_RequiresOwnership verifies that a non-owner gets 403 when +// trying to read another operator's network. +func TestGetNetwork_RequiresOwnership(t *testing.T) { + srv, testDB := newTestServer(t) + + _, _, ca1 := createOperatorWithCA(t, srv) + op2Key, _, _ := createOperatorWithCA(t, srv) + + net1 := &models.Network{ + ID: "net-get-1", + Name: "Net Get 1", + CAID: ca1.ID, + CIDRs: []string{"10.0.0.0/8"}, + } + require.NoError(t, testDB.CreateNetwork(context.Background(), net1)) + + req := httptest.NewRequest("GET", fmt.Sprintf("/api/v1/networks/%s", net1.ID), nil) + req.Header.Set("Authorization", "Bearer "+op2Key) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code, "non-owner should not read foreign network") +} + +// TestGetNetwork_OwnerSucceeds verifies the happy path: the operator who owns +// the network's CA can read it. +func TestGetNetwork_OwnerSucceeds(t *testing.T) { + srv, testDB := newTestServer(t) + + op1Key, _, ca1 := createOperatorWithCA(t, srv) + + net1 := &models.Network{ + ID: "net-get-owner-1", + Name: "Net Get Owner 1", + CAID: ca1.ID, + CIDRs: []string{"10.0.0.0/8"}, + } + require.NoError(t, testDB.CreateNetwork(context.Background(), net1)) + + req := httptest.NewRequest("GET", fmt.Sprintf("/api/v1/networks/%s", net1.ID), nil) + req.Header.Set("Authorization", "Bearer "+op1Key) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code, "owner should read their network") +} + +// TestCreateNetwork_AdminSucceeds verifies the admin happy path for network +// creation. +func TestCreateNetwork_AdminSucceeds(t *testing.T) { + srv, _ := newTestServer(t) + adminKey := createUserWithAPIKey(t, srv, "admin") + + body, _ := json.Marshal(map[string]any{ + "name": "admin-created-net", + "cidrs": []string{"10.99.0.0/16"}, + }) + req := httptest.NewRequest("POST", "/api/v1/networks", bytes.NewBuffer(body)) + req.Header.Set("Authorization", "Bearer "+adminKey) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + assert.Equal(t, http.StatusCreated, w.Code, "admin should create networks; body=%s", w.Body.String()) +} + +// TestListNetworks_ScopedToOwnedCAs verifies that a non-admin operator only +// sees networks under their own CAs. +func TestListNetworks_ScopedToOwnedCAs(t *testing.T) { + srv, testDB := newTestServer(t) + + keyA, _, caA := createOperatorWithCA(t, srv) + _, _, caB := createOperatorWithCA(t, srv) + + netA := &models.Network{ + ID: "net-A", + Name: "Net A", + CAID: caA.ID, + CIDRs: []string{"10.0.0.0/8"}, + } + netB := &models.Network{ + ID: "net-B", + Name: "Net B", + CAID: caB.ID, + CIDRs: []string{"10.1.0.0/8"}, + } + require.NoError(t, testDB.CreateNetwork(context.Background(), netA)) + require.NoError(t, testDB.CreateNetwork(context.Background(), netB)) + + req := httptest.NewRequest("GET", "/api/v1/networks", nil) + req.Header.Set("Authorization", "Bearer "+keyA) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + + var got []*models.Network + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got)) + + var ids []string + for _, n := range got { + ids = append(ids, n.ID) + } + assert.Contains(t, ids, "net-A", "owner should see their own network") + assert.NotContains(t, ids, "net-B", "non-admin should not see foreign network") +}
internal/api/networks.go+38 −0 modified@@ -17,6 +17,10 @@ type createNetworkRequest struct { } func (s *Server) handleCreateNetwork(w http.ResponseWriter, r *http.Request) { + if !actorIsAdmin(r.Context()) { + writeError(w, http.StatusForbidden, "network creation requires the admin role") + return + } var req createNetworkRequest if err := decodeJSONStrict(r, &req); err != nil { writeError(w, http.StatusBadRequest, err.Error()) @@ -55,6 +59,30 @@ func (s *Server) handleListNetworks(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "failed to list networks") return } + if !actorIsAdmin(r.Context()) { + actor := ActorOf(r.Context()) + if actor == nil { + writeJSON(w, http.StatusOK, []*models.Network{}) + return + } + cas, err := s.store.ListCAsByOwner(r.Context(), actor.ID) + if err != nil { + s.logger.Error("list cas by owner", "error", err) + writeError(w, http.StatusInternalServerError, "failed to scope networks") + return + } + owned := make(map[string]struct{}, len(cas)) + for _, ca := range cas { + owned[ca.ID] = struct{}{} + } + filtered := networks[:0] + for _, n := range networks { + if _, ok := owned[n.CAID]; ok { + filtered = append(filtered, n) + } + } + networks = filtered + } writeJSON(w, http.StatusOK, networks) } @@ -70,6 +98,16 @@ func (s *Server) handleGetNetwork(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "failed to get network") return } + ok, err := s.canAccessNetwork(r.Context(), network) + if err != nil { + s.logger.Error("authz check", "error", err) + writeError(w, http.StatusInternalServerError, "authz check failed") + return + } + if !ok { + writeError(w, http.StatusForbidden, "forbidden") + return + } writeJSON(w, http.StatusOK, network) }
internal/api/operators_admin_test.go+117 −0 modified@@ -65,3 +65,120 @@ func TestCreateOperator_AdminCanCreate(t *testing.T) { t.Errorf("admin status = %d, want 201; body=%s", rec.Code, rec.Body.String()) } } + +func TestListOperators_RequiresAdminRole(t *testing.T) { + srv, _ := newTestServer(t) + userKey := createUserWithAPIKey(t, srv, "user") + + req := httptest.NewRequest("GET", "/api/v1/operators", nil) + req.Header.Set("Authorization", "Bearer "+userKey) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Errorf("non-admin status = %d, want 403", rec.Code) + } +} + +// TestGetBlocklist_RequiresAdminRole verifies non-admin operators cannot read +// the global blocklist. Without the gate, GET /api/v1/blocklist exposes the +// fingerprints of every blocked cert across every tenant. +func TestGetBlocklist_RequiresAdminRole(t *testing.T) { + srv, _ := newTestServer(t) + userKey := createUserWithAPIKey(t, srv, "user") + + req := httptest.NewRequest("GET", "/api/v1/blocklist", nil) + req.Header.Set("Authorization", "Bearer "+userKey) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Errorf("non-admin status = %d, want 403", rec.Code) + } +} + +func TestDisableOperator_RequiresAdminRole(t *testing.T) { + srv, _ := newTestServer(t) + userKey := createUserWithAPIKey(t, srv, "user") + targetOp := &models.Operator{ + ID: uuid.New().String(), + Username: "target-" + uuid.New().String()[:6], + PasswordHash: "x", + Role: "user", + } + if err := srv.store.CreateOperator(context.Background(), targetOp); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest("POST", "/api/v1/operators/"+targetOp.ID+"/disable", nil) + req.Header.Set("Authorization", "Bearer "+userKey) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Errorf("non-admin status = %d, want 403", rec.Code) + } +} + +func TestEnableOperator_RequiresAdminRole(t *testing.T) { + srv, _ := newTestServer(t) + userKey := createUserWithAPIKey(t, srv, "user") + targetOp := &models.Operator{ + ID: uuid.New().String(), + Username: "target-" + uuid.New().String()[:6], + PasswordHash: "x", + Role: "user", + } + if err := srv.store.CreateOperator(context.Background(), targetOp); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest("POST", "/api/v1/operators/"+targetOp.ID+"/enable", nil) + req.Header.Set("Authorization", "Bearer "+userKey) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Errorf("non-admin status = %d, want 403", rec.Code) + } +} + +func TestCreateOperatorAPIKey_RequiresAdminRole(t *testing.T) { + srv, _ := newTestServer(t) + userKey := createUserWithAPIKey(t, srv, "user") + targetID := createUserWithAPIKey(t, srv, "user") + + body, _ := json.Marshal(map[string]string{"name": "evil-key"}) + req := httptest.NewRequest("POST", "/api/v1/operators/"+targetID+"/api-keys", bytes.NewBuffer(body)) + req.Header.Set("Authorization", "Bearer "+userKey) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Errorf("non-admin status = %d, want 403", rec.Code) + } +} + +func TestRevokeOperatorAPIKey_RequiresAdminRole(t *testing.T) { + srv, _ := newTestServer(t) + userKey := createUserWithAPIKey(t, srv, "user") + targetID := createUserWithAPIKey(t, srv, "user") + keyID := uuid.New().String() + + req := httptest.NewRequest("DELETE", "/api/v1/operators/"+targetID+"/api-keys/"+keyID, nil) + req.Header.Set("Authorization", "Bearer "+userKey) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Errorf("non-admin status = %d, want 403", rec.Code) + } +} + +func TestListOperatorAPIKeys_RequiresAdminRole(t *testing.T) { + srv, _ := newTestServer(t) + userKey := createUserWithAPIKey(t, srv, "user") + targetID := createUserWithAPIKey(t, srv, "user") + + req := httptest.NewRequest("GET", "/api/v1/operators/"+targetID+"/api-keys", nil) + req.Header.Set("Authorization", "Bearer "+userKey) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Errorf("non-admin status = %d, want 403", rec.Code) + } +}
internal/api/operators.go+24 −0 modified@@ -64,6 +64,10 @@ func (s *Server) handleCreateOperator(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleListOperators(w http.ResponseWriter, r *http.Request) { + if !actorIsAdmin(r.Context()) { + writeError(w, http.StatusForbidden, "operator management requires the admin role") + return + } ops, err := s.store.ListOperators(r.Context()) if err != nil { s.logger.Error("list operators", "error", err) @@ -77,6 +81,10 @@ func (s *Server) handleListOperators(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleDisableOperator(w http.ResponseWriter, r *http.Request) { + if !actorIsAdmin(r.Context()) { + writeError(w, http.StatusForbidden, "operator management requires the admin role") + return + } id := chi.URLParam(r, "id") if err := s.store.DisableOperator(r.Context(), id); err != nil { if errors.Is(err, store.ErrNotFound) { @@ -92,6 +100,10 @@ func (s *Server) handleDisableOperator(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleEnableOperator(w http.ResponseWriter, r *http.Request) { + if !actorIsAdmin(r.Context()) { + writeError(w, http.StatusForbidden, "operator management requires the admin role") + return + } id := chi.URLParam(r, "id") if err := s.store.EnableOperator(r.Context(), id); err != nil { if errors.Is(err, store.ErrNotFound) { @@ -116,6 +128,10 @@ type createAPIKeyResponse struct { } func (s *Server) handleCreateOperatorAPIKey(w http.ResponseWriter, r *http.Request) { + if !actorIsAdmin(r.Context()) { + writeError(w, http.StatusForbidden, "operator management requires the admin role") + return + } id := chi.URLParam(r, "id") if _, err := s.store.GetOperator(r.Context(), id); err != nil { if errors.Is(err, store.ErrNotFound) { @@ -155,6 +171,10 @@ func (s *Server) handleCreateOperatorAPIKey(w http.ResponseWriter, r *http.Reque } func (s *Server) handleRevokeOperatorAPIKey(w http.ResponseWriter, r *http.Request) { + if !actorIsAdmin(r.Context()) { + writeError(w, http.StatusForbidden, "operator management requires the admin role") + return + } id := chi.URLParam(r, "id") kid := chi.URLParam(r, "kid") if err := s.store.RevokeOperatorAPIKey(r.Context(), kid); err != nil { @@ -171,6 +191,10 @@ func (s *Server) handleRevokeOperatorAPIKey(w http.ResponseWriter, r *http.Reque } func (s *Server) handleListOperatorAPIKeys(w http.ResponseWriter, r *http.Request) { + if !actorIsAdmin(r.Context()) { + writeError(w, http.StatusForbidden, "operator management requires the admin role") + return + } id := chi.URLParam(r, "id") keys, err := s.store.ListOperatorAPIKeys(r.Context(), id) if err != nil {
Vulnerability mechanics
Root cause
"The `handleGetAuditLog` function in `internal/api/audit.go` lacks an administrator check, allowing any authenticated operator to access sensitive audit data."
Attack vector
An attacker with any valid operator API key can send a GET request to the `/api/v1/audit-log` endpoint. This request bypasses necessary authorization checks because the route is only gated by bearer authentication. The server then returns up to 1000 audit log entries, which can include cross-tenant information, host details, and masked IP addresses [ref_id=1]. This data can be used to infer server activity, identify high-value targets, or understand staffing patterns.
Affected code
The vulnerability resides in the `handleGetAuditLog` function located in `internal/api/audit.go` at line 12. The fix is applied by adding a conditional check for administrator privileges before proceeding to list audit entries.
What the fix does
The patch introduces a check at the beginning of the `handleGetAuditLog` function to ensure the actor has administrator privileges using `actorIsAdmin(r.Context())`. If the actor is not an admin, a `403 Forbidden` status is returned, preventing unauthorized access to the audit log. This directly addresses the vulnerability by enforcing the required administrative role for accessing sensitive audit information, aligning with the principle that operator management should be admin-only [ref_id=1].
Preconditions
- authThe attacker must possess any valid operator API key.
Reproduction
curl -H "Authorization: Bearer <any-operator-key>" \ https://server/api/v1/audit-log?limit=1000
Generated on Jun 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4News mentions
0No linked articles in our index yet.