VYPR
Critical severity9.9NVD Advisory· Published Jun 8, 2026· Updated Jun 8, 2026

nebula-mesh: API endpoints lack ownership checks, enabling cross-operator privilege escalation

CVE-2026-47724

Description

The /api/v1/* route surface trusts the bearer token alone for authorisation on most endpoints. The codebase itself admits this at internal/api/hosts.go:384: *"API trusts the bearer token for authorisation; per-CA ownership is enforced only in the Web layer."*

The Web UI gates state-changing routes through loadAccessibleCA (internal/web/cas.go); CA-management endpoints in internal/api/cas.go ALSO have proper canAccessCA gates. The gap is on the host, network, firewall, mobile-bundle, and most operator endpoints. Combined with the per-operator CA model from ADR 0002, this gives any non-admin operator API key broad cross-tenant access — instant privilege escalation in the worst case.

Affected

All released versions prior to v0.3.4.

Exploit chain

### A) Mint admin API key from any operator key (instant privilege escalation) internal/api/operators.go:118handleCreateOperatorAPIKey does no admin check and no actor/target-operator ownership check. Any operator key can call it for any operator (including admins) and receive a fresh bearer.

curl -X POST -H "Authorization: Bearer " \
  https://server/api/v1/operators//api-keys \
  -H 'Content-Type: application/json' -d '{"name":"oops"}'
# Returns: {"key":"<32-byte admin bearer>","entry":{...}}

Reuse the returned key for subsequent requests → full admin.

### B) Cross-operator host takeover via reenroll internal/api/hosts.go:321,330mintEnrollmentTokenForHost. Looks up host by URL param, mints a single-use enrollment token, returns it. No ownership check.

curl -X POST -H "Authorization: Bearer " \
  https://server/api/v1/hosts//reenroll
# Returns: {"enrollment_token":"",...}

Caller POSTs /api/v1/enroll with their own X25519 + Ed25519 keypairs. enroll.go:175 overwrites signing_pub_pem; SaveCertificateAndEnrollHost overwrites the cert. Legitimate agent's next signed poll fails bad_signature. Attacker now owns the victim's Nebula identity.

### C) Cross-tenant CRUD on hosts, networks, firewall The same gap applies across: - /api/v1/hosts* — create, list, get, update, delete, block, unblock - /api/v1/networks* — create, list, get - /api/v1/networks/{id}/firewall — get, PUT - /api/v1/hosts/{id}/mobile-bundle (already filed as public issue #119)

All trust bearer-auth alone. Any operator can read or mutate any other operator's resources.

## Affected operator-management handlers (in addition to A) Beyond handleCreateOperatorAPIKey (covered by A), internal/api/operators.go is missing admin gates on: - handleListOperators (line 66) — operator roster info disclosure - handleDisableOperator (line 79) — DoS / sabotage - handleEnableOperator (line 94) — re-enable disabled operators - handleRevokeOperatorAPIKey (line 157) — invalidate any operator's API keys - handleListOperatorAPIKeys (line 173) — API-key metadata disclosure

handleCreateOperator (line 26) IS properly gated (actorIsAdmin at line 27).

## NOT affected (verified) internal/api/cas.go properly gates every CA endpoint via canAccessCA (calls at lines 70, 176, 216) and admin shortcuts at lines 39, 82. An earlier description draft mistakenly listed /api/v1/cas/{id}/rotate as affected — that endpoint is properly protected. CAs are not in this gap.

## Impact - Any non-admin operator → admin via one curl (A). - Any non-admin operator → ownership of any victim's hosts with cert + identity transfer (B). - Mass cross-tenant CRUD including firewall-rule mutation (C). - Any operator → disable/enable other operators, revoke their API keys, enumerate the operator roster.

CVSS 3.1: AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H — 9.6.

Suggested fix

Shared helpers in a new internal/api/authz.go, mirroring the Web layer's loadAccessibleCA:

func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) bool
func (s *Server) requireOperatorAccess(w http.ResponseWriter, r *http.Request, operatorID string) bool
func (s *Server) requireHostAccess(w http.ResponseWriter, r *http.Request, hostID string) (*models.Host, bool)
func (s *Server) requireNetworkAccess(w http.ResponseWriter, r *http.Request, networkID string) (*models.Network, bool)

Each loads the resource, resolves its CA via *.CAID, accepts if actorIsAdmin(ctx) OR actor owns the CA. Reject 403 forbidden; audit-log api..forbidden with the reason.

The operator-management endpoints take requireAdmin instead (operator ownership doesn't map to CA ownership).

Apply at the top of every host-, network-, firewall-, mobile-bundle-touching API handler, plus the 5 operator endpoints listed above. The legacy config-key path retains admin (preserves backward compatibility); the broader legacy-fallback question is tracked separately as issue #121.

## Test matrix - admin → all operations permitted - owning non-admin → operations on owned hosts/networks permitted - non-owner non-admin → 403 + audit entry - legacy config-key → preserved (admin) - unauthenticated → existing 401 from middleware

Coordinated context

Subsumes public issue #119 (mobile-bundle authz). Issue #121 (actor.go:40 legacy-admin fallback) is a separate concern tracked independently.

Affected products

1

Patches

1
9d8bcd7667ec

fix(api): re-check operator status in admin authz gates (#147)

https://github.com/forgekeep/nebula-meshAdamMay 24, 2026via ghsa-ref
10 files changed · +220 18
  • internal/api/audit.go+1 1 modified
    @@ -10,7 +10,7 @@ import (
     const defaultAuditLimit = 100
     
     func (s *Server) handleGetAuditLog(w http.ResponseWriter, r *http.Request) {
    -	if !actorIsAdmin(r.Context()) {
    +	if !s.isActiveAdmin(r.Context()) {
     		writeError(w, http.StatusForbidden, "audit log access requires the admin role")
     		return
     	}
    
  • internal/api/authz.go+20 1 modified
    @@ -8,10 +8,29 @@ import (
     	"github.com/juev/nebula-mesh/internal/store"
     )
     
    +// isActiveAdmin re-fetches the captured-ctx actor and reports whether
    +// they are still an active admin. Round-trips the operators table per
    +// call. Closes the stale-snapshot gap but not the residual TOCTOU
    +// between this check and the handler's subsequent mutating SQL.
    +func (s *Server) isActiveAdmin(ctx context.Context) bool {
    +	captured := ActorOf(ctx)
    +	if captured == nil {
    +		return false
    +	}
    +	fresh, err := s.store.GetOperator(ctx, captured.ID)
    +	if err != nil {
    +		if !errors.Is(err, store.ErrNotFound) {
    +			s.logger.Error("isActiveAdmin: store lookup", "operator", captured.ID, "error", err)
    +		}
    +		return false
    +	}
    +	return fresh.Status == models.OperatorStatusActive && fresh.Role == "admin"
    +}
    +
     // 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) {
    +	if s.isActiveAdmin(ctx) {
     		return true, nil
     	}
     	if caID == "" {
    
  • internal/api/authz_test.go+4 1 modified
    @@ -74,7 +74,7 @@ func TestActorOwnsCA_Admin(t *testing.T) {
     	srv, st := newTestServer(t)
     	ctx := context.Background()
     
    -	// Admin operator
    +	// seed: actorOwnsCA now re-fetches via isActiveAdmin.
     	adminOp := &models.Operator{
     		ID:           "admin-op",
     		Username:     "admin-test",
    @@ -83,6 +83,9 @@ func TestActorOwnsCA_Admin(t *testing.T) {
     		Status:       models.OperatorStatusActive,
     		AuthProvider: models.OperatorAuthLocal,
     	}
    +	if err := st.CreateOperator(ctx, adminOp); err != nil {
    +		t.Fatalf("seed admin op: %v", err)
    +	}
     	ctx = context.WithValue(ctx, actorContextKey, adminOp)
     
     	// Get the default CA from newTestServer (owned by test-admin, not admin-op)
    
  • internal/api/cas.go+2 2 modified
    @@ -37,7 +37,7 @@ func (s *Server) handleListCAs(w http.ResponseWriter, r *http.Request) {
     		cas []*models.CA
     		err error
     	)
    -	if actorIsAdmin(r.Context()) {
    +	if s.isActiveAdmin(r.Context()) {
     		cas, err = s.store.ListCAs(r.Context())
     	} else {
     		cas, err = s.store.ListCAsByOwner(r.Context(), ActorOf(r.Context()).ID)
    @@ -183,7 +183,7 @@ func (s *Server) handleDeleteCA(w http.ResponseWriter, r *http.Request) {
     
     // canAccessCA checks ownership. Admins bypass.
     func (s *Server) canAccessCA(r *http.Request, c *models.CA) bool {
    -	if actorIsAdmin(r.Context()) {
    +	if s.isActiveAdmin(r.Context()) {
     		return true
     	}
     	return ActorOf(r.Context()).ID == c.OwnerOperatorID
    
  • internal/api/hosts.go+2 2 modified
    @@ -188,7 +188,7 @@ func (s *Server) handleListHosts(w http.ResponseWriter, r *http.Request) {
     	}
     
     	// For non-admin, scope to owned CAs
    -	if !actorIsAdmin(r.Context()) {
    +	if !s.isActiveAdmin(r.Context()) {
     		actor := ActorOf(r.Context())
     		if actor == nil {
     			writeJSON(w, http.StatusOK, []*models.Host{})
    @@ -354,7 +354,7 @@ func (s *Server) handleUnblockHost(w http.ResponseWriter, r *http.Request) {
     }
     
     func (s *Server) handleGetBlocklist(w http.ResponseWriter, r *http.Request) {
    -	if !actorIsAdmin(r.Context()) {
    +	if !s.isActiveAdmin(r.Context()) {
     		writeError(w, http.StatusForbidden, "blocklist access requires the admin role")
     		return
     	}
    
  • internal/api/inflight_authz_test.go+81 0 added
    @@ -0,0 +1,81 @@
    +package api
    +
    +import (
    +	"context"
    +	"net/http"
    +	"net/http/httptest"
    +	"testing"
    +
    +	"github.com/juev/nebula-mesh/internal/models"
    +)
    +
    +// Tests for isActiveAdmin — the re-checking authz gate that closes the
    +// stale-captured-context window between bearerAuth and the handler's
    +// mutating SQL (residual-TOCTOU caveat noted in authz.go).
    +
    +// mutateHandler returns a minimal admin-gated handler stub for
    +// exercising isActiveAdmin in isolation, without coupling tests to a
    +// specific real handler's body.
    +func mutateHandler(srv *Server) http.HandlerFunc {
    +	return func(w http.ResponseWriter, r *http.Request) {
    +		if !srv.isActiveAdmin(r.Context()) {
    +			writeError(w, http.StatusForbidden, "operator management requires the admin role")
    +			return
    +		}
    +		w.WriteHeader(http.StatusCreated)
    +	}
    +}
    +
    +func TestIsActiveAdmin_RejectsDisabledMidFlight(t *testing.T) {
    +	srv, st := newTestServer(t)
    +	ctx := context.Background()
    +
    +	captured := &models.Operator{
    +		ID: "captured-admin", Username: "captured-admin", PasswordHash: "h",
    +		Role: "admin", Status: models.OperatorStatusActive, AuthProvider: models.OperatorAuthLocal,
    +	}
    +	if err := st.CreateOperator(ctx, captured); err != nil {
    +		t.Fatalf("seed: %v", err)
    +	}
    +	if err := st.DisableOperator(ctx, captured.ID); err != nil {
    +		t.Fatalf("disable: %v", err)
    +	}
    +
    +	// Sanity: the stale ctx still claims admin — the snapshot gap the
    +	// re-fetch closes.
    +	staleCtx := withActor(ctx, captured)
    +	if !actorIsAdmin(staleCtx) {
    +		t.Fatal("stale ctx should still pass the snapshot-only actorIsAdmin check")
    +	}
    +
    +	rec := httptest.NewRecorder()
    +	mutateHandler(srv)(rec, httptest.NewRequest(http.MethodPost, "/m", nil).WithContext(staleCtx))
    +	if rec.Code != http.StatusForbidden {
    +		t.Errorf("status = %d, want 403; body=%s", rec.Code, rec.Body.String())
    +	}
    +}
    +
    +func TestIsActiveAdmin_RejectsDeletedOperator(t *testing.T) {
    +	srv, st := newTestServer(t)
    +	ctx := context.Background()
    +
    +	captured := &models.Operator{
    +		ID: "captured-deleted", Username: "captured-deleted", PasswordHash: "h",
    +		Role: "admin", Status: models.OperatorStatusActive, AuthProvider: models.OperatorAuthLocal,
    +	}
    +	if err := st.CreateOperator(ctx, captured); err != nil {
    +		t.Fatalf("seed: %v", err)
    +	}
    +	// Hard-DELETE exercises the ErrNotFound fork separately from the
    +	// disabled branch — the two have different error-handling paths in
    +	// isActiveAdmin (ErrNotFound is silent; other errors log).
    +	if _, err := st.DB().ExecContext(ctx, `DELETE FROM operators WHERE id=?`, captured.ID); err != nil {
    +		t.Fatalf("delete: %v", err)
    +	}
    +
    +	rec := httptest.NewRecorder()
    +	mutateHandler(srv)(rec, httptest.NewRequest(http.MethodPost, "/m", nil).WithContext(withActor(ctx, captured)))
    +	if rec.Code != http.StatusForbidden {
    +		t.Errorf("status = %d, want 403; body=%s", rec.Code, rec.Body.String())
    +	}
    +}
    
  • internal/api/networks.go+2 2 modified
    @@ -18,7 +18,7 @@ type createNetworkRequest struct {
     }
     
     func (s *Server) handleCreateNetwork(w http.ResponseWriter, r *http.Request) {
    -	if !actorIsAdmin(r.Context()) {
    +	if !s.isActiveAdmin(r.Context()) {
     		writeError(w, http.StatusForbidden, "network creation requires the admin role")
     		return
     	}
    @@ -60,7 +60,7 @@ func (s *Server) handleListNetworks(w http.ResponseWriter, r *http.Request) {
     		writeError(w, http.StatusInternalServerError, "failed to list networks")
     		return
     	}
    -	if !actorIsAdmin(r.Context()) {
    +	if !s.isActiveAdmin(r.Context()) {
     		actor := ActorOf(r.Context())
     		if actor == nil {
     			writeJSON(w, http.StatusOK, []*models.Network{})
    
  • internal/api/operators.go+7 7 modified
    @@ -25,7 +25,7 @@ type createOperatorRequest struct {
     }
     
     func (s *Server) handleCreateOperator(w http.ResponseWriter, r *http.Request) {
    -	if !actorIsAdmin(r.Context()) {
    +	if !s.isActiveAdmin(r.Context()) {
     		writeError(w, http.StatusForbidden, "operator management requires the admin role")
     		return
     	}
    @@ -65,7 +65,7 @@ func (s *Server) handleCreateOperator(w http.ResponseWriter, r *http.Request) {
     }
     
     func (s *Server) handleListOperators(w http.ResponseWriter, r *http.Request) {
    -	if !actorIsAdmin(r.Context()) {
    +	if !s.isActiveAdmin(r.Context()) {
     		writeError(w, http.StatusForbidden, "operator management requires the admin role")
     		return
     	}
    @@ -82,7 +82,7 @@ func (s *Server) handleListOperators(w http.ResponseWriter, r *http.Request) {
     }
     
     func (s *Server) handleDisableOperator(w http.ResponseWriter, r *http.Request) {
    -	if !actorIsAdmin(r.Context()) {
    +	if !s.isActiveAdmin(r.Context()) {
     		writeError(w, http.StatusForbidden, "operator management requires the admin role")
     		return
     	}
    @@ -101,7 +101,7 @@ func (s *Server) handleDisableOperator(w http.ResponseWriter, r *http.Request) {
     }
     
     func (s *Server) handleEnableOperator(w http.ResponseWriter, r *http.Request) {
    -	if !actorIsAdmin(r.Context()) {
    +	if !s.isActiveAdmin(r.Context()) {
     		writeError(w, http.StatusForbidden, "operator management requires the admin role")
     		return
     	}
    @@ -129,7 +129,7 @@ type createAPIKeyResponse struct {
     }
     
     func (s *Server) handleCreateOperatorAPIKey(w http.ResponseWriter, r *http.Request) {
    -	if !actorIsAdmin(r.Context()) {
    +	if !s.isActiveAdmin(r.Context()) {
     		writeError(w, http.StatusForbidden, "operator management requires the admin role")
     		return
     	}
    @@ -172,7 +172,7 @@ func (s *Server) handleCreateOperatorAPIKey(w http.ResponseWriter, r *http.Reque
     }
     
     func (s *Server) handleRevokeOperatorAPIKey(w http.ResponseWriter, r *http.Request) {
    -	if !actorIsAdmin(r.Context()) {
    +	if !s.isActiveAdmin(r.Context()) {
     		writeError(w, http.StatusForbidden, "operator management requires the admin role")
     		return
     	}
    @@ -229,7 +229,7 @@ func (s *Server) handleRevokeOperatorAPIKey(w http.ResponseWriter, r *http.Reque
     }
     
     func (s *Server) handleListOperatorAPIKeys(w http.ResponseWriter, r *http.Request) {
    -	if !actorIsAdmin(r.Context()) {
    +	if !s.isActiveAdmin(r.Context()) {
     		writeError(w, http.StatusForbidden, "operator management requires the admin role")
     		return
     	}
    
  • internal/api/settings.go+2 2 modified
    @@ -22,7 +22,7 @@ type settingsResponse struct {
     // Admin-only because exposing the surface to non-admins gives them a
     // view of the deployment's security posture.
     func (s *Server) handleGetSettings(w http.ResponseWriter, r *http.Request) {
    -	if !actorIsAdmin(r.Context()) {
    +	if !s.isActiveAdmin(r.Context()) {
     		writeError(w, http.StatusForbidden, "admin role required")
     		return
     	}
    @@ -33,7 +33,7 @@ func (s *Server) handleGetSettings(w http.ResponseWriter, r *http.Request) {
     // handlePatchSettings updates one or more admin-tunable settings.
     // Currently exposes only enforce_2fa; will grow with the rest of #47.
     func (s *Server) handlePatchSettings(w http.ResponseWriter, r *http.Request) {
    -	if !actorIsAdmin(r.Context()) {
    +	if !s.isActiveAdmin(r.Context()) {
     		writeError(w, http.StatusForbidden, "admin role required")
     		return
     	}
    
  • internal/web/oidc_disable_cascade_test.go+99 0 added
    @@ -0,0 +1,99 @@
    +package web
    +
    +import (
    +	"context"
    +	"net/http"
    +	"net/http/httptest"
    +	"testing"
    +
    +	"github.com/juev/nebula-mesh/internal/models"
    +	"github.com/juev/nebula-mesh/internal/store"
    +)
    +
    +// TestDisableOperator_KillsActiveOIDCSession pins the disable-cascade
    +// contract for OIDC-authenticated sessions. Local-auth sessions are
    +// already covered in store/sqlite_operators_test.go; this variant
    +// guards against a future refactor that partitions sessions by
    +// auth_provider against the OIDC path specifically.
    +func TestDisableOperator_KillsActiveOIDCSession(t *testing.T) {
    +	ctx := context.Background()
    +	s, err := store.NewSQLiteStore(":memory:")
    +	if err != nil {
    +		t.Fatalf("open store: %v", err)
    +	}
    +	t.Cleanup(func() { _ = s.Close() })
    +	if err := s.Migrate(ctx); err != nil {
    +		t.Fatalf("migrate: %v", err)
    +	}
    +
    +	op := &models.Operator{
    +		ID:           "oidc-user-id",
    +		Username:     "oidc-user@example.com",
    +		DisplayName:  "OIDC User",
    +		PasswordHash: "oidc", // matches oidc.go::upsertOperator's placeholder
    +		Role:         "admin",
    +		AuthProvider: models.OperatorAuthOIDC,
    +		OIDCIssuer:   "https://idp.example.com",
    +		OIDCSubject:  "sub-12345",
    +	}
    +	if err := s.CreateOperator(ctx, op); err != nil {
    +		t.Fatalf("create OIDC operator: %v", err)
    +	}
    +
    +	sm := NewSessionManager(s)
    +
    +	// Mint a session via the same SessionManager method the OIDC
    +	// callback uses (oidc.go HandleCallback -> session.StartAuthenticatedSession).
    +	rec := httptest.NewRecorder()
    +	startReq := httptest.NewRequest(http.MethodGet, "/", nil)
    +	if err := sm.StartAuthenticatedSession(rec, startReq, op); err != nil {
    +		t.Fatalf("StartAuthenticatedSession: %v", err)
    +	}
    +
    +	// Lift the session cookie back into a request, the way a browser
    +	// would carry it across calls.
    +	var sessionCookie *http.Cookie
    +	for _, c := range rec.Result().Cookies() {
    +		if c.Name == sessionCookieName {
    +			sessionCookie = c
    +			break
    +		}
    +	}
    +	if sessionCookie == nil {
    +		t.Fatal("StartAuthenticatedSession did not set a session cookie")
    +	}
    +
    +	carryCookie := func() *http.Request {
    +		r := httptest.NewRequest(http.MethodGet, "/", nil)
    +		r.AddCookie(sessionCookie)
    +		return r
    +	}
    +
    +	// Pre-disable: the session authenticates the OIDC operator.
    +	if !sm.IsAuthenticated(carryCookie()) {
    +		t.Fatal("OIDC session should authenticate before disable")
    +	}
    +	if got := sm.CurrentOperator(carryCookie()); got == nil || got.ID != op.ID {
    +		t.Fatalf("CurrentOperator pre-disable = %v, want operator %q", got, op.ID)
    +	}
    +
    +	// Disable the OIDC operator. DisableOperator's atomic transaction
    +	// must delete the session row alongside the status flip — same
    +	// cascade local-auth operators get.
    +	if err := s.DisableOperator(ctx, op.ID); err != nil {
    +		t.Fatalf("DisableOperator: %v", err)
    +	}
    +
    +	// Post-disable: the session must no longer authenticate.
    +	if sm.IsAuthenticated(carryCookie()) {
    +		t.Error("OIDC session still authenticates after DisableOperator (cascade broken)")
    +	}
    +	if got := sm.CurrentOperator(carryCookie()); got != nil {
    +		t.Errorf("CurrentOperator post-disable = %v, want nil", got)
    +	}
    +
    +	// Verify the row is gone, not just filtered.
    +	if _, err := s.GetOperatorBySession(ctx, sessionCookie.Value); err == nil {
    +		t.Error("expected ErrNotFound from GetOperatorBySession; row still present")
    +	}
    +}
    

Vulnerability mechanics

Root cause

"Most API endpoints trust bearer tokens for authorization without enforcing per-operator ownership."

Attack vector

An attacker with any non-admin operator API key can exploit authorization gaps in the `/api/v1/*` route surface. Specifically, endpoints related to hosts, networks, firewalls, mobile bundles, and operator management do not adequately check operator ownership or administrative privileges. This allows a low-privileged operator to perform actions on behalf of other operators, including administrators, leading to privilege escalation or unauthorized resource manipulation [ref_id=1].

Affected code

The vulnerability exists in multiple API handlers within `internal/api/operators.go`, `internal/api/hosts.go`, `internal/api/networks.go`, `internal/api/settings.go`, and `internal/api/audit.go`. These handlers previously relied on a less strict `actorIsAdmin` check, which did not re-verify the operator's active status before performing sensitive operations. The patch modifies these handlers to use the new `isActiveAdmin` check.

What the fix does

The patch introduces a new `isActiveAdmin` helper function within `internal/api/authz.go`. This function re-fetches the operator's status from the store on each call, ensuring that an operator who has been disabled or deleted since the request began is no longer considered an active admin. This function is now used to gate sensitive operator management endpoints, preventing unauthorized actions by ensuring the actor is indeed an active administrator at the time of the operation [patch_id=5276174].

Preconditions

  • authAttacker must possess a valid, non-admin operator API key.

Generated on Jun 8, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

3

News mentions

0

No linked articles in our index yet.