VYPR
Critical severity9.6NVD Advisory· Published Jun 10, 2026· Updated Jun 10, 2026

CVE-2026-53471

CVE-2026-53471

Description

Migration-planner agent API allows JWT source_id manipulation, enabling cross-tenant data corruption and inventory overwrites.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Migration-planner agent API allows JWT source_id manipulation, enabling cross-tenant data corruption and inventory overwrites.

Vulnerability

A flaw exists in the migration-planner's agent API middleware where the UpdateSourceInventory and UpdateAgentStatus handlers fail to validate the source_id claim within JSON Web Tokens (JWTs) against the requested source ID. This allows an attacker to bypass tenant isolation. Affected versions are not explicitly specified, but the issue is present in the code described in the references [2, 3].

Exploitation

An authenticated attacker possessing a valid agent token can exploit this vulnerability. The attacker needs to send a request to the agent API, manipulating the source_id claim in their JWT to match a different tenant's source ID than the one specified in the request path or body. This allows them to perform actions as if they were operating on the targeted tenant's data [3].

Impact

Successful exploitation leads to a complete collapse of tenant isolation. An attacker can overwrite victim inventory data, plant malicious credential URLs, or corrupt migration assessments, effectively gaining unauthorized write capabilities across different tenants [3].

Mitigation

A fix has been merged into the migration-planner repository, specifically addressing the validation of the JWT source_id in agent handlers [2]. The suggested fix involves checking auth.MustHaveAgent(ctx).SourceID in both handlers and returning a 403 Forbidden error if it does not match the target source [3]. No specific patched version or release date is provided in the available references.

AI Insight generated on Jun 10, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

2

Patches

1
fd21a239216f

ECOPROJECT-4721 | fix: Validate JWT source_id in agent handlers

https://github.com/kubev2v/migration-plannerAviel SegevJun 1, 2026via nvd-ref
6 files changed · +213 44
  • internal/api_server/agentserver/server.go+1 7 modified
    @@ -72,15 +72,9 @@ func (s *AgentServer) Run(ctx context.Context) error {
     		metricMiddleware.Handler,
     		middleware.RequestID,
     		log.ConditionalLogger(s.cfg.Service.LogLevel, zap.L(), "router_agent"),
    +		auth.NewAgentAuthenticator(s.cfg.Service.Auth.AgentAuthenticationEnabled, s.store).Authenticator,
     	)
     
    -	zap.S().Infow("agent authentication", "enabled", s.cfg.Service.Auth.AgentAuthenticationEnabled)
    -	if s.cfg.Service.Auth.AgentAuthenticationEnabled {
    -		router.Use(
    -			auth.NewAgentAuthenticator(s.store).Authenticator,
    -		)
    -	}
    -
     	router.Use(
     		middleware.Recoverer,
     		oapimiddleware.OapiRequestValidatorWithOptions(swagger, &oapiOpts),
    
  • internal/auth/agent_authenticator.go+11 1 modified
    @@ -76,7 +76,17 @@ type AgentAuthenticator struct {
     	store store.Store
     }
     
    -func NewAgentAuthenticator(store store.Store) *AgentAuthenticator {
    +func NewAgentAuthenticator(enabled bool, store store.Store) Authenticator {
    +	if enabled {
    +		zap.S().Named("auth").Info("agent authentication enabled")
    +		return newProductionAgentAuthenticator(store)
    +	}
    +
    +	zap.S().Named("auth").Info("agent authentication disabled, using none authenticator")
    +	return NewNoneAgentAuthenticator()
    +}
    +
    +func newProductionAgentAuthenticator(store store.Store) *AgentAuthenticator {
     	return &AgentAuthenticator{store: store}
     }
     
    
  • internal/auth/agent_authenticator_test.go+36 4 modified
    @@ -1,9 +1,11 @@
     package auth_test
     
     import (
    +	"bytes"
     	"crypto/rand"
     	"crypto/rsa"
     	"crypto/x509"
    +	"encoding/json"
     	"encoding/pem"
     	"fmt"
     	"net/http"
    @@ -60,7 +62,7 @@ var _ = Describe("agent authentication", Ordered, func() {
     
     			token := generateAgentToken("kid", "my_source", "GothamCity", privateKey)
     
    -			agentAuthenticator := auth.NewAgentAuthenticator(s)
    +			agentAuthenticator := auth.NewAgentAuthenticator(true, s).(*auth.AgentAuthenticator)
     
     			agentJwt, err := agentAuthenticator.Authenticate(token)
     			Expect(err).To(BeNil())
    @@ -87,7 +89,7 @@ var _ = Describe("agent authentication", Ordered, func() {
     
     			token := generateAgentToken("missing-key-kid", "my_source", "GothamCity", privateKey)
     
    -			agentAuthenticator := auth.NewAgentAuthenticator(s)
    +			agentAuthenticator := auth.NewAgentAuthenticator(true, s).(*auth.AgentAuthenticator)
     
     			_, err = agentAuthenticator.Authenticate(token)
     			Expect(err).ToNot(BeNil())
    @@ -112,7 +114,7 @@ var _ = Describe("agent authentication", Ordered, func() {
     
     			token := generateAgentToken("kid", "my_source", "GothamCity", signingKey)
     
    -			agentAuthenticator := auth.NewAgentAuthenticator(s)
    +			agentAuthenticator := auth.NewAgentAuthenticator(true, s).(*auth.AgentAuthenticator)
     
     			_, err = agentAuthenticator.Authenticate(token)
     			Expect(err).ToNot(BeNil())
    @@ -140,7 +142,7 @@ var _ = Describe("agent authentication", Ordered, func() {
     
     			token := generateAgentToken("1234_kid", "my_source", "org_id", privateKey)
     
    -			agentAuthenticator := auth.NewAgentAuthenticator(s)
    +			agentAuthenticator := auth.NewAgentAuthenticator(true, s)
     			h := &handler{}
     			ts := httptest.NewServer(agentAuthenticator.Authenticator(h))
     			defer ts.Close()
    @@ -159,6 +161,36 @@ var _ = Describe("agent authentication", Ordered, func() {
     		})
     	})
     
    +	Context("none authenticator middleware", func() {
    +		It("successfully extracts sourceId from request body", func() {
    +			innerHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    +				// Verify AgentJWT is in context
    +				agentJWT := auth.MustHaveAgent(r.Context())
    +				Expect(agentJWT.SourceID).To(Equal("test-source-123"))
    +
    +				// Verify body is still readable for downstream
    +				var body map[string]interface{}
    +				err := json.NewDecoder(r.Body).Decode(&body)
    +				Expect(err).To(BeNil(), "Body should be readable by downstream handler")
    +				Expect(body["sourceId"]).To(Equal("test-source-123"))
    +
    +				w.WriteHeader(200)
    +			})
    +
    +			noneAuthenticator := auth.NewNoneAgentAuthenticator()
    +			ts := httptest.NewServer(noneAuthenticator.Authenticator(innerHandler))
    +			defer ts.Close()
    +
    +			body := `{"sourceId":"test-source-123"}`
    +			req, err := http.NewRequest(http.MethodPost, ts.URL, bytes.NewBufferString(body))
    +			Expect(err).To(BeNil())
    +
    +			resp, rerr := http.DefaultClient.Do(req)
    +			Expect(rerr).To(BeNil())
    +			Expect(resp.StatusCode).To(Equal(200))
    +		})
    +	})
    +
     })
     
     func generateAgentToken(kid, sourceID, orgID string, signingKey *rsa.PrivateKey) string {
    
  • internal/auth/none_agent_authenticator.go+48 0 added
    @@ -0,0 +1,48 @@
    +package auth
    +
    +import (
    +	"bytes"
    +	"encoding/json"
    +	"fmt"
    +	"io"
    +	"net/http"
    +	"time"
    +)
    +
    +type NoneAgentAuthenticator struct{}
    +
    +func NewNoneAgentAuthenticator() *NoneAgentAuthenticator {
    +	return &NoneAgentAuthenticator{}
    +}
    +
    +func (n *NoneAgentAuthenticator) Authenticator(next http.Handler) http.Handler {
    +	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    +		bodyBytes, err := io.ReadAll(r.Body)
    +		if err != nil {
    +			http.Error(w, fmt.Sprintf("failed to read request body: %v", err), http.StatusBadRequest)
    +			return
    +		}
    +		_ = r.Body.Close()
    +
    +		var req struct {
    +			SourceID string `json:"sourceId"`
    +		}
    +
    +		if err := json.Unmarshal(bodyBytes, &req); err != nil {
    +			http.Error(w, fmt.Sprintf("missing source id from request body: %v", err), http.StatusBadRequest)
    +			return
    +		}
    +
    +		r.Body = io.NopCloser(bytes.NewReader(bodyBytes))
    +
    +		agentJWT := AgentJWT{
    +			ExpireAt: time.Now().Add(defaultExpirationPeriod * time.Hour),
    +			IssueAt:  time.Now(),
    +			Issuer:   "none",
    +			OrgID:    "internal",
    +			SourceID: req.SourceID,
    +		}
    +		ctx := NewTokenContext(r.Context(), agentJWT)
    +		next.ServeHTTP(w, r.WithContext(ctx))
    +	})
    +}
    
  • internal/handlers/v1alpha1/agent.go+15 0 modified
    @@ -7,6 +7,7 @@ import (
     
     	v1alpha1 "github.com/kubev2v/migration-planner/api/v1alpha1/agent"
     	agentServer "github.com/kubev2v/migration-planner/internal/api/server/agent"
    +	"github.com/kubev2v/migration-planner/internal/auth"
     	apiMappers "github.com/kubev2v/migration-planner/internal/handlers/v1alpha1/mappers"
     	"github.com/kubev2v/migration-planner/internal/handlers/validator"
     	"github.com/kubev2v/migration-planner/internal/service"
    @@ -31,6 +32,13 @@ func (h *AgentHandler) UpdateSourceInventory(ctx context.Context, request agentS
     		return agentServer.UpdateSourceInventory400JSONResponse{Message: "empty body"}, nil
     	}
     
    +	agentJWT := auth.MustHaveAgent(ctx)
    +	if agentJWT.SourceID != request.Id.String() {
    +		return agentServer.UpdateSourceInventory403JSONResponse{
    +			Message: fmt.Sprintf("agent is not authorized to update source %s", request.Id),
    +		}, nil
    +	}
    +
     	data, err := json.Marshal(request.Body.Inventory)
     	if err != nil {
     		return agentServer.UpdateSourceInventory500JSONResponse{Message: err.Error()}, nil
    @@ -74,6 +82,13 @@ func (h *AgentHandler) UpdateAgentStatus(ctx context.Context, request agentServe
     		return agentServer.UpdateAgentStatus400JSONResponse{Message: err.Error()}, nil
     	}
     
    +	agentJWT := auth.MustHaveAgent(ctx)
    +	if agentJWT.SourceID != request.Body.SourceId.String() {
    +		return agentServer.UpdateAgentStatus403JSONResponse{
    +			Message: fmt.Sprintf("agent is not authorized to update source %s", request.Body.SourceId),
    +		}, nil
    +	}
    +
     	_, created, err := h.srv.UpdateAgentStatus(ctx, mappers.AgentUpdateForm{
     		ID:         request.Id,
     		SourceID:   request.Body.SourceId,
    
  • internal/handlers/v1alpha1/agent_test.go+102 32 modified
    @@ -47,11 +47,11 @@ var _ = Describe("agent service", Ordered, func() {
     			Expect(tx.Error).To(BeNil())
     			agentID := uuid.New()
     
    -			user := auth.User{
    -				Username:     "admin",
    -				Organization: "admin",
    +			agentJWT := auth.AgentJWT{
    +				OrgID:    "admin",
    +				SourceID: sourceID.String(),
     			}
    -			ctx := auth.NewTokenContext(context.TODO(), user)
    +			ctx := auth.NewTokenContext(context.TODO(), agentJWT)
     
     			srv := handlers.NewAgentHandler(service.NewAgentService(s))
     			resp, err := srv.UpdateAgentStatus(ctx, server.UpdateAgentStatusRequestObject{
    @@ -94,11 +94,11 @@ var _ = Describe("agent service", Ordered, func() {
     			Expect(tx.Error).To(BeNil())
     			agentID := uuid.New()
     
    -			user := auth.User{
    -				Username:     "admin",
    -				Organization: "admin",
    +			agentJWT := auth.AgentJWT{
    +				OrgID:    "admin",
    +				SourceID: sourceID.String(),
     			}
    -			ctx := auth.NewTokenContext(context.TODO(), user)
    +			ctx := auth.NewTokenContext(context.TODO(), agentJWT)
     
     			srv := handlers.NewAgentHandler(service.NewAgentService(s))
     			resp, err := srv.UpdateAgentStatus(ctx, server.UpdateAgentStatusRequestObject{
    @@ -128,11 +128,11 @@ var _ = Describe("agent service", Ordered, func() {
     			tx = gormdb.Exec(fmt.Sprintf(insertAgentStm, agentID, "not-connected", "status-info-1", "cred_url-1", sourceID))
     			Expect(tx.Error).To(BeNil())
     
    -			user := auth.User{
    -				Username:     "admin",
    -				Organization: "admin",
    +			agentJWT := auth.AgentJWT{
    +				OrgID:    "admin",
    +				SourceID: sourceID.String(),
     			}
    -			ctx := auth.NewTokenContext(context.TODO(), user)
    +			ctx := auth.NewTokenContext(context.TODO(), agentJWT)
     
     			srv := handlers.NewAgentHandler(service.NewAgentService(s))
     			resp, err := srv.UpdateAgentStatus(ctx, server.UpdateAgentStatusRequestObject{
    @@ -174,11 +174,11 @@ var _ = Describe("agent service", Ordered, func() {
     			tx := gormdb.Exec(fmt.Sprintf(insertSourceWithUsernameStm, sourceID, "admin", "admin"))
     			Expect(tx.Error).To(BeNil())
     
    -			user := auth.User{
    -				Username:     "batman",
    -				Organization: "wayne_enterprises",
    +			agentJWT := auth.AgentJWT{
    +				OrgID:    "wayne_enterprises",
    +				SourceID: sourceID,
     			}
    -			ctx := auth.NewTokenContext(context.TODO(), user)
    +			ctx := auth.NewTokenContext(context.TODO(), agentJWT)
     
     			srv := handlers.NewAgentHandler(service.NewAgentService(s))
     			resp, err := srv.UpdateAgentStatus(ctx, server.UpdateAgentStatusRequestObject{
    @@ -194,6 +194,41 @@ var _ = Describe("agent service", Ordered, func() {
     			Expect(reflect.TypeOf(resp).String()).To(Equal(reflect.TypeOf(server.UpdateAgentStatus400JSONResponse{}).String()))
     		})
     
    +		It("rejects update when JWT source_id does not match target source", func() {
    +			sourceID := uuid.New()
    +			differentSourceID := uuid.New()
    +			tx := gormdb.Exec(fmt.Sprintf(insertSourceWithUsernameStm, sourceID, "admin", "admin"))
    +			Expect(tx.Error).To(BeNil())
    +			agentID := uuid.New()
    +
    +			// JWT has a different source_id than the one being updated
    +			agentJWT := auth.AgentJWT{
    +				OrgID:    "admin",
    +				SourceID: differentSourceID.String(),
    +			}
    +			ctx := auth.NewTokenContext(context.TODO(), agentJWT)
    +
    +			srv := handlers.NewAgentHandler(service.NewAgentService(s))
    +			resp, err := srv.UpdateAgentStatus(ctx, server.UpdateAgentStatusRequestObject{
    +				Id: agentID,
    +				Body: &apiAgent.UpdateAgentStatusJSONRequestBody{
    +					Status:        string(v1alpha1.AgentStatusWaitingForCredentials),
    +					StatusInfo:    "waiting-for-credentials",
    +					CredentialUrl: "http://agent.com",
    +					Version:       "version-1",
    +					SourceId:      sourceID,
    +				},
    +			})
    +			Expect(err).To(BeNil())
    +			Expect(reflect.TypeOf(resp).String()).To(Equal(reflect.TypeOf(server.UpdateAgentStatus403JSONResponse{}).String()))
    +
    +			// Verify no agent was created
    +			count := -1
    +			tx = gormdb.Raw("SELECT COUNT(*) FROM agents;").Scan(&count)
    +			Expect(tx.Error).To(BeNil())
    +			Expect(count).To(Equal(0))
    +		})
    +
     		AfterEach(func() {
     			gormdb.Exec("DELETE FROM agents;")
     			gormdb.Exec("DELETE FROM sources;")
    @@ -209,11 +244,11 @@ var _ = Describe("agent service", Ordered, func() {
     			tx = gormdb.Exec(fmt.Sprintf(insertAgentStm, agentID, "not-connected", "status-info-1", "cred_url-1", sourceID))
     			Expect(tx.Error).To(BeNil())
     
    -			user := auth.User{
    -				Username:     "admin",
    -				Organization: "admin",
    +			agentJWT := auth.AgentJWT{
    +				OrgID:    "admin",
    +				SourceID: sourceID.String(),
     			}
    -			ctx := auth.NewTokenContext(context.TODO(), user)
    +			ctx := auth.NewTokenContext(context.TODO(), agentJWT)
     
     			srv := handlers.NewAgentHandler(service.NewAgentService(s))
     			resp, err := srv.UpdateSourceInventory(ctx, server.UpdateSourceInventoryRequestObject{
    @@ -251,11 +286,11 @@ var _ = Describe("agent service", Ordered, func() {
     			tx = gormdb.Exec(fmt.Sprintf(insertAgentStm, secondAgentID, "not-connected", "status-info-1", "cred_url-1", sourceID))
     			Expect(tx.Error).To(BeNil())
     
    -			user := auth.User{
    -				Username:     "admin",
    -				Organization: "admin",
    +			agentJWT := auth.AgentJWT{
    +				OrgID:    "admin",
    +				SourceID: sourceID.String(),
     			}
    -			ctx := auth.NewTokenContext(context.TODO(), user)
    +			ctx := auth.NewTokenContext(context.TODO(), agentJWT)
     
     			// first agent request
     			srv := handlers.NewAgentHandler(service.NewAgentService(s))
    @@ -308,11 +343,11 @@ var _ = Describe("agent service", Ordered, func() {
     			tx = gormdb.Exec(fmt.Sprintf(insertAgentStm, uuid.New(), "not-connected", "status-info-1", "cred_url-1", secondSourceID))
     			Expect(tx.Error).To(BeNil())
     
    -			user := auth.User{
    -				Username:     "admin",
    -				Organization: "admin",
    +			agentJWT := auth.AgentJWT{
    +				OrgID:    "admin",
    +				SourceID: firstSourceID.String(),
     			}
    -			ctx := auth.NewTokenContext(context.TODO(), user)
    +			ctx := auth.NewTokenContext(context.TODO(), agentJWT)
     
     			srv := handlers.NewAgentHandler(service.NewAgentService(s))
     			resp, err := srv.UpdateSourceInventory(ctx, server.UpdateSourceInventoryRequestObject{
    @@ -334,11 +369,11 @@ var _ = Describe("agent service", Ordered, func() {
     			tx = gormdb.Exec(fmt.Sprintf(insertAgentStm, firstAgentID, "not-connected", "status-info-1", "cred_url-1", firstSourceID))
     			Expect(tx.Error).To(BeNil())
     
    -			user := auth.User{
    -				Username:     "admin",
    -				Organization: "admin",
    +			agentJWT := auth.AgentJWT{
    +				OrgID:    "admin",
    +				SourceID: firstSourceID.String(),
     			}
    -			ctx := auth.NewTokenContext(context.TODO(), user)
    +			ctx := auth.NewTokenContext(context.TODO(), agentJWT)
     
     			srv := handlers.NewAgentHandler(service.NewAgentService(s))
     			resp, err := srv.UpdateSourceInventory(ctx, server.UpdateSourceInventoryRequestObject{
    @@ -367,6 +402,41 @@ var _ = Describe("agent service", Ordered, func() {
     
     		})
     
    +		It("rejects inventory update when JWT source_id does not match target source", func() {
    +			sourceID := uuid.New()
    +			differentSourceID := uuid.New()
    +			agentID := uuid.New()
    +			tx := gormdb.Exec(fmt.Sprintf(insertSourceWithUsernameStm, sourceID, "admin", "admin"))
    +			Expect(tx.Error).To(BeNil())
    +			tx = gormdb.Exec(fmt.Sprintf(insertAgentStm, agentID, "not-connected", "status-info-1", "cred_url-1", sourceID))
    +			Expect(tx.Error).To(BeNil())
    +
    +			// JWT has a different source_id than the one being updated
    +			agentJWT := auth.AgentJWT{
    +				OrgID:    "admin",
    +				SourceID: differentSourceID.String(),
    +			}
    +			ctx := auth.NewTokenContext(context.TODO(), agentJWT)
    +
    +			srv := handlers.NewAgentHandler(service.NewAgentService(s))
    +			resp, err := srv.UpdateSourceInventory(ctx, server.UpdateSourceInventoryRequestObject{
    +				Id: sourceID,
    +				Body: &apiAgent.SourceStatusUpdate{
    +					AgentId: agentID,
    +					Inventory: v1alpha1.Inventory{
    +						VcenterId: "vcenter",
    +					},
    +				},
    +			})
    +			Expect(err).To(BeNil())
    +			Expect(reflect.TypeOf(resp).String()).To(Equal(reflect.TypeOf(server.UpdateSourceInventory403JSONResponse{}).String()))
    +
    +			// Verify inventory was not updated
    +			source, err := s.Source().Get(ctx, sourceID)
    +			Expect(err).To(BeNil())
    +			Expect(source.Inventory).To(BeNil())
    +		})
    +
     		AfterEach(func() {
     			gormdb.Exec("DELETE FROM agents;")
     			gormdb.Exec("DELETE FROM sources;")
    

Vulnerability mechanics

Root cause

"The agent API handlers fail to validate the source_id claim within JWTs against the requested source ID."

Attack vector

An attacker with a valid agent token can send requests to the UpdateSourceInventory or UpdateAgentStatus endpoints. These requests can specify a source ID that differs from the source ID present in the agent's JWT. The API handlers do not validate this discrepancy, allowing the attacker to manipulate data for any tenant's source.

Affected code

The vulnerability exists in the `UpdateSourceInventory` and `UpdateAgentStatus` functions within `internal/handlers/v1alpha1/agent.go`. The authentication middleware in `internal/api_server/agentserver/server.go` was also updated to correctly integrate the authenticator.

What the fix does

The patch modifies the `UpdateSourceInventory` and `UpdateAgentStatus` handlers in `internal/handlers/v1alpha1/agent.go`. It now retrieves the agent's JWT from the context using `auth.MustHaveAgent(ctx)` and compares the `SourceID` claim within the JWT to the `request.Id` (for `UpdateSourceInventory`) or `request.Body.SourceId` (for `UpdateAgentStatus`). If they do not match, a 403 Forbidden response is returned, preventing unauthorized cross-tenant data manipulation.

Preconditions

  • authThe attacker must possess a valid agent token.

Generated on Jun 10, 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.