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
2Patches
1fd21a239216fECOPROJECT-4721 | fix: Validate JWT source_id in agent handlers
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
3News mentions
0No linked articles in our index yet.