Arbitrary executable upload via authenticated endpoint
Description
In Juju versions prior to 3.6.8 and 2.9.52, any authenticated controller user was allowed to upload arbitrary agent binaries to any model or to the controller itself, without verifying model membership or requiring explicit permissions. This enabled the distribution of poisoned binaries to new or upgraded machines, potentially resulting in remote code execution.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/juju/jujuGo | < 0.0.0-20250619215741-4034aa13c7cf | 0.0.0-20250619215741-4034aa13c7cf |
Affected products
1- Range: 2.0.0
Patches
422cdcf6b54c2fix: ensure users uploading tools need admin permission on the model
2 files changed · +35 −13
apiserver/apiserver.go+12 −11 modified@@ -708,11 +708,12 @@ func (srv *Server) endpoints() ([]apihttp.Endpoint, error) { healthHandler := srv.monitoredHandler(http.HandlerFunc(srv.healthHandler), "health") logStreamHandler := srv.monitoredHandler(newLogStreamEndpointHandler(httpCtxt), "logstream") embeddedCLIHandler := srv.monitoredHandler(newEmbeddedCLIHandler(httpCtxt), "commands") + controllerAdminAuthorizer := controllerAdminAuthorizer{ + controllerTag: systemState.ControllerTag(), + } var debuglogAuth httpcontext.CompositeAuthorizer = []authentication.Authorizer{ tagKindAuthorizer{names.MachineTagKind, names.ControllerAgentTagKind}, - controllerAdminAuthorizer{ - controllerTag: systemState.ControllerTag(), - }, + controllerAdminAuthorizer, modelPermissionAuthorizer{ perm: permission.ReadAccess, }, @@ -757,9 +758,7 @@ func (srv *Server) endpoints() ([]apihttp.Endpoint, error) { GetHandler: modelCharmsHandler.ServeGet, }, "charms") var modelCharmsUploadAuthorizer httpcontext.CompositeAuthorizer = []authentication.Authorizer{ - controllerAdminAuthorizer{ - controllerTag: systemState.ControllerTag(), - }, + controllerAdminAuthorizer, modelPermissionAuthorizer{ perm: permission.WriteAccess, }, @@ -779,7 +778,12 @@ func (srv *Server) endpoints() ([]apihttp.Endpoint, error) { ctxt: httpCtxt, stateAuthFunc: httpCtxt.stateForRequestAuthenticatedUser, }, "tools") - modelToolsUploadAuthorizer := tagKindAuthorizer{names.UserTagKind} + var modelToolsUploadAuthorizer httpcontext.CompositeAuthorizer = []authentication.Authorizer{ + controllerAdminAuthorizer, + modelPermissionAuthorizer{ + perm: permission.AdminAccess, + }, + } modelToolsDownloadHandler := srv.monitoredHandler(newToolsDownloadHandler(httpCtxt), "tools") resourcesHandler := srv.monitoredHandler(&ResourcesHandler{ StateAuthFunc: func(req *http.Request, tagKinds ...string) (ResourcesBackend, state.PoolHelper, names.Tag, @@ -826,9 +830,6 @@ func (srv *Server) endpoints() ([]apihttp.Endpoint, error) { }, }, "units") - controllerAdminAuthorizer := controllerAdminAuthorizer{ - controllerTag: systemState.ControllerTag(), - } migrateCharmsHandler := &charmsHandler{ ctxt: httpCtxt, dataDir: srv.dataDir, @@ -983,7 +984,7 @@ func (srv *Server) endpoints() ([]apihttp.Endpoint, error) { }, { pattern: "/tools", handler: modelToolsUploadHandler, - authorizer: modelToolsUploadAuthorizer, + authorizer: controllerAdminAuthorizer, }, { pattern: "/tools/:version", handler: modelToolsDownloadHandler,
apiserver/tools_test.go+23 −2 modified@@ -24,6 +24,7 @@ import ( gc "gopkg.in/check.v1" apitesting "github.com/juju/juju/apiserver/testing" + "github.com/juju/juju/core/permission" "github.com/juju/juju/environs" "github.com/juju/juju/environs/simplestreams" "github.com/juju/juju/environs/storage" @@ -177,7 +178,7 @@ func (s *toolsSuite) TestRequiresPOST(c *gc.C) { s.assertJSONErrorResponse(c, resp, http.StatusMethodNotAllowed, `unsupported method: "PUT"`) } -func (s *toolsSuite) TestAuthRequiresUser(c *gc.C) { +func (s *toolsSuite) TestAuthRejectsNonsUser(c *gc.C) { // Add a machine and try to login. machine, err := s.State.AddMachine(state.UbuntuBase("12.10"), state.JobHostUnits) c.Assert(err, jc.ErrorIsNil) @@ -197,14 +198,34 @@ func (s *toolsSuite) TestAuthRequiresUser(c *gc.C) { }) s.assertPlainErrorResponse( c, resp, http.StatusForbidden, - "authorization failed: tag kind machine not valid", + "authorization failed: permission denied", ) // Now try a user login. resp = s.sendHTTPRequest(c, apitesting.HTTPRequestParams{Method: "POST", URL: s.toolsURI("")}) s.assertJSONErrorResponse(c, resp, http.StatusBadRequest, "expected binaryVersion argument") } +func (s *toolsSuite) TestAuthRejectsUserWithoutPermission(c *gc.C) { + u := s.Factory.MakeUser(c, &factory.UserParams{ + Name: "oryx", + Password: "gardener", + Access: permission.WriteAccess, + }) + + resp := apitesting.SendHTTPRequest(c, apitesting.HTTPRequestParams{ + Tag: u.Tag().String(), + Password: "gardener", + Method: "POST", + URL: s.toolsURI(""), + Nonce: "fake_nonce", + }) + s.assertPlainErrorResponse( + c, resp, http.StatusForbidden, + "authorization failed: permission denied", + ) +} + func (s *toolsSuite) TestUploadRequiresVersion(c *gc.C) { resp := s.sendHTTPRequest(c, apitesting.HTTPRequestParams{Method: "POST", URL: s.toolsURI("")}) s.assertJSONErrorResponse(c, resp, http.StatusBadRequest, "expected binaryVersion argument")
4034aa13c7cffix: ensure users uploading tools need admin permission on the model
2 files changed · +33 −4
apiserver/apiserver.go+10 −2 modified@@ -752,7 +752,15 @@ func (srv *Server) endpoints() ([]apihttp.Endpoint, error) { ctxt: httpCtxt, stateAuthFunc: httpCtxt.stateForRequestAuthenticatedUser, } - modelToolsUploadAuthorizer := tagKindAuthorizer{names.UserTagKind} + var modelToolsUploadAuthorizer httpcontext.CompositeAuthorizer = []httpcontext.Authorizer{ + controllerAdminAuthorizer{ + st: systemState, + }, + modelPermissionAuthorizer{ + userAccess: systemState.UserPermission, + perm: permission.AdminAccess, + }, + } modelToolsDownloadHandler := newToolsDownloadHandler(httpCtxt) resourcesHandler := &ResourcesHandler{ StateAuthFunc: func(req *http.Request, tagKinds ...string) (ResourcesBackend, state.PoolHelper, names.Tag, error) { @@ -937,7 +945,7 @@ func (srv *Server) endpoints() ([]apihttp.Endpoint, error) { }, { pattern: "/tools", handler: modelToolsUploadHandler, - authorizer: modelToolsUploadAuthorizer, + authorizer: controllerAdminAuthorizer, }, { pattern: "/tools/:version", handler: modelToolsDownloadHandler,
apiserver/tools_test.go+23 −2 modified@@ -24,6 +24,7 @@ import ( gc "gopkg.in/check.v1" apitesting "github.com/juju/juju/apiserver/testing" + "github.com/juju/juju/core/permission" "github.com/juju/juju/environs" "github.com/juju/juju/environs/simplestreams" "github.com/juju/juju/environs/storage" @@ -177,7 +178,7 @@ func (s *toolsSuite) TestRequiresPOST(c *gc.C) { s.assertJSONErrorResponse(c, resp, http.StatusMethodNotAllowed, `unsupported method: "PUT"`) } -func (s *toolsSuite) TestAuthRequiresUser(c *gc.C) { +func (s *toolsSuite) TestAuthRejectsNonsUser(c *gc.C) { // Add a machine and try to login. machine, err := s.State.AddMachine("quantal", state.JobHostUnits) c.Assert(err, jc.ErrorIsNil) @@ -197,14 +198,34 @@ func (s *toolsSuite) TestAuthRequiresUser(c *gc.C) { }) s.assertPlainErrorResponse( c, resp, http.StatusForbidden, - "authorization failed: tag kind machine not valid", + "authorization failed: permission denied", ) // Now try a user login. resp = s.sendHTTPRequest(c, apitesting.HTTPRequestParams{Method: "POST", URL: s.toolsURI("")}) s.assertJSONErrorResponse(c, resp, http.StatusBadRequest, "expected binaryVersion argument") } +func (s *toolsSuite) TestAuthRejectsUserWithoutPermission(c *gc.C) { + u := s.Factory.MakeUser(c, &factory.UserParams{ + Name: "oryx", + Password: "gardener", + Access: permission.WriteAccess, + }) + + resp := apitesting.SendHTTPRequest(c, apitesting.HTTPRequestParams{ + Tag: u.Tag().String(), + Password: "gardener", + Method: "POST", + URL: s.toolsURI(""), + Nonce: "fake_nonce", + }) + s.assertPlainErrorResponse( + c, resp, http.StatusForbidden, + "authorization failed: permission denied", + ) +} + func (s *toolsSuite) TestUploadRequiresVersion(c *gc.C) { resp := s.sendHTTPRequest(c, apitesting.HTTPRequestParams{Method: "POST", URL: s.toolsURI("")}) s.assertJSONErrorResponse(c, resp, http.StatusBadRequest, "expected binaryVersion argument")
311e374cb8d2fix: ensure users uploading charms need write permission on the model
2 files changed · +61 −3
apiserver/apiserver.go+8 −1 modified@@ -756,7 +756,14 @@ func (srv *Server) endpoints() ([]apihttp.Endpoint, error) { PostHandler: modelCharmsHandler.ServePost, GetHandler: modelCharmsHandler.ServeGet, }, "charms") - modelCharmsUploadAuthorizer := tagKindAuthorizer{names.UserTagKind} + var modelCharmsUploadAuthorizer httpcontext.CompositeAuthorizer = []authentication.Authorizer{ + controllerAdminAuthorizer{ + controllerTag: systemState.ControllerTag(), + }, + modelPermissionAuthorizer{ + perm: permission.WriteAccess, + }, + } modelObjectsCharmsHandler := &objectsCharmHandler{ ctxt: httpCtxt,
apiserver/charms_test.go+53 −2 modified@@ -23,6 +23,7 @@ import ( "github.com/juju/juju/apiserver/common" apitesting "github.com/juju/juju/apiserver/testing" + "github.com/juju/juju/core/permission" jujutesting "github.com/juju/juju/juju/testing" "github.com/juju/juju/rpc/params" "github.com/juju/juju/state" @@ -128,7 +129,7 @@ func (s *charmsSuite) TestRequiresPOSTorGET(c *gc.C) { c.Assert(string(body), gc.Equals, "Method Not Allowed\n") } -func (s *charmsSuite) TestPOSTRequiresUserAuth(c *gc.C) { +func (s *charmsSuite) TestPOSTRejectsNonUserAuth(c *gc.C) { // Add a machine and try to login. machine, password := s.Factory.MakeMachineReturningPassword(c, &factory.MachineParams{ Nonce: "noncy", @@ -142,13 +143,63 @@ func (s *charmsSuite) TestPOSTRequiresUserAuth(c *gc.C) { ContentType: "foo/bar", }) body := apitesting.AssertResponse(c, resp, http.StatusForbidden, "text/plain; charset=utf-8") - c.Assert(string(body), gc.Equals, "authorization failed: tag kind machine not valid\n") + c.Assert(string(body), gc.Equals, "authorization failed: permission denied\n") // Now try a user login. resp = s.sendHTTPRequest(c, apitesting.HTTPRequestParams{Method: "POST", URL: s.charmsURI("")}) s.assertErrorResponse(c, resp, http.StatusBadRequest, ".*expected Content-Type: application/zip.+") } +func (s *charmsSuite) TestPOSTRejectsUserWithoutPermission(c *gc.C) { + u := s.Factory.MakeUser(c, &factory.UserParams{ + Name: "oryx", + Password: "gardener", + NoModelUser: true, + }) + + resp := apitesting.SendHTTPRequest(c, apitesting.HTTPRequestParams{ + Tag: u.Tag().String(), + Password: "gardener", + Method: "POST", + URL: s.charmsURI(""), + Nonce: "noncy", + ContentType: "foo/bar", + }) + body := apitesting.AssertResponse(c, resp, http.StatusForbidden, "text/plain; charset=utf-8") + c.Assert(string(body), gc.Equals, "authorization failed: permission denied\n") + + // Now try a user login. + resp = s.sendHTTPRequest(c, apitesting.HTTPRequestParams{Method: "POST", URL: s.charmsURI("")}) + s.assertErrorResponse(c, resp, http.StatusBadRequest, ".*expected Content-Type: application/zip.+") +} + +func (s *charmsSuite) TestPOSTAllowsUserWithWritePermission(c *gc.C) { + u := s.Factory.MakeUser(c, &factory.UserParams{ + Name: "oryx", + Password: "gardener", + Access: permission.WriteAccess, + }) + + pathToArchive := testcharms.Repo.CharmArchivePath(c.MkDir(), "dummy") + ch, err := charm.ReadCharmArchive(pathToArchive) + c.Assert(err, gc.IsNil) + f, err := os.Open(ch.Path) + c.Assert(err, jc.ErrorIsNil) + defer f.Close() + resp := apitesting.SendHTTPRequest(c, apitesting.HTTPRequestParams{ + Tag: u.Tag().String(), + Password: "gardener", + Method: "POST", + URL: s.charmsURI("?series=quantal"), + Nonce: "noncy", + ContentType: "application/zip", + Body: f, + }) + + inputURL := charm.MustParseURL("local:quantal/dummy-1") + s.assertUploadResponse(c, resp, inputURL.String()) +} + func (s *charmsSuite) TestUploadFailsWithInvalidZip(c *gc.C) { var empty bytes.Buffer
b4176e6e45c2fix: ensure users uploading charms need write permission on the model
2 files changed · +62 −3
apiserver/apiserver.go+9 −1 modified@@ -739,7 +739,15 @@ func (srv *Server) endpoints() ([]apihttp.Endpoint, error) { PostHandler: modelCharmsHandler.ServePost, GetHandler: modelCharmsHandler.ServeGet, } - modelCharmsUploadAuthorizer := tagKindAuthorizer{names.UserTagKind} + var modelCharmsUploadAuthorizer httpcontext.CompositeAuthorizer = []httpcontext.Authorizer{ + controllerAdminAuthorizer{ + st: systemState, + }, + modelPermissionAuthorizer{ + userAccess: systemState.UserPermission, + perm: permission.WriteAccess, + }, + } modelToolsUploadHandler := &toolsUploadHandler{ ctxt: httpCtxt, stateAuthFunc: httpCtxt.stateForRequestAuthenticatedUser,
apiserver/charms_test.go+53 −2 modified@@ -26,6 +26,7 @@ import ( "github.com/juju/juju/apiserver/common" apitesting "github.com/juju/juju/apiserver/testing" "github.com/juju/juju/controller" + "github.com/juju/juju/core/permission" "github.com/juju/juju/feature" jujutesting "github.com/juju/juju/juju/testing" "github.com/juju/juju/rpc/params" @@ -136,7 +137,7 @@ func (s *charmsSuite) TestRequiresPOSTorGET(c *gc.C) { c.Assert(string(body), gc.Equals, "Method Not Allowed\n") } -func (s *charmsSuite) TestPOSTRequiresUserAuth(c *gc.C) { +func (s *charmsSuite) TestPOSTRejectsNonUserAuth(c *gc.C) { // Add a machine and try to login. machine, password := s.Factory.MakeMachineReturningPassword(c, &factory.MachineParams{ Nonce: "noncy", @@ -150,13 +151,63 @@ func (s *charmsSuite) TestPOSTRequiresUserAuth(c *gc.C) { ContentType: "foo/bar", }) body := apitesting.AssertResponse(c, resp, http.StatusForbidden, "text/plain; charset=utf-8") - c.Assert(string(body), gc.Equals, "authorization failed: tag kind machine not valid\n") + c.Assert(string(body), gc.Equals, "authorization failed: permission denied\n") // Now try a user login. resp = s.sendHTTPRequest(c, apitesting.HTTPRequestParams{Method: "POST", URL: s.charmsURI("")}) s.assertErrorResponse(c, resp, http.StatusBadRequest, ".*expected Content-Type: application/zip.+") } +func (s *charmsSuite) TestPOSTRejectsUserWithoutPermission(c *gc.C) { + u := s.Factory.MakeUser(c, &factory.UserParams{ + Name: "oryx", + Password: "gardener", + NoModelUser: true, + }) + + resp := apitesting.SendHTTPRequest(c, apitesting.HTTPRequestParams{ + Tag: u.Tag().String(), + Password: "gardener", + Method: "POST", + URL: s.charmsURI(""), + Nonce: "noncy", + ContentType: "foo/bar", + }) + body := apitesting.AssertResponse(c, resp, http.StatusForbidden, "text/plain; charset=utf-8") + c.Assert(string(body), gc.Equals, "authorization failed: permission denied\n") + + // Now try a user login. + resp = s.sendHTTPRequest(c, apitesting.HTTPRequestParams{Method: "POST", URL: s.charmsURI("")}) + s.assertErrorResponse(c, resp, http.StatusBadRequest, ".*expected Content-Type: application/zip.+") +} + +func (s *charmsSuite) TestPOSTAllowsUserWithWritePermission(c *gc.C) { + u := s.Factory.MakeUser(c, &factory.UserParams{ + Name: "oryx", + Password: "gardener", + Access: permission.WriteAccess, + }) + + pathToArchive := testcharms.Repo.CharmArchivePath(c.MkDir(), "dummy") + ch, err := charm.ReadCharmArchive(pathToArchive) + c.Assert(err, gc.IsNil) + f, err := os.Open(ch.Path) + c.Assert(err, jc.ErrorIsNil) + defer f.Close() + resp := apitesting.SendHTTPRequest(c, apitesting.HTTPRequestParams{ + Tag: u.Tag().String(), + Password: "gardener", + Method: "POST", + URL: s.charmsURI("?series=quantal"), + Nonce: "noncy", + ContentType: "application/zip", + Body: f, + }) + + inputURL := charm.MustParseURL("local:quantal/dummy-1") + s.assertUploadResponse(c, resp, inputURL.String()) +} + func (s *charmsSuite) TestUploadFailsWithInvalidZip(c *gc.C) { var empty bytes.Buffer
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
8- github.com/advisories/GHSA-4vc8-wvhw-m5gvghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-0928ghsaADVISORY
- github.com/juju/juju/commit/22cdcf6b54c2f371822e1c203d4f341be6c9589eghsaWEB
- github.com/juju/juju/commit/311e374cb8d2431032c51fb3fb5c4b0aaaa7196cghsaWEB
- github.com/juju/juju/commit/4034aa13c7cf5a37427fcd032925d5d21955b096ghsaWEB
- github.com/juju/juju/commit/b4176e6e45c2c3c817ab60b39e2d52f9a11a5ddfghsaWEB
- github.com/juju/juju/security/advisories/GHSA-4vc8-wvhw-m5gvghsaWEB
- pkg.go.dev/vuln/GO-2025-3805ghsaWEB
News mentions
0No linked articles in our index yet.