CVE-2026-53470
Description
Improper access control in migration-planner's image-url endpoint allows authenticated attackers to download sensitive OVA images from other users.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Improper access control in migration-planner's image-url endpoint allows authenticated attackers to download sensitive OVA images from other users.
Vulnerability
A flaw was found in the migration-planner application. An authenticated attacker can exploit an improper access control vulnerability in the /api/v1/sources/{id}/image-url endpoint. This endpoint, unlike sibling endpoints such as GetSource, UpdateSource, and DeleteSource, skips the user.Organization == source.OrgID ownership check and proceeds directly to URL generation. This affects all versions of migration-planner where this check is missing.
Exploitation
An attacker needs to be authenticated with a valid bearer token and know the UUID of a victim's source. The attacker sends a GET request to the /api/v1/sources/{id}/image-url endpoint, where {id} is the victim's source UUID. Since the ownership check is bypassed, the application generates and returns a presigned S3 URL for the OVA image associated with that source.
Impact
Successful exploitation allows an attacker to download OVA images belonging to other users. These images can contain sensitive information, including long-lived agent JSON Web Tokens (JWTs) and source configurations. This disclosure can lead to unauthorized access and modification of the victim's source, and potentially disclose proxy or network configuration details.
Mitigation
A fix has been implemented in migration-planner by adding the missing organization check to the GetSourceDownloadURL endpoint, as detailed in pull request #1218 [2]. The fixed version and release date are not yet disclosed in the available references. No workarounds are mentioned. The vulnerability is listed in the Red Hat security advisory [1] and Bugzilla [3].
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
1ec47a336a620ECOPROJECT-4722 | fix: Add missing organization check to GetSourceDownloadURL endpoint
6 files changed · +182 −4
api/v1alpha1/openapi.yaml+12 −0 modified@@ -340,12 +340,24 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" + "403": + description: Forbidden + content: + application/json: + schema: + $ref: "#/components/schemas/Error" "404": description: NotFound content: application/json: schema: $ref: "#/components/schemas/Error" + "500": + description: Internal error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" /api/v1/sources/{id}/image: head: tags:
api/v1alpha1/spec.gen.go+2 −2 modified@@ -186,8 +186,8 @@ var swaggerSpec = []string{ "fPDr2Xp7ebjN07eRMpdtZkK1inWNoL5DYiulWyndSunGDMEGj9MamdRfn5tYbsoUfZqHv3ptoOFJFeZW", "M2w1wwb37xrbew+HcKbsbh9Br6pAfkXQU1J/8ekY6LZlLSKbnJkvzSrEe7qdvWEj7iIendi5nf1a2WVZ", "8mqKtFC3H7Og0T2zQF8wxxB8vHpfb8G9prckoNDTjRpJrjsA7P3lrLiIIY5nBHkKezaddvUeCKoq1KjM", - "0pmA/Fia/FH16jKsn2T3Vk9lDcZR1tBuH53lvn+3JlJ5qc/USsoRa2svbe2lDdtLPoKB8Gu3Tv0ZuD5y", - "b2xWUaDEvps1kgPBzPpFwc8VoFrbqG3c2XPuv9z//wAAAP//rMNyepBEAQA=", + "0pmAbDX5VpOv0we4TcaTNObqTbDBCswa2g3Bs9z379YWLC/1mZqDOWJt1clWnWzYMPQRDIRfayPoz8D1", + "kXtjM/8CJfbdzK4cCGbWLwp+rgDV2kbZK86ec//l/v8HAAD//x11ePR5RQEA", } // GetSwagger returns the content of the embedded swagger specification file
internal/api/client/client.gen.go+16 −0 modified@@ -4117,7 +4117,9 @@ type GetSourceDownloadURLResponse struct { JSON200 *PresignedUrl JSON400 *Error JSON401 *Error + JSON403 *Error JSON404 *Error + JSON500 *Error } // Status returns HTTPResponse.Status @@ -7052,13 +7054,27 @@ func ParseGetSourceDownloadURLResponse(rsp *http.Response) (*GetSourceDownloadUR } response.JSON401 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON404 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + } return response, nil
internal/api/server/server.gen.go+18 −0 modified@@ -4131,6 +4131,15 @@ func (response GetSourceDownloadURL401JSONResponse) VisitGetSourceDownloadURLRes return json.NewEncoder(w).Encode(response) } +type GetSourceDownloadURL403JSONResponse Error + +func (response GetSourceDownloadURL403JSONResponse) VisitGetSourceDownloadURLResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + type GetSourceDownloadURL404JSONResponse Error func (response GetSourceDownloadURL404JSONResponse) VisitGetSourceDownloadURLResponse(w http.ResponseWriter) error { @@ -4140,6 +4149,15 @@ func (response GetSourceDownloadURL404JSONResponse) VisitGetSourceDownloadURLRes return json.NewEncoder(w).Encode(response) } +type GetSourceDownloadURL500JSONResponse Error + +func (response GetSourceDownloadURL500JSONResponse) VisitGetSourceDownloadURLResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + type UpdateInventoryRequestObject struct { Id openapi_types.UUID `json:"id"` Body *UpdateInventoryJSONRequestBody
internal/handlers/v1alpha1/source.go+13 −2 modified@@ -234,15 +234,26 @@ func (s *ServiceHandler) HeadImage(ctx context.Context, request server.HeadImage // (GET /api/v1/sources/{id}/image-url) func (s *ServiceHandler) GetSourceDownloadURL(ctx context.Context, request server.GetSourceDownloadURLRequestObject) (server.GetSourceDownloadURLResponseObject, error) { - url, expireAt, err := s.sourceSrv.GetSourceDownloadURL(ctx, request.Id) + source, err := s.sourceSrv.GetSource(ctx, request.Id) if err != nil { switch err.(type) { case *service.ErrResourceNotFound: return server.GetSourceDownloadURL404JSONResponse{Message: err.Error()}, nil default: - return server.GetSourceDownloadURL400JSONResponse{}, nil // FIX: should be 500 + return server.GetSourceDownloadURL500JSONResponse{Message: fmt.Sprintf("failed to load source %s: %v", request.Id, err)}, nil } } + + user := auth.MustHaveUser(ctx) + if user.Username != source.Username || user.Organization != source.OrgID { + message := fmt.Sprintf("forbidden to access source %s by user with org_id %s", request.Id, user.Organization) + return server.GetSourceDownloadURL403JSONResponse{Message: message}, nil + } + + url, expireAt, err := s.sourceSrv.GetSourceDownloadURL(ctx, request.Id) + if err != nil { + return server.GetSourceDownloadURL500JSONResponse{Message: fmt.Sprintf("failed to get download URL for source %s: %v", request.Id, err)}, nil + } return server.GetSourceDownloadURL200JSONResponse{Url: url, ExpiresAt: &expireAt}, nil }
internal/handlers/v1alpha1/source_test.go+121 −0 modified@@ -1091,4 +1091,125 @@ var _ = Describe("source handler", Ordered, func() { gormdb.Exec("DELETE FROM sources;") }) }) + + Context("GetSourceDownloadURL", func() { + It("successfully returns image URL for owned source", func() { + sourceID := uuid.New() + tx := gormdb.Exec(fmt.Sprintf(insertSourceWithUsernameStm, sourceID, "admin", "admin")) + Expect(tx.Error).To(BeNil()) + + // Insert image_infra record (required for URL generation) + insertImageInfraStm := `INSERT INTO image_infras (source_id) VALUES ('%s');` + tx = gormdb.Exec(fmt.Sprintf(insertImageInfraStm, sourceID)) + Expect(tx.Error).To(BeNil()) + + user := auth.User{ + Username: "admin", + Organization: "admin", + EmailDomain: "admin.example.com", + } + ctx := auth.NewTokenContext(context.TODO(), user) + + srv := handlers.NewServiceHandler(service.NewSourceService(s, nil), service.NewAssessmentService(s, nil, nil), nil, service.NewSizerService(nil, s), nil, nil, nil) + resp, err := srv.GetSourceDownloadURL(ctx, server.GetSourceDownloadURLRequestObject{Id: sourceID}) + Expect(err).To(BeNil()) + Expect(reflect.TypeOf(resp).String()).To(Equal(reflect.TypeOf(server.GetSourceDownloadURL200JSONResponse{}).String())) + + result := resp.(server.GetSourceDownloadURL200JSONResponse) + Expect(result.Url).NotTo(BeEmpty()) + Expect(result.ExpiresAt).NotTo(BeNil()) + }) + + It("returns 403 when trying to access another org's source", func() { + // Create source owned by "batman" org + victimSourceID := uuid.New() + tx := gormdb.Exec(fmt.Sprintf(insertSourceWithUsernameStm, victimSourceID, "batman", "batman")) + Expect(tx.Error).To(BeNil()) + + insertImageInfraStm := `INSERT INTO image_infras (source_id) VALUES ('%s');` + tx = gormdb.Exec(fmt.Sprintf(insertImageInfraStm, victimSourceID)) + Expect(tx.Error).To(BeNil()) + + // Attempt to access with "joker" credentials + attackerUser := auth.User{ + Username: "joker", + Organization: "joker", + EmailDomain: "joker.example.com", + } + ctx := auth.NewTokenContext(context.TODO(), attackerUser) + + srv := handlers.NewServiceHandler(service.NewSourceService(s, nil), service.NewAssessmentService(s, nil, nil), nil, service.NewSizerService(nil, s), nil, nil, nil) + resp, err := srv.GetSourceDownloadURL(ctx, server.GetSourceDownloadURLRequestObject{Id: victimSourceID}) + + Expect(err).To(BeNil()) + Expect(reflect.TypeOf(resp).String()).To(Equal(reflect.TypeOf(server.GetSourceDownloadURL403JSONResponse{}).String())) + }) + + It("returns 403 when accessing with same username but different org", func() { + // Create source owned by "batman" user in "batman-org" + victimSourceID := uuid.New() + tx := gormdb.Exec(fmt.Sprintf(insertSourceWithUsernameStm, victimSourceID, "batman", "batman-org")) + Expect(tx.Error).To(BeNil()) + + insertImageInfraStm := `INSERT INTO image_infras (source_id) VALUES ('%s');` + tx = gormdb.Exec(fmt.Sprintf(insertImageInfraStm, victimSourceID)) + Expect(tx.Error).To(BeNil()) + + // Attempt to access with same username but different organization + attackerUser := auth.User{ + Username: "batman", + Organization: "evil-org", + EmailDomain: "evil.example.com", + } + ctx := auth.NewTokenContext(context.TODO(), attackerUser) + + srv := handlers.NewServiceHandler(service.NewSourceService(s, nil), service.NewAssessmentService(s, nil, nil), nil, service.NewSizerService(nil, s), nil, nil, nil) + resp, err := srv.GetSourceDownloadURL(ctx, server.GetSourceDownloadURLRequestObject{Id: victimSourceID}) + + Expect(err).To(BeNil()) + Expect(reflect.TypeOf(resp).String()).To(Equal(reflect.TypeOf(server.GetSourceDownloadURL403JSONResponse{}).String())) + }) + + It("returns 404 for non-existent source", func() { + user := auth.User{ + Username: "admin", + Organization: "admin", + EmailDomain: "admin.example.com", + } + ctx := auth.NewTokenContext(context.TODO(), user) + + srv := handlers.NewServiceHandler(service.NewSourceService(s, nil), service.NewAssessmentService(s, nil, nil), nil, service.NewSizerService(nil, s), nil, nil, nil) + resp, err := srv.GetSourceDownloadURL(ctx, server.GetSourceDownloadURLRequestObject{Id: uuid.New()}) + + Expect(err).To(BeNil()) + Expect(reflect.TypeOf(resp).String()).To(Equal(reflect.TypeOf(server.GetSourceDownloadURL404JSONResponse{}).String())) + }) + + It("returns 500 when URL generation fails after auth succeeds", func() { + sourceID := uuid.New() + tx := gormdb.Exec(fmt.Sprintf(insertSourceWithUsernameStm, sourceID, "admin", "admin")) + Expect(tx.Error).To(BeNil()) + + // NOTE: Deliberately NOT inserting image_infra record + // This passes auth (user owns source) but fails during URL generation + + user := auth.User{ + Username: "admin", + Organization: "admin", + EmailDomain: "admin.example.com", + } + ctx := auth.NewTokenContext(context.TODO(), user) + + srv := handlers.NewServiceHandler(service.NewSourceService(s, nil), service.NewAssessmentService(s, nil, nil), nil, service.NewSizerService(nil, s), nil, nil, nil) + resp, err := srv.GetSourceDownloadURL(ctx, server.GetSourceDownloadURLRequestObject{Id: sourceID}) + + Expect(err).To(BeNil()) + Expect(reflect.TypeOf(resp).String()).To(Equal(reflect.TypeOf(server.GetSourceDownloadURL500JSONResponse{}).String())) + }) + + AfterEach(func() { + gormdb.Exec("DELETE FROM image_infras;") + gormdb.Exec("DELETE FROM sources;") + }) + }) })
Vulnerability mechanics
Root cause
"The GetSourceDownloadURL endpoint in migration-planner improperly checks user ownership of a source."
Attack vector
An authenticated attacker can send a GET request to the `/api/v1/sources/{id}/image-url` endpoint with the ID of a source they do not own. The vulnerability exists because the endpoint previously did not enforce that the authenticated user's organization matched the source's organization [ref_id=2]. This allows an attacker to bypass ownership checks and obtain presigned S3 URLs for OVA images belonging to other users [ref_id=1].
Affected code
The vulnerability resides in the `GetSourceDownloadURL` function within `internal/handlers/v1alpha1/source.go`. The fix modifies this function to include the ownership check. The OpenAPI specification and generated client/server code were also updated to reflect the new 403 response for unauthorized access.
What the fix does
The patch introduces an organization check within the GetSourceDownloadURL handler. It now first fetches the source details and then compares the authenticated user's organization ID with the source's organization ID. If they do not match, a 403 Forbidden response is returned, preventing unauthorized access to other users' OVA images [patch_id=5486346]. This addresses the improper access control by enforcing ownership before generating download URLs.
Preconditions
- authThe attacker must be authenticated.
- inputThe attacker needs to know the UUID of a source belonging to another user.
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.