diff --git a/api/http/handler/websocket/attach_test.go b/api/http/handler/websocket/attach_test.go new file mode 100644 index 0000000000..b9e27cd4ea --- /dev/null +++ b/api/http/handler/websocket/attach_test.go @@ -0,0 +1,34 @@ +package websocket + +import ( + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/security" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestWebsocketAttach_deniesUnauthorizedEndpoint asserts a non-admin with no access policy on +// the environment is rejected with 403 — the environment-access (L2) gate (BE-13027). +func TestWebsocketAttach_deniesUnauthorizedEndpoint(t *testing.T) { + handler, _ := newWebsocketTestHandler(t) + + user := &portainer.User{Username: "restricted", Role: portainer.StandardUserRole} + err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + return tx.User().Create(user) + }) + require.NoError(t, err) + + // attach requires a hexadecimal `id` query parameter to reach the authorization check. + req := httptest.NewRequest(http.MethodGet, "/websocket/attach?id=abcdef&endpointId=2", nil) + req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: user.ID, Role: portainer.StandardUserRole})) + + handlerErr := handler.websocketAttach(httptest.NewRecorder(), req) + + require.NotNil(t, handlerErr, "expected an authorization error for a denied environment") + assert.Equal(t, http.StatusForbidden, handlerErr.StatusCode) +} diff --git a/api/http/handler/websocket/exec_test.go b/api/http/handler/websocket/exec_test.go new file mode 100644 index 0000000000..fedfbb56de --- /dev/null +++ b/api/http/handler/websocket/exec_test.go @@ -0,0 +1,34 @@ +package websocket + +import ( + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/security" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestWebsocketExec_deniesUnauthorizedEndpoint asserts a non-admin with no access policy on +// the environment is rejected with 403 — the environment-access (L2) gate (BE-13027). +func TestWebsocketExec_deniesUnauthorizedEndpoint(t *testing.T) { + handler, _ := newWebsocketTestHandler(t) + + user := &portainer.User{Username: "restricted", Role: portainer.StandardUserRole} + err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + return tx.User().Create(user) + }) + require.NoError(t, err) + + // exec requires a hexadecimal `id` query parameter to reach the authorization check. + req := httptest.NewRequest(http.MethodGet, "/websocket/exec?id=abcdef&endpointId=2", nil) + req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: user.ID, Role: portainer.StandardUserRole})) + + handlerErr := handler.websocketExec(httptest.NewRecorder(), req) + + require.NotNil(t, handlerErr, "expected an authorization error for a denied environment") + assert.Equal(t, http.StatusForbidden, handlerErr.StatusCode) +} diff --git a/api/http/handler/websocket/pod_test.go b/api/http/handler/websocket/pod_test.go new file mode 100644 index 0000000000..c8c6c29978 --- /dev/null +++ b/api/http/handler/websocket/pod_test.go @@ -0,0 +1,62 @@ +package websocket + +import ( + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/security" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// podExecQuery is the minimal set of query parameters required to reach the authorization +// check in websocketPodExec. +const podExecQuery = "/websocket/pod?endpointId=2&namespace=default&podName=p&containerName=c&command=sh" + +// TestWebsocketPodExec_deniesUnauthorizedEndpoint asserts a non-admin with no access policy on +// the environment is rejected with 403 — the environment-access (L2) gate (BE-13027). +func TestWebsocketPodExec_deniesUnauthorizedEndpoint(t *testing.T) { + handler, _ := newWebsocketTestHandler(t) + + user := &portainer.User{Username: "restricted", Role: portainer.StandardUserRole} + err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + return tx.User().Create(user) + }) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, podExecQuery, nil) + req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: user.ID, Role: portainer.StandardUserRole})) + + handlerErr := handler.websocketPodExec(httptest.NewRecorder(), req) + + require.NotNil(t, handlerErr, "expected an authorization error for a denied environment") + assert.Equal(t, http.StatusForbidden, handlerErr.StatusCode) +} + +// TestWebsocketPodExec_allowsAuthorizedNonAdmin asserts a non-admin granted environment access +// passes authorization (reaching the nil client via getToken and panicking). CE has no +// operation-level (L3) layer, so environment access is the only gate (BE-13027). +func TestWebsocketPodExec_allowsAuthorizedNonAdmin(t *testing.T) { + handler, endpoint := newWebsocketTestHandler(t) + + user := &portainer.User{Username: "standard", Role: portainer.StandardUserRole} + err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + if err := tx.User().Create(user); err != nil { + return err + } + // Access is by membership; the access policy's role is irrelevant to the CE access decision. + endpoint.UserAccessPolicies = portainer.UserAccessPolicies{user.ID: {}} + return tx.Endpoint().UpdateEndpoint(endpoint.ID, endpoint) + }) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, podExecQuery, nil) + req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: user.ID, Role: portainer.StandardUserRole})) + + assert.Panics(t, func() { + _ = handler.websocketPodExec(httptest.NewRecorder(), req) + }) +} diff --git a/api/http/handler/websocket/shell_pod.go b/api/http/handler/websocket/shell_pod.go index b472b596c2..a5e00933ce 100644 --- a/api/http/handler/websocket/shell_pod.go +++ b/api/http/handler/websocket/shell_pod.go @@ -21,6 +21,7 @@ import ( // @success 200 "Success" // @failure 400 "Invalid request" // @failure 403 "Permission denied" +// @failure 404 "Environment not found" // @failure 500 "Server error" // @router /websocket/kubernetes-shell [get] func (handler *Handler) websocketShellPodExec(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { @@ -36,9 +37,13 @@ func (handler *Handler) websocketShellPodExec(w http.ResponseWriter, r *http.Req return httperror.InternalServerError("Unable to find the environment associated to the stack inside the database", err) } + if err := handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint); err != nil { + return httperror.Forbidden("Permission denied to access environment", err) + } + tokenData, err := security.RetrieveTokenData(r) if err != nil { - return httperror.Forbidden("Permission denied to access environment", err) + return httperror.InternalServerError("Unable to retrieve user authentication token", err) } cli, err := handler.KubernetesClientFactory.GetPrivilegedKubeClient(endpoint) diff --git a/api/http/handler/websocket/shell_pod_test.go b/api/http/handler/websocket/shell_pod_test.go new file mode 100644 index 0000000000..d244374421 --- /dev/null +++ b/api/http/handler/websocket/shell_pod_test.go @@ -0,0 +1,101 @@ +package websocket + +import ( + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/http/security" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newWebsocketTestHandler builds a websocket Handler backed by a test store with a single +// Kubernetes environment (ID 2) and a real bouncer, returning both the handler and that +// endpoint so callers can grant access via its UserAccessPolicies. KubernetesClientFactory is +// left nil so any handler that proceeds past authorization trips a clear panic. Shared by the +// exec/attach/pod/kubernetes-shell L2 tests (BE-13027). +func newWebsocketTestHandler(t *testing.T) (*Handler, *portainer.Endpoint) { + t.Helper() + + _, store := datastore.MustNewTestStore(t, true, false) + + endpoint := &portainer.Endpoint{ + ID: 2, + Name: "target-env", + Type: portainer.AgentOnKubernetesEnvironment, + GroupID: 1, + } + require.NoError(t, store.Endpoint().Create(endpoint)) + + bouncer := security.NewRequestBouncer(t.Context(), store, nil, nil) + + handler := &Handler{ + DataStore: store, + requestBouncer: bouncer, + // KubernetesClientFactory intentionally left nil. + } + + return handler, endpoint +} + +// TestWebsocketShellPodExec_deniesUnauthorizedEndpoint asserts a non-admin with no access +// policy on the environment is rejected with 403 — the environment-access (L2) gate (BE-13027). +func TestWebsocketShellPodExec_deniesUnauthorizedEndpoint(t *testing.T) { + handler, _ := newWebsocketTestHandler(t) + + user := &portainer.User{Username: "restricted", Role: portainer.StandardUserRole} + err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + return tx.User().Create(user) + }) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, "/websocket/kubernetes-shell?endpointId=2", nil) + req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: user.ID, Role: portainer.StandardUserRole})) + + handlerErr := handler.websocketShellPodExec(httptest.NewRecorder(), req) + + require.NotNil(t, handlerErr, "expected an authorization error for a denied environment") + assert.Equal(t, http.StatusForbidden, handlerErr.StatusCode) +} + +// TestWebsocketShellPodExec_allowsAuthorizedEndpoint asserts an admin passes authorization and +// reaches the nil KubernetesClientFactory (panic proves auth did not block the request) (BE-13027). +func TestWebsocketShellPodExec_allowsAuthorizedEndpoint(t *testing.T) { + handler, _ := newWebsocketTestHandler(t) + + req := httptest.NewRequest(http.MethodGet, "/websocket/kubernetes-shell?endpointId=2", nil) + req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole})) + + assert.Panics(t, func() { + _ = handler.websocketShellPodExec(httptest.NewRecorder(), req) + }) +} + +// TestWebsocketShellPodExec_allowsAuthorizedNonAdmin asserts a non-admin granted environment +// access passes authorization (reaching the nil client and panicking). CE has no operation-level +// (L3) layer, so environment access is the only gate (BE-13027). +func TestWebsocketShellPodExec_allowsAuthorizedNonAdmin(t *testing.T) { + handler, endpoint := newWebsocketTestHandler(t) + + user := &portainer.User{Username: "standard", Role: portainer.StandardUserRole} + err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + if err := tx.User().Create(user); err != nil { + return err + } + // Access is by membership; the access policy's role is irrelevant to the CE access decision. + endpoint.UserAccessPolicies = portainer.UserAccessPolicies{user.ID: {}} + return tx.Endpoint().UpdateEndpoint(endpoint.ID, endpoint) + }) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, "/websocket/kubernetes-shell?endpointId=2", nil) + req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: user.ID, Role: portainer.StandardUserRole})) + + assert.Panics(t, func() { + _ = handler.websocketShellPodExec(httptest.NewRecorder(), req) + }) +}