From e99e7ac7807821a80f2440793a641708b1a9f40d Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Wed, 18 Jan 2023 18:47:59 +0000 Subject: [PATCH 1/4] fix(security)!: path-based app sharing changes This commit disables path-based app sharing by default. It is possible for a workspace app on a path (not a subdomain) to make API requests to the Coder API. When accessing your own workspace, this is not much of a problem. When accessing a shared workspace app, the workspace owner could include malicious javascript in the page that makes requests to the Coder API on behalf of the visitor. This vulnerability does not affect subdomain apps. - Disables path-based app sharing by default. Previous behavior can be restored using the `--dangerous-allow-path-app-sharing` flag which is not recommended. - Disables users with the site "owner" role from accessing path-based apps from workspaces they do not own. Previous behavior can be restored using the `--dangerous-allow-path-app-site-owner-access` flag which is not recommended. - Adds a flag `--disable-path-apps` which can be used by security-conscious admins to disable all path-based apps across the entire deployment. This check is enforced at app-access time, not at template-ingest time. --- cli/deployment/config.go | 20 ++ cli/testdata/coder_server_--help.golden | 30 +++ coderd/apidoc/docs.go | 17 ++ coderd/apidoc/swagger.json | 17 ++ coderd/workspaceapps.go | 53 +++++- coderd/workspaceapps_test.go | 241 +++++++++++++++++++----- codersdk/deploymentconfig.go | 7 + docs/api/general.md | 35 ++++ docs/api/schemas.md | 73 +++++++ site/src/api/typesGenerated.ts | 8 + 10 files changed, 441 insertions(+), 60 deletions(-) diff --git a/cli/deployment/config.go b/cli/deployment/config.go index 02b24f371ce6f..470a3278f3b42 100644 --- a/cli/deployment/config.go +++ b/cli/deployment/config.go @@ -491,6 +491,26 @@ func newConfig() *codersdk.DeploymentConfig { Default: "", }, }, + Dangerous: &codersdk.DangerousConfig{ + AllowPathAppSharing: &codersdk.DeploymentConfigField[bool]{ + Name: "DANGEROUS: Allow Path App Sharing", + Usage: "Allow workspace apps that are not served from subdomains to be shared. Path-based app sharing is DISABLED by default for security purposes. Path-based apps can make requests to the Coder API and pose a security risk when the workspace serves malicious Javascript. Path-based apps can be disabled entirely with --disable-path-apps for further security.", + Flag: "dangerous-allow-path-app-sharing", + Default: false, + }, + AllowPathAppSiteOwnerAccess: &codersdk.DeploymentConfigField[bool]{ + Name: "DANGEROUS: Allow Site Owners to Access Path Apps", + Usage: "Allow site-owners to access workspace apps from workspaces they do not own. Owners cannot access path-based apps they do not own by default. Path-based apps can make requests to the Coder API and pose a security risk when the workspace serves malicious Javascript. Path-based apps can be disabled entirely with --disable-path-apps for further security.", + Flag: "dangerous-allow-path-app-site-owner-access", + Default: false, + }, + }, + DisablePathApps: &codersdk.DeploymentConfigField[bool]{ + Name: "Disable Path Apps", + Usage: "Disable workspace apps that are not served from subdomains. Path-based apps can make requests to the Coder API and pose a security risk when the workspace serves malicious Javascript. This is recommended for security purposes if a --wildcard-access-url is configured.", + Flag: "disable-path-apps", + Default: false, + }, } } diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index 0424c2c8dd9f1..d01cce7416a18 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -29,6 +29,28 @@ Flags: with systemd. Consumes $CODER_CACHE_DIRECTORY (default "/tmp/coder-cli-test-cache") + --dangerous-allow-path-app-sharing Allow workspace apps that are not served + from subdomains to be shared. Path-based + app sharing is DISABLED by default for + security purposes. Path-based apps can + make requests to the Coder API and pose a + security risk when the workspace serves + malicious Javascript. Path-based apps can + be disabled entirely with + --disable-path-apps for further security. + Consumes + $CODER_DANGEROUS_ALLOW_PATH_APP_SHARING + --dangerous-allow-path-app-site-owner-access Allow site-owners to access workspace + apps from workspaces they do not own. + Owners cannot access path-based apps they + do not own by default. Path-based apps + can make requests to the Coder API and + pose a security risk when the workspace + serves malicious Javascript. Path-based + apps can be disabled entirely with + --disable-path-apps for further security. + Consumes + $CODER_DANGEROUS_ALLOW_PATH_APP_SITE_OWNER_ACCESS --dangerous-disable-rate-limits Disables all rate limits. This is not recommended in production. Consumes $CODER_RATE_LIMIT_DISABLE_ALL @@ -61,6 +83,14 @@ Flags: Consumes $CODER_DERP_SERVER_STUN_ADDRESSES (default [stun.l.google.com:19302]) + --disable-path-apps Disable workspace apps that are not + served from subdomains. Path-based apps + can make requests to the Coder API and + pose a security risk when the workspace + serves malicious Javascript. This is + recommended for security purposes if a + --wildcard-access-url is configured. + Consumes $CODER_DISABLE_PATH_APPS --experimental Enable experimental features. Experimental features are not ready for production. diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 177c4230c94a1..386a7dcc881c5 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -5704,6 +5704,17 @@ const docTemplate = `{ } } }, + "codersdk.DangerousConfig": { + "type": "object", + "properties": { + "allow_path_app_sharing": { + "$ref": "#/definitions/codersdk.DeploymentConfigField-bool" + }, + "allow_path_app_site_owner_access": { + "$ref": "#/definitions/codersdk.DeploymentConfigField-bool" + } + } + }, "codersdk.DeploymentConfig": { "type": "object", "properties": { @@ -5736,9 +5747,15 @@ const docTemplate = `{ "cache_directory": { "$ref": "#/definitions/codersdk.DeploymentConfigField-string" }, + "dangerous": { + "$ref": "#/definitions/codersdk.DangerousConfig" + }, "derp": { "$ref": "#/definitions/codersdk.DERP" }, + "disable_path_apps": { + "$ref": "#/definitions/codersdk.DeploymentConfigField-bool" + }, "experimental": { "$ref": "#/definitions/codersdk.DeploymentConfigField-bool" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index b9a34ede0b0f3..f7d571a941a75 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -5057,6 +5057,17 @@ } } }, + "codersdk.DangerousConfig": { + "type": "object", + "properties": { + "allow_path_app_sharing": { + "$ref": "#/definitions/codersdk.DeploymentConfigField-bool" + }, + "allow_path_app_site_owner_access": { + "$ref": "#/definitions/codersdk.DeploymentConfigField-bool" + } + } + }, "codersdk.DeploymentConfig": { "type": "object", "properties": { @@ -5089,9 +5100,15 @@ "cache_directory": { "$ref": "#/definitions/codersdk.DeploymentConfigField-string" }, + "dangerous": { + "$ref": "#/definitions/codersdk.DangerousConfig" + }, "derp": { "$ref": "#/definitions/codersdk.DERP" }, + "disable_path_apps": { + "$ref": "#/definitions/codersdk.DeploymentConfigField-bool" + }, "experimental": { "$ref": "#/definitions/codersdk.DeploymentConfigField-bool" }, diff --git a/coderd/workspaceapps.go b/coderd/workspaceapps.go index fe6cd564dfd8d..ed40c0cf4deac 100644 --- a/coderd/workspaceapps.go +++ b/coderd/workspaceapps.go @@ -89,6 +89,17 @@ func (api *API) workspaceAppsProxyPath(rw http.ResponseWriter, r *http.Request) workspace := httpmw.WorkspaceParam(r) agent := httpmw.WorkspaceAgentParam(r) + if api.DeploymentConfig.DisablePathApps.Value { + site.RenderStaticErrorPage(rw, r, site.ErrorPageData{ + Status: http.StatusUnauthorized, + Title: "Unauthorized", + Description: "Path-based applications are disabled on this Coder deployment by the administrator.", + RetryEnabled: false, + DashboardURL: api.AccessURL.String(), + }) + return + } + // We do not support port proxying on paths, so lookup the app by slug. appSlug := chi.URLParam(r, "workspaceapp") app, ok := api.lookupWorkspaceApp(rw, r, agent.ID, appSlug) @@ -100,7 +111,7 @@ func (api *API) workspaceAppsProxyPath(rw http.ResponseWriter, r *http.Request) if app.SharingLevel != "" { appSharingLevel = app.SharingLevel } - authed, ok := api.fetchWorkspaceApplicationAuth(rw, r, workspace, appSharingLevel) + authed, ok := api.fetchWorkspaceApplicationAuth(rw, r, true, workspace, appSharingLevel) if !ok { return } @@ -127,6 +138,7 @@ func (api *API) workspaceAppsProxyPath(rw http.ResponseWriter, r *http.Request) } api.proxyWorkspaceApplication(proxyApplication{ + IsPathApp: true, Workspace: workspace, Agent: agent, App: &app, @@ -238,6 +250,7 @@ func (api *API) handleSubdomainApplications(middlewares ...func(http.Handler) ht } api.proxyWorkspaceApplication(proxyApplication{ + IsPathApp: false, Workspace: workspace, Agent: agent, App: workspaceAppPtr, @@ -411,9 +424,17 @@ func (api *API) lookupWorkspaceApp(rw http.ResponseWriter, r *http.Request, agen return app, true } -func (api *API) authorizeWorkspaceApp(r *http.Request, sharingLevel database.AppSharingLevel, workspace database.Workspace) (bool, error) { +//nolint:revive +func (api *API) authorizeWorkspaceApp(r *http.Request, isPathApp bool, sharingLevel database.AppSharingLevel, workspace database.Workspace) (bool, error) { ctx := r.Context() + // If path-based app sharing is disabled (which is the default), we can + // force the sharing level to be "owner" so that the user can only access + // their own apps. + if isPathApp && !api.DeploymentConfig.Dangerous.AllowPathAppSharing.Value { + sharingLevel = database.AppSharingLevelOwner + } + // Short circuit if not authenticated. roles, ok := httpmw.UserAuthorizationOptional(r) if !ok { @@ -422,13 +443,26 @@ func (api *API) authorizeWorkspaceApp(r *http.Request, sharingLevel database.App return sharingLevel == database.AppSharingLevelPublic, nil } + // If owners are not allowed to access any path-based app (which is the + // default), we can remove the "owner" role from the list of roles so that + // the RBAC check doesn't give them God permissions. + checkRoles := roles.Roles + if !api.DeploymentConfig.Dangerous.AllowPathAppSiteOwnerAccess.Value { + checkRoles = make([]string, 0, len(roles.Roles)) + for _, role := range roles.Roles { + if role != rbac.RoleOwner() { + checkRoles = append(checkRoles, role) + } + } + } + // Do a standard RBAC check. This accounts for share level "owner" and any // other RBAC rules that may be in place. // // Regardless of share level or whether it's enabled or not, the owner of // the workspace can always access applications (as long as their API key's // scope allows it). - err := api.Authorizer.ByRoleName(ctx, roles.ID.String(), roles.Roles, roles.Scope.ToRBAC(), []string{}, rbac.ActionCreate, workspace.ApplicationConnectRBAC()) + err := api.Authorizer.ByRoleName(ctx, roles.ID.String(), checkRoles, roles.Scope.ToRBAC(), []string{}, rbac.ActionCreate, workspace.ApplicationConnectRBAC()) if err == nil { return true, nil } @@ -463,8 +497,8 @@ func (api *API) authorizeWorkspaceApp(r *http.Request, sharingLevel database.App // for a given app share level in the given workspace. The user's authorization // status is returned. If a server error occurs, a HTML error page is rendered // and false is returned so the caller can return early. -func (api *API) fetchWorkspaceApplicationAuth(rw http.ResponseWriter, r *http.Request, workspace database.Workspace, appSharingLevel database.AppSharingLevel) (authed bool, ok bool) { - ok, err := api.authorizeWorkspaceApp(r, appSharingLevel, workspace) +func (api *API) fetchWorkspaceApplicationAuth(rw http.ResponseWriter, r *http.Request, isPathApp bool, workspace database.Workspace, appSharingLevel database.AppSharingLevel) (authed bool, ok bool) { + ok, err := api.authorizeWorkspaceApp(r, isPathApp, appSharingLevel, workspace) if err != nil { api.Logger.Error(r.Context(), "authorize workspace app", slog.Error(err)) site.RenderStaticErrorPage(rw, r, site.ErrorPageData{ @@ -484,8 +518,8 @@ func (api *API) fetchWorkspaceApplicationAuth(rw http.ResponseWriter, r *http.Re // for a given app share level in the given workspace. If the user is not // authorized or a server error occurs, a discrete HTML error page is rendered // and false is returned so the caller can return early. -func (api *API) checkWorkspaceApplicationAuth(rw http.ResponseWriter, r *http.Request, workspace database.Workspace, appSharingLevel database.AppSharingLevel) bool { - authed, ok := api.fetchWorkspaceApplicationAuth(rw, r, workspace, appSharingLevel) +func (api *API) checkWorkspaceApplicationAuth(rw http.ResponseWriter, r *http.Request, isPathApp bool, workspace database.Workspace, appSharingLevel database.AppSharingLevel) bool { + authed, ok := api.fetchWorkspaceApplicationAuth(rw, r, isPathApp, workspace, appSharingLevel) if !ok { return false } @@ -502,7 +536,7 @@ func (api *API) checkWorkspaceApplicationAuth(rw http.ResponseWriter, r *http.Re // they will be redirected to the route below. If the user does have a session // key but insufficient permissions a static error page will be rendered. func (api *API) verifyWorkspaceApplicationSubdomainAuth(rw http.ResponseWriter, r *http.Request, host string, workspace database.Workspace, appSharingLevel database.AppSharingLevel) bool { - authed, ok := api.fetchWorkspaceApplicationAuth(rw, r, workspace, appSharingLevel) + authed, ok := api.fetchWorkspaceApplicationAuth(rw, r, true, workspace, appSharingLevel) if !ok { return false } @@ -731,6 +765,7 @@ func (api *API) workspaceApplicationAuth(rw http.ResponseWriter, r *http.Request // proxyApplication are the required fields to proxy a workspace application. type proxyApplication struct { + IsPathApp bool Workspace database.Workspace Agent database.WorkspaceAgent @@ -752,7 +787,7 @@ func (api *API) proxyWorkspaceApplication(proxyApp proxyApplication, rw http.Res if proxyApp.App != nil && proxyApp.App.SharingLevel != "" { sharingLevel = proxyApp.App.SharingLevel } - if !api.checkWorkspaceApplicationAuth(rw, r, proxyApp.Workspace, sharingLevel) { + if !api.checkWorkspaceApplicationAuth(rw, r, proxyApp.IsPathApp, proxyApp.Workspace, sharingLevel) { return } diff --git a/coderd/workspaceapps_test.go b/coderd/workspaceapps_test.go index 3b450153fec3b..9e01b6bd8a20b 100644 --- a/coderd/workspaceapps_test.go +++ b/coderd/workspaceapps_test.go @@ -108,10 +108,26 @@ func TestGetAppHost(t *testing.T) { } } +type setupProxyTestOpts struct { + AppHost string + DisablePathApps bool + DangerousAllowPathAppSharing bool + DangerousAllowPathAppSiteOwnerAccess bool + + NoWorkspace bool +} + // setupProxyTest creates a workspace with an agent and some apps. It returns a // codersdk client, the first user, the workspace, and the port number the test // listener is running on. -func setupProxyTest(t *testing.T, customAppHost ...string) (*codersdk.Client, codersdk.CreateFirstUserResponse, codersdk.Workspace, uint16) { +func setupProxyTest(t *testing.T, opts *setupProxyTestOpts) (*codersdk.Client, codersdk.CreateFirstUserResponse, *codersdk.Workspace, uint16) { + if opts == nil { + opts = &setupProxyTestOpts{} + } + if opts.AppHost == "" { + opts.AppHost = proxyTestSubdomainRaw + } + // #nosec ln, err := net.Listen("tcp", ":0") require.NoError(t, err) @@ -133,13 +149,14 @@ func setupProxyTest(t *testing.T, customAppHost ...string) (*codersdk.Client, co tcpAddr, ok := ln.Addr().(*net.TCPAddr) require.True(t, ok) - appHost := proxyTestSubdomainRaw - if len(customAppHost) > 0 { - appHost = customAppHost[0] - } + deploymentConfig := coderdtest.DeploymentConfig(t) + deploymentConfig.DisablePathApps.Value = opts.DisablePathApps + deploymentConfig.Dangerous.AllowPathAppSharing.Value = opts.DangerousAllowPathAppSharing + deploymentConfig.Dangerous.AllowPathAppSiteOwnerAccess.Value = opts.DangerousAllowPathAppSiteOwnerAccess client := coderdtest.New(t, &coderdtest.Options{ - AppHostname: appHost, + DeploymentConfig: deploymentConfig, + AppHostname: opts.AppHost, IncludeProvisionerDaemon: true, AgentStatsRefreshInterval: time.Millisecond * 100, MetricsCacheRefreshInterval: time.Millisecond * 100, @@ -156,7 +173,11 @@ func setupProxyTest(t *testing.T, customAppHost ...string) (*codersdk.Client, co user := coderdtest.CreateFirstUser(t, client) - workspace := createWorkspaceWithApps(t, client, user.OrganizationID, appHost, uint16(tcpAddr.Port)) + var workspace *codersdk.Workspace + if !opts.NoWorkspace { + ws := createWorkspaceWithApps(t, client, user.OrganizationID, opts.AppHost, uint16(tcpAddr.Port)) + workspace = &ws + } // Configure the HTTP client to not follow redirects and to route all // requests regardless of hostname to the coderd test server. @@ -234,6 +255,12 @@ func createWorkspaceWithApps(t *testing.T, client *codersdk.Client, orgID uuid.U workspace := coderdtest.CreateWorkspace(t, client, orgID, template.ID, workspaceMutators...) coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium) + defer cancel() + + user, err := client.User(ctx, codersdk.Me) + require.NoError(t, err) + agentClient := codersdk.New(client.URL) agentClient.SetSessionToken(authToken) if appHost != "" { @@ -243,7 +270,7 @@ func createWorkspaceWithApps(t *testing.T, client *codersdk.Client, orgID uuid.U "http://{{port}}--%s--%s--%s%s", proxyTestAgentName, workspace.Name, - "testuser", + user.Username, strings.ReplaceAll(appHost, "*", ""), ) if client.URL.Port() != "" { @@ -265,7 +292,34 @@ func createWorkspaceWithApps(t *testing.T, client *codersdk.Client, orgID uuid.U func TestWorkspaceAppsProxyPath(t *testing.T) { t.Parallel() - client, firstUser, workspace, _ := setupProxyTest(t) + client, firstUser, workspace, _ := setupProxyTest(t, nil) + + t.Run("Disabled", func(t *testing.T) { + t.Parallel() + + deploymentConfig := coderdtest.DeploymentConfig(t) + deploymentConfig.DisablePathApps.Value = true + + client := coderdtest.New(t, &coderdtest.Options{ + DeploymentConfig: deploymentConfig, + IncludeProvisionerDaemon: true, + AgentStatsRefreshInterval: time.Millisecond * 100, + MetricsCacheRefreshInterval: time.Millisecond * 100, + }) + user := coderdtest.CreateFirstUser(t, client) + workspace := createWorkspaceWithApps(t, client, user.OrganizationID, "", 0) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + resp, err := requestWithRetries(ctx, t, client, http.MethodGet, fmt.Sprintf("/@me/%s/apps/%s", workspace.Name, proxyTestAppNameOwner), nil) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusUnauthorized, resp.StatusCode) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Contains(t, string(body), "Path-based applications are disabled") + }) t.Run("LoginWithoutAuth", func(t *testing.T) { t.Parallel() @@ -384,7 +438,7 @@ func TestWorkspaceApplicationAuth(t *testing.T) { t.Run("End-to-End", func(t *testing.T) { t.Parallel() - client, firstUser, workspace, _ := setupProxyTest(t) + client, firstUser, workspace, _ := setupProxyTest(t, nil) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() @@ -511,7 +565,7 @@ func TestWorkspaceApplicationAuth(t *testing.T) { t.Run("VerifyRedirectURI", func(t *testing.T) { t.Parallel() - client, _, _, _ := setupProxyTest(t) + client, _, _, _ := setupProxyTest(t, nil) cases := []struct { name string @@ -655,7 +709,7 @@ func TestWorkspaceAppsProxySubdomainBlocked(t *testing.T) { func TestWorkspaceAppsProxySubdomain(t *testing.T) { t.Parallel() - client, firstUser, _, port := setupProxyTest(t) + client, firstUser, _, port := setupProxyTest(t, nil) // proxyURL generates a URL for the proxy subdomain. The default path is a // slash. @@ -829,7 +883,9 @@ func TestWorkspaceAppsProxySubdomain(t *testing.T) { t.Run("SuffixWildcardOK", func(t *testing.T) { t.Parallel() - client, _, _, _ := setupProxyTest(t, "*-suffix.test.coder.com") + client, _, _, _ := setupProxyTest(t, &setupProxyTestOpts{ + AppHost: "*-suffix.test.coder.com", + }) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() @@ -849,7 +905,9 @@ func TestWorkspaceAppsProxySubdomain(t *testing.T) { t.Run("SuffixWildcardNotMatch", func(t *testing.T) { t.Parallel() - client, _, _, _ := setupProxyTest(t, "*-suffix.test.coder.com") + client, _, _, _ := setupProxyTest(t, &setupProxyTestOpts{ + AppHost: "*-suffix.test.coder.com", + }) t.Run("NoSuffix", func(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) @@ -991,7 +1049,7 @@ func TestAppSubdomainLogout(t *testing.T) { t.Run(c.name, func(t *testing.T) { t.Parallel() - client, _, _, _ := setupProxyTest(t) + client, _, _, _ := setupProxyTest(t, nil) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() @@ -1085,18 +1143,52 @@ func TestAppSubdomainLogout(t *testing.T) { func TestAppSharing(t *testing.T) { t.Parallel() - setup := func(t *testing.T) (workspace codersdk.Workspace, agnt codersdk.WorkspaceAgent, user codersdk.User, client *codersdk.Client, clientInOtherOrg *codersdk.Client, clientWithNoAuth *codersdk.Client) { + setup := func(t *testing.T, allowPathAppSharing, allowSiteOwnerAccess bool) (workspace codersdk.Workspace, agnt codersdk.WorkspaceAgent, user codersdk.User, ownerClient *codersdk.Client, client *codersdk.Client, clientInOtherOrg *codersdk.Client, clientWithNoAuth *codersdk.Client) { //nolint:gosec const password = "password" - client, _, workspace, _ = setupProxyTest(t) + var port uint16 + ownerClient, _, _, port = setupProxyTest(t, &setupProxyTestOpts{ + NoWorkspace: true, + DangerousAllowPathAppSharing: allowPathAppSharing, + DangerousAllowPathAppSiteOwnerAccess: allowSiteOwnerAccess, + }) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) t.Cleanup(cancel) - user, err := client.User(ctx, codersdk.Me) + ownerUser, err := ownerClient.User(ctx, codersdk.Me) + require.NoError(t, err) + + // Create a template-admin user in the same org. We don't use an owner + // since they have access to everything. + user, err = ownerClient.CreateUser(ctx, codersdk.CreateUserRequest{ + Email: "user@coder.com", + Username: "user", + Password: password, + OrganizationID: ownerUser.OrganizationIDs[0], + }) require.NoError(t, err) + _, err = ownerClient.UpdateUserRoles(ctx, user.ID.String(), codersdk.UpdateRoles{ + Roles: []string{"template-admin", "member"}, + }) + require.NoError(t, err) + + client = codersdk.New(ownerClient.URL) + loginRes, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{ + Email: user.Email, + Password: password, + }) + require.NoError(t, err) + client.SetSessionToken(loginRes.SessionToken) + client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + } + + // Create workspace. + workspace = createWorkspaceWithApps(t, client, user.OrganizationIDs[0], proxyTestSubdomainRaw, port) + // Verify that the apps have the correct sharing levels set. workspaceBuild, err := client.WorkspaceBuild(ctx, workspace.LatestBuild.ID) require.NoError(t, err) @@ -1114,11 +1206,11 @@ func TestAppSharing(t *testing.T) { require.Equal(t, expected, found, "apps have incorrect sharing levels") // Create a user in a different org. - otherOrg, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{ + otherOrg, err := ownerClient.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{ Name: "a-different-org", }) require.NoError(t, err) - userInOtherOrg, err := client.CreateUser(ctx, codersdk.CreateUserRequest{ + userInOtherOrg, err := ownerClient.CreateUser(ctx, codersdk.CreateUserRequest{ Email: "no-template-access@coder.com", Username: "no-template-access", Password: password, @@ -1127,7 +1219,7 @@ func TestAppSharing(t *testing.T) { require.NoError(t, err) clientInOtherOrg = codersdk.New(client.URL) - loginRes, err := clientInOtherOrg.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{ + loginRes, err = clientInOtherOrg.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{ Email: userInOtherOrg.Email, Password: password, }) @@ -1143,7 +1235,7 @@ func TestAppSharing(t *testing.T) { return http.ErrUseLastResponse } - return workspace, agnt, user, client, clientInOtherOrg, clientWithNoAuth + return workspace, agnt, user, ownerClient, client, clientInOtherOrg, clientWithNoAuth } verifyAccess := func(t *testing.T, username, workspaceName, agentName, appName string, client *codersdk.Client, shouldHaveAccess, shouldRedirectToLogin bool) { @@ -1176,7 +1268,7 @@ func TestAppSharing(t *testing.T) { require.NoError(t, err, msg) dump, err := httputil.DumpResponse(res, true) - res.Body.Close() + _ = res.Body.Close() require.NoError(t, err, msg) t.Logf("response dump: %s", dump) @@ -1200,51 +1292,98 @@ func TestAppSharing(t *testing.T) { } } - t.Run("Level", func(t *testing.T) { - t.Parallel() + testLevels := func(t *testing.T, isPathApp, pathAppSharingEnabled, siteOwnerPathAppAccessEnabled bool) { + t.Run("Level", func(t *testing.T) { + t.Parallel() - workspace, agent, user, client, clientInOtherOrg, clientWithNoAuth := setup(t) + workspace, agent, user, ownerClient, client, clientInOtherOrg, clientWithNoAuth := setup(t, pathAppSharingEnabled, siteOwnerPathAppAccessEnabled) - t.Run("Owner", func(t *testing.T) { - t.Parallel() + siteOwnerCanAccess := isPathApp && siteOwnerPathAppAccessEnabled + allowedUnlessSharingDisabled := isPathApp && pathAppSharingEnabled - // Owner should be able to access their own workspace. - verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNameOwner, client, true, false) + t.Run("Owner", func(t *testing.T) { + t.Parallel() - // Authenticated users should not have access to a workspace that - // they do not own. - verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNameOwner, clientInOtherOrg, false, false) + // Site owner should be able to access all workspaces if + // enabled. + verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNameOwner, ownerClient, siteOwnerCanAccess, false) - // Unauthenticated user should not have any access. - verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNameOwner, clientWithNoAuth, false, true) - }) + // Owner should be able to access their own workspace. + verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNameOwner, client, true, false) - t.Run("Authenticated", func(t *testing.T) { - t.Parallel() + // Authenticated users should not have access to a workspace that + // they do not own. + verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNameOwner, clientInOtherOrg, false, false) + + // Unauthenticated user should not have any access. + verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNameOwner, clientWithNoAuth, false, true) + }) + + t.Run("Authenticated", func(t *testing.T) { + t.Parallel() + + // Site owner should be able to access all workspaces if + // enabled. + verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNameOwner, ownerClient, siteOwnerCanAccess, false) + + // Owner should be able to access their own workspace. + verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNameAuthenticated, client, true, false) - // Owner should be able to access their own workspace. - verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNameAuthenticated, client, true, false) + // Authenticated users should be able to access the workspace. + verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNameAuthenticated, clientInOtherOrg, allowedUnlessSharingDisabled, false) - // Authenticated users should be able to access the workspace. - verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNameAuthenticated, clientInOtherOrg, true, false) + // Unauthenticated user should not have any access. + verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNameAuthenticated, clientWithNoAuth, false, true) + }) + + t.Run("Public", func(t *testing.T) { + t.Parallel() + + // Site owner should be able to access all workspaces if + // enabled. + verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNameOwner, ownerClient, siteOwnerCanAccess, false) + + // Owner should be able to access their own workspace. + verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNamePublic, client, true, false) + + // Authenticated users should be able to access the workspace. + verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNamePublic, clientInOtherOrg, allowedUnlessSharingDisabled, false) - // Unauthenticated user should not have any access. - verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNameAuthenticated, clientWithNoAuth, false, true) + // Unauthenticated user should be able to access the workspace. + verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNamePublic, clientWithNoAuth, allowedUnlessSharingDisabled, !allowedUnlessSharingDisabled) + }) }) + } - t.Run("Public", func(t *testing.T) { + t.Run("Path", func(t *testing.T) { + t.Parallel() + + t.Run("Default", func(t *testing.T) { t.Parallel() + testLevels(t, true, false, false) + }) - // Owner should be able to access their own workspace. - verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNamePublic, client, true, false) + t.Run("AppSharingEnabled", func(t *testing.T) { + t.Parallel() + testLevels(t, true, true, false) + }) - // Authenticated users should be able to access the workspace. - verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNamePublic, clientInOtherOrg, true, false) + t.Run("SiteOwnerAccessEnabled", func(t *testing.T) { + t.Parallel() + testLevels(t, true, false, true) + }) - // Unauthenticated user should be able to access the workspace. - verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNamePublic, clientWithNoAuth, true, false) + t.Run("BothEnabled", func(t *testing.T) { + t.Parallel() + testLevels(t, true, false, true) }) }) + + t.Run("Subdomain", func(t *testing.T) { + t.Parallel() + + testLevels(t, false, false, false) + }) } func TestWorkspaceAppsNonCanonicalHeaders(t *testing.T) { diff --git a/codersdk/deploymentconfig.go b/codersdk/deploymentconfig.go index 477a27b013e6c..11f4bee64525e 100644 --- a/codersdk/deploymentconfig.go +++ b/codersdk/deploymentconfig.go @@ -46,6 +46,8 @@ type DeploymentConfig struct { MaxTokenLifetime *DeploymentConfigField[time.Duration] `json:"max_token_lifetime" typescript:",notnull"` Swagger *SwaggerConfig `json:"swagger" typescript:",notnull"` Logging *LoggingConfig `json:"logging" typescript:",notnull"` + Dangerous *DangerousConfig `json:"dangerous" typescript:",notnull"` + DisablePathApps *DeploymentConfigField[bool] `json:"disable_path_apps" typescript:",notnull"` } type DERP struct { @@ -162,6 +164,11 @@ type LoggingConfig struct { Stackdriver *DeploymentConfigField[string] `json:"stackdriver" typescript:",notnull"` } +type DangerousConfig struct { + AllowPathAppSharing *DeploymentConfigField[bool] `json:"allow_path_app_sharing" typescript:",notnull"` + AllowPathAppSiteOwnerAccess *DeploymentConfigField[bool] `json:"allow_path_app_site_owner_access" typescript:",notnull"` +} + type Flaggable interface { string | time.Duration | bool | int | []string | []GitAuthConfig } diff --git a/docs/api/general.md b/docs/api/general.md index 461226c00a998..3be0812c71c8f 100644 --- a/docs/api/general.md +++ b/docs/api/general.md @@ -171,6 +171,30 @@ curl -X GET http://coder-server:8080/api/v2/config/deployment \ "usage": "string", "value": "string" }, + "dangerous": { + "allow_path_app_sharing": { + "default": true, + "enterprise": true, + "flag": "string", + "hidden": true, + "name": "string", + "secret": true, + "shorthand": "string", + "usage": "string", + "value": true + }, + "allow_path_app_site_owner_access": { + "default": true, + "enterprise": true, + "flag": "string", + "hidden": true, + "name": "string", + "secret": true, + "shorthand": "string", + "usage": "string", + "value": true + } + }, "derp": { "config": { "path": { @@ -265,6 +289,17 @@ curl -X GET http://coder-server:8080/api/v2/config/deployment \ } } }, + "disable_path_apps": { + "default": true, + "enterprise": true, + "flag": "string", + "hidden": true, + "name": "string", + "secret": true, + "shorthand": "string", + "usage": "string", + "value": true + }, "experimental": { "default": true, "enterprise": true, diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 91cb8f816960f..6b636fec411bb 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -1159,6 +1159,42 @@ CreateParameterRequest is a structure used to create a new parameter value for a | `relay_url` | [codersdk.DeploymentConfigField-string](#codersdkdeploymentconfigfield-string) | false | | | | `stun_addresses` | [codersdk.DeploymentConfigField-array_string](#codersdkdeploymentconfigfield-array_string) | false | | | +## codersdk.DangerousConfig + +```json +{ + "allow_path_app_sharing": { + "default": true, + "enterprise": true, + "flag": "string", + "hidden": true, + "name": "string", + "secret": true, + "shorthand": "string", + "usage": "string", + "value": true + }, + "allow_path_app_site_owner_access": { + "default": true, + "enterprise": true, + "flag": "string", + "hidden": true, + "name": "string", + "secret": true, + "shorthand": "string", + "usage": "string", + "value": true + } +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ---------------------------------- | -------------------------------------------------------------------------- | -------- | ------------ | ----------- | +| `allow_path_app_sharing` | [codersdk.DeploymentConfigField-bool](#codersdkdeploymentconfigfield-bool) | false | | | +| `allow_path_app_site_owner_access` | [codersdk.DeploymentConfigField-bool](#codersdkdeploymentconfigfield-bool) | false | | | + ## codersdk.DeploymentConfig ```json @@ -1251,6 +1287,30 @@ CreateParameterRequest is a structure used to create a new parameter value for a "usage": "string", "value": "string" }, + "dangerous": { + "allow_path_app_sharing": { + "default": true, + "enterprise": true, + "flag": "string", + "hidden": true, + "name": "string", + "secret": true, + "shorthand": "string", + "usage": "string", + "value": true + }, + "allow_path_app_site_owner_access": { + "default": true, + "enterprise": true, + "flag": "string", + "hidden": true, + "name": "string", + "secret": true, + "shorthand": "string", + "usage": "string", + "value": true + } + }, "derp": { "config": { "path": { @@ -1345,6 +1405,17 @@ CreateParameterRequest is a structure used to create a new parameter value for a } } }, + "disable_path_apps": { + "default": true, + "enterprise": true, + "flag": "string", + "hidden": true, + "name": "string", + "secret": true, + "shorthand": "string", + "usage": "string", + "value": true + }, "experimental": { "default": true, "enterprise": true, @@ -2057,7 +2128,9 @@ CreateParameterRequest is a structure used to create a new parameter value for a | `autobuild_poll_interval` | [codersdk.DeploymentConfigField-time_Duration](#codersdkdeploymentconfigfield-time_duration) | false | | | | `browser_only` | [codersdk.DeploymentConfigField-bool](#codersdkdeploymentconfigfield-bool) | false | | | | `cache_directory` | [codersdk.DeploymentConfigField-string](#codersdkdeploymentconfigfield-string) | false | | | +| `dangerous` | [codersdk.DangerousConfig](#codersdkdangerousconfig) | false | | | | `derp` | [codersdk.DERP](#codersdkderp) | false | | | +| `disable_path_apps` | [codersdk.DeploymentConfigField-bool](#codersdkdeploymentconfigfield-bool) | false | | | | `experimental` | [codersdk.DeploymentConfigField-bool](#codersdkdeploymentconfigfield-bool) | false | | | | `gitauth` | [codersdk.DeploymentConfigField-array_codersdk_GitAuthConfig](#codersdkdeploymentconfigfield-array_codersdk_gitauthconfig) | false | | | | `http_address` | [codersdk.DeploymentConfigField-string](#codersdkdeploymentconfigfield-string) | false | | | diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index f5d1728a9e9d6..f2822a4c7c4f8 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -280,6 +280,12 @@ export interface DERPServerConfig { readonly relay_url: DeploymentConfigField } +// From codersdk/deploymentconfig.go +export interface DangerousConfig { + readonly allow_path_app_sharing: DeploymentConfigField + readonly allow_path_app_site_owner_access: DeploymentConfigField +} + // From codersdk/deploymentconfig.go export interface DeploymentConfig { readonly access_url: DeploymentConfigField @@ -316,6 +322,8 @@ export interface DeploymentConfig { readonly max_token_lifetime: DeploymentConfigField readonly swagger: SwaggerConfig readonly logging: LoggingConfig + readonly dangerous: DangerousConfig + readonly disable_path_apps: DeploymentConfigField } // From codersdk/deploymentconfig.go From 463893f162183c8574d5545615cf28a8cd92d848 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Wed, 18 Jan 2023 22:17:40 +0000 Subject: [PATCH 2/4] fixup! fix(security)!: path-based app sharing changes --- cli/deployment/config.go | 6 +- coderd/workspaceapps.go | 31 ++++---- coderd/workspaceapps_test.go | 146 +++++++++++++++++++++-------------- 3 files changed, 109 insertions(+), 74 deletions(-) diff --git a/cli/deployment/config.go b/cli/deployment/config.go index 470a3278f3b42..1c2a1c54adc61 100644 --- a/cli/deployment/config.go +++ b/cli/deployment/config.go @@ -494,20 +494,20 @@ func newConfig() *codersdk.DeploymentConfig { Dangerous: &codersdk.DangerousConfig{ AllowPathAppSharing: &codersdk.DeploymentConfigField[bool]{ Name: "DANGEROUS: Allow Path App Sharing", - Usage: "Allow workspace apps that are not served from subdomains to be shared. Path-based app sharing is DISABLED by default for security purposes. Path-based apps can make requests to the Coder API and pose a security risk when the workspace serves malicious Javascript. Path-based apps can be disabled entirely with --disable-path-apps for further security.", + Usage: "Allow workspace apps that are not served from subdomains to be shared. Path-based app sharing is DISABLED by default for security purposes. Path-based apps can make requests to the Coder API and pose a security risk when the workspace serves malicious JavaScript. Path-based apps can be disabled entirely with --disable-path-apps for further security.", Flag: "dangerous-allow-path-app-sharing", Default: false, }, AllowPathAppSiteOwnerAccess: &codersdk.DeploymentConfigField[bool]{ Name: "DANGEROUS: Allow Site Owners to Access Path Apps", - Usage: "Allow site-owners to access workspace apps from workspaces they do not own. Owners cannot access path-based apps they do not own by default. Path-based apps can make requests to the Coder API and pose a security risk when the workspace serves malicious Javascript. Path-based apps can be disabled entirely with --disable-path-apps for further security.", + Usage: "Allow site-owners to access workspace apps from workspaces they do not own. Owners cannot access path-based apps they do not own by default. Path-based apps can make requests to the Coder API and pose a security risk when the workspace serves malicious JavaScript. Path-based apps can be disabled entirely with --disable-path-apps for further security.", Flag: "dangerous-allow-path-app-site-owner-access", Default: false, }, }, DisablePathApps: &codersdk.DeploymentConfigField[bool]{ Name: "Disable Path Apps", - Usage: "Disable workspace apps that are not served from subdomains. Path-based apps can make requests to the Coder API and pose a security risk when the workspace serves malicious Javascript. This is recommended for security purposes if a --wildcard-access-url is configured.", + Usage: "Disable workspace apps that are not served from subdomains. Path-based apps can make requests to the Coder API and pose a security risk when the workspace serves malicious JavaScript. This is recommended for security purposes if a --wildcard-access-url is configured.", Flag: "disable-path-apps", Default: false, }, diff --git a/coderd/workspaceapps.go b/coderd/workspaceapps.go index ed40c0cf4deac..0d9c48ede4c34 100644 --- a/coderd/workspaceapps.go +++ b/coderd/workspaceapps.go @@ -431,6 +431,9 @@ func (api *API) authorizeWorkspaceApp(r *http.Request, isPathApp bool, sharingLe // If path-based app sharing is disabled (which is the default), we can // force the sharing level to be "owner" so that the user can only access // their own apps. + // + // Site owners are blocked from accessing path-based apps unless the + // Dangerous.AllowPathAppSiteOwnerAccess flag is enabled in the check below. if isPathApp && !api.DeploymentConfig.Dangerous.AllowPathAppSharing.Value { sharingLevel = database.AppSharingLevelOwner } @@ -443,17 +446,19 @@ func (api *API) authorizeWorkspaceApp(r *http.Request, isPathApp bool, sharingLe return sharingLevel == database.AppSharingLevelPublic, nil } - // If owners are not allowed to access any path-based app (which is the - // default), we can remove the "owner" role from the list of roles so that - // the RBAC check doesn't give them God permissions. - checkRoles := roles.Roles - if !api.DeploymentConfig.Dangerous.AllowPathAppSiteOwnerAccess.Value { - checkRoles = make([]string, 0, len(roles.Roles)) - for _, role := range roles.Roles { - if role != rbac.RoleOwner() { - checkRoles = append(checkRoles, role) - } - } + // Block anyone from accessing workspaces they don't own in path-based apps + // unless the admin disables this security feature. This blocks site-owners + // from accessing any apps from any user's workspaces. + // + // When the Dangerous.AllowPathAppSharing flag is not enabled, the sharing + // level will be forced to "owner", so this check will always be true for + // workspaces owned by different users. + if isPathApp && + sharingLevel == database.AppSharingLevelOwner && + workspace.OwnerID != roles.ID && + !api.DeploymentConfig.Dangerous.AllowPathAppSiteOwnerAccess.Value { + + return false, nil } // Do a standard RBAC check. This accounts for share level "owner" and any @@ -462,7 +467,7 @@ func (api *API) authorizeWorkspaceApp(r *http.Request, isPathApp bool, sharingLe // Regardless of share level or whether it's enabled or not, the owner of // the workspace can always access applications (as long as their API key's // scope allows it). - err := api.Authorizer.ByRoleName(ctx, roles.ID.String(), checkRoles, roles.Scope.ToRBAC(), []string{}, rbac.ActionCreate, workspace.ApplicationConnectRBAC()) + err := api.Authorizer.ByRoleName(ctx, roles.ID.String(), roles.Roles, roles.Scope.ToRBAC(), []string{}, rbac.ActionCreate, workspace.ApplicationConnectRBAC()) if err == nil { return true, nil } @@ -536,7 +541,7 @@ func (api *API) checkWorkspaceApplicationAuth(rw http.ResponseWriter, r *http.Re // they will be redirected to the route below. If the user does have a session // key but insufficient permissions a static error page will be rendered. func (api *API) verifyWorkspaceApplicationSubdomainAuth(rw http.ResponseWriter, r *http.Request, host string, workspace database.Workspace, appSharingLevel database.AppSharingLevel) bool { - authed, ok := api.fetchWorkspaceApplicationAuth(rw, r, true, workspace, appSharingLevel) + authed, ok := api.fetchWorkspaceApplicationAuth(rw, r, false, workspace, appSharingLevel) if !ok { return false } diff --git a/coderd/workspaceapps_test.go b/coderd/workspaceapps_test.go index 9e01b6bd8a20b..3df3718f3fe64 100644 --- a/coderd/workspaceapps_test.go +++ b/coderd/workspaceapps_test.go @@ -184,16 +184,7 @@ func setupProxyTest(t *testing.T, opts *setupProxyTestOpts) (*codersdk.Client, c client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse } - defaultTransport, ok := http.DefaultTransport.(*http.Transport) - require.True(t, ok) - transport := defaultTransport.Clone() - transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { - return (&net.Dialer{}).DialContext(ctx, network, client.URL.Host) - } - client.HTTPClient.Transport = transport - t.Cleanup(func() { - transport.CloseIdleConnections() - }) + forceURLTransport(t, client) return client, user, workspace, uint16(tcpAddr.Port) } @@ -1153,6 +1144,7 @@ func TestAppSharing(t *testing.T) { DangerousAllowPathAppSharing: allowPathAppSharing, DangerousAllowPathAppSiteOwnerAccess: allowSiteOwnerAccess, }) + forceURLTransport(t, ownerClient) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) t.Cleanup(cancel) @@ -1185,6 +1177,7 @@ func TestAppSharing(t *testing.T) { client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse } + forceURLTransport(t, client) // Create workspace. workspace = createWorkspaceWithApps(t, client, user.OrganizationIDs[0], proxyTestSubdomainRaw, port) @@ -1228,17 +1221,19 @@ func TestAppSharing(t *testing.T) { clientInOtherOrg.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse } + forceURLTransport(t, clientInOtherOrg) // Create an unauthenticated codersdk client. clientWithNoAuth = codersdk.New(client.URL) clientWithNoAuth.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse } + forceURLTransport(t, clientWithNoAuth) return workspace, agnt, user, ownerClient, client, clientInOtherOrg, clientWithNoAuth } - verifyAccess := func(t *testing.T, username, workspaceName, agentName, appName string, client *codersdk.Client, shouldHaveAccess, shouldRedirectToLogin bool) { + verifyAccess := func(t *testing.T, isPathApp bool, username, workspaceName, agentName, appName string, client *codersdk.Client, shouldHaveAccess, shouldRedirectToLogin bool) { t.Helper() ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) @@ -1256,6 +1251,7 @@ func TestAppSharing(t *testing.T) { scopedClient := codersdk.New(client.URL) scopedClient.SetSessionToken(token.Key) scopedClient.HTTPClient.CheckRedirect = client.HTTPClient.CheckRedirect + scopedClient.HTTPClient.Transport = client.HTTPClient.Transport clients = append(clients, scopedClient) } @@ -1263,21 +1259,38 @@ func TestAppSharing(t *testing.T) { for i, client := range clients { msg := fmt.Sprintf("client %d", i) - appPath := fmt.Sprintf("/@%s/%s.%s/apps/%s/?%s", username, workspaceName, agentName, appName, proxyTestAppQuery) - res, err := requestWithRetries(ctx, t, client, http.MethodGet, appPath, nil) + u := fmt.Sprintf("/@%s/%s.%s/apps/%s/?%s", username, workspaceName, agentName, appName, proxyTestAppQuery) + if !isPathApp { + subdomain := httpapi.ApplicationURL{ + AppSlug: appName, + AgentName: agentName, + WorkspaceName: workspaceName, + Username: username, + }.String() + + hostname := strings.Replace(proxyTestSubdomainRaw, "*", subdomain, 1) + u = fmt.Sprintf("http://%s/?%s", hostname, proxyTestAppQuery) + } + + res, err := requestWithRetries(ctx, t, client, http.MethodGet, u, nil) require.NoError(t, err, msg) dump, err := httputil.DumpResponse(res, true) _ = res.Body.Close() require.NoError(t, err, msg) - t.Logf("response dump: %s", dump) + // t.Logf("response dump: %s", dump) if !shouldHaveAccess { if shouldRedirectToLogin { assert.Equal(t, http.StatusTemporaryRedirect, res.StatusCode, "should not have access, expected temporary redirect. "+msg) location, err := res.Location() require.NoError(t, err, msg) - assert.Equal(t, "/login", location.Path, "should not have access, expected redirect to /login. "+msg) + + expectedPath := "/login" + if !isPathApp { + expectedPath = "/api/v2/applications/auth-redirect" + } + assert.Equal(t, expectedPath, location.Path, "should not have access, expected redirect to applicable login endpoint. "+msg) } else { // If the user doesn't have access we return 404 to avoid // leaking information about the existence of the app. @@ -1293,65 +1306,68 @@ func TestAppSharing(t *testing.T) { } testLevels := func(t *testing.T, isPathApp, pathAppSharingEnabled, siteOwnerPathAppAccessEnabled bool) { - t.Run("Level", func(t *testing.T) { - t.Parallel() + workspace, agent, user, ownerClient, client, clientInOtherOrg, clientWithNoAuth := setup(t, pathAppSharingEnabled, siteOwnerPathAppAccessEnabled) - workspace, agent, user, ownerClient, client, clientInOtherOrg, clientWithNoAuth := setup(t, pathAppSharingEnabled, siteOwnerPathAppAccessEnabled) + allowedUnlessSharingDisabled := !isPathApp || pathAppSharingEnabled + siteOwnerCanAccess := !isPathApp || siteOwnerPathAppAccessEnabled + siteOwnerCanAccessShared := siteOwnerCanAccess || pathAppSharingEnabled - siteOwnerCanAccess := isPathApp && siteOwnerPathAppAccessEnabled - allowedUnlessSharingDisabled := isPathApp && pathAppSharingEnabled + deploymentConfig, err := ownerClient.DeploymentConfig(context.Background()) + require.NoError(t, err) - t.Run("Owner", func(t *testing.T) { - t.Parallel() + assert.Equal(t, pathAppSharingEnabled, deploymentConfig.Dangerous.AllowPathAppSharing.Value) + assert.Equal(t, siteOwnerPathAppAccessEnabled, deploymentConfig.Dangerous.AllowPathAppSiteOwnerAccess.Value) - // Site owner should be able to access all workspaces if - // enabled. - verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNameOwner, ownerClient, siteOwnerCanAccess, false) + t.Run("LevelOwner", func(t *testing.T) { + t.Parallel() - // Owner should be able to access their own workspace. - verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNameOwner, client, true, false) + // Site owner should be able to access all workspaces if + // enabled. + verifyAccess(t, isPathApp, user.Username, workspace.Name, agent.Name, proxyTestAppNameOwner, ownerClient, siteOwnerCanAccess, false) - // Authenticated users should not have access to a workspace that - // they do not own. - verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNameOwner, clientInOtherOrg, false, false) + // Owner should be able to access their own workspace. + verifyAccess(t, isPathApp, user.Username, workspace.Name, agent.Name, proxyTestAppNameOwner, client, true, false) - // Unauthenticated user should not have any access. - verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNameOwner, clientWithNoAuth, false, true) - }) + // Authenticated users should not have access to a workspace that + // they do not own. + verifyAccess(t, isPathApp, user.Username, workspace.Name, agent.Name, proxyTestAppNameOwner, clientInOtherOrg, false, false) - t.Run("Authenticated", func(t *testing.T) { - t.Parallel() + // Unauthenticated user should not have any access. + verifyAccess(t, isPathApp, user.Username, workspace.Name, agent.Name, proxyTestAppNameOwner, clientWithNoAuth, false, true) + }) - // Site owner should be able to access all workspaces if - // enabled. - verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNameOwner, ownerClient, siteOwnerCanAccess, false) + t.Run("LevelAuthenticated", func(t *testing.T) { + t.Parallel() - // Owner should be able to access their own workspace. - verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNameAuthenticated, client, true, false) + // Site owner should be able to access all workspaces if + // enabled. + verifyAccess(t, isPathApp, user.Username, workspace.Name, agent.Name, proxyTestAppNameAuthenticated, ownerClient, siteOwnerCanAccessShared, false) - // Authenticated users should be able to access the workspace. - verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNameAuthenticated, clientInOtherOrg, allowedUnlessSharingDisabled, false) + // Owner should be able to access their own workspace. + verifyAccess(t, isPathApp, user.Username, workspace.Name, agent.Name, proxyTestAppNameAuthenticated, client, true, false) - // Unauthenticated user should not have any access. - verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNameAuthenticated, clientWithNoAuth, false, true) - }) + // Authenticated users should be able to access the workspace. + verifyAccess(t, isPathApp, user.Username, workspace.Name, agent.Name, proxyTestAppNameAuthenticated, clientInOtherOrg, allowedUnlessSharingDisabled, false) - t.Run("Public", func(t *testing.T) { - t.Parallel() + // Unauthenticated user should not have any access. + verifyAccess(t, isPathApp, user.Username, workspace.Name, agent.Name, proxyTestAppNameAuthenticated, clientWithNoAuth, false, true) + }) - // Site owner should be able to access all workspaces if - // enabled. - verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNameOwner, ownerClient, siteOwnerCanAccess, false) + t.Run("LevelPublic", func(t *testing.T) { + t.Parallel() - // Owner should be able to access their own workspace. - verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNamePublic, client, true, false) + // Site owner should be able to access all workspaces if + // enabled. + verifyAccess(t, isPathApp, user.Username, workspace.Name, agent.Name, proxyTestAppNamePublic, ownerClient, siteOwnerCanAccessShared, false) - // Authenticated users should be able to access the workspace. - verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNamePublic, clientInOtherOrg, allowedUnlessSharingDisabled, false) + // Owner should be able to access their own workspace. + verifyAccess(t, isPathApp, user.Username, workspace.Name, agent.Name, proxyTestAppNamePublic, client, true, false) - // Unauthenticated user should be able to access the workspace. - verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNamePublic, clientWithNoAuth, allowedUnlessSharingDisabled, !allowedUnlessSharingDisabled) - }) + // Authenticated users should be able to access the workspace. + verifyAccess(t, isPathApp, user.Username, workspace.Name, agent.Name, proxyTestAppNamePublic, clientInOtherOrg, allowedUnlessSharingDisabled, false) + + // Unauthenticated user should be able to access the workspace. + verifyAccess(t, isPathApp, user.Username, workspace.Name, agent.Name, proxyTestAppNamePublic, clientWithNoAuth, allowedUnlessSharingDisabled, !allowedUnlessSharingDisabled) }) } @@ -1381,7 +1397,6 @@ func TestAppSharing(t *testing.T) { t.Run("Subdomain", func(t *testing.T) { t.Parallel() - testLevels(t, false, false, false) }) } @@ -1577,3 +1592,18 @@ func TestWorkspaceAppsNonCanonicalHeaders(t *testing.T) { require.Equal(t, secWebSocketKey, resp.Header.Get("Sec-WebSocket-Key")) }) } + +// forceURLTransport forces the client to route all requests to the client's +// configured URL host regardless of hostname. +func forceURLTransport(t *testing.T, client *codersdk.Client) { + defaultTransport, ok := http.DefaultTransport.(*http.Transport) + require.True(t, ok) + transport := defaultTransport.Clone() + transport.DialContext = func(ctx context.Context, network, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, network, client.URL.Host) + } + client.HTTPClient.Transport = transport + t.Cleanup(func() { + transport.CloseIdleConnections() + }) +} From aaa0b4f26b376080e7dcf712e76317475a12a75d Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Wed, 18 Jan 2023 22:40:11 +0000 Subject: [PATCH 3/4] fixup! Merge branch 'main' into dean/path-app-security --- coderd/workspaceapps.go | 58 +++++++++++++++++++++++++---------------- 1 file changed, 35 insertions(+), 23 deletions(-) diff --git a/coderd/workspaceapps.go b/coderd/workspaceapps.go index 0d9c48ede4c34..ccb647b3a5f52 100644 --- a/coderd/workspaceapps.go +++ b/coderd/workspaceapps.go @@ -65,6 +65,13 @@ var nonCanonicalHeaders = map[string]string{ "Sec-Websocket-Version": "Sec-WebSocket-Version", } +type workspaceAppAccessMethod string + +const ( + workspaceAppAccessMethodPath workspaceAppAccessMethod = "path" + workspaceAppAccessMethodSubdomain workspaceAppAccessMethod = "subdomain" +) + // @Summary Get applications host // @ID get-applications-host // @Security CoderSessionToken @@ -111,7 +118,7 @@ func (api *API) workspaceAppsProxyPath(rw http.ResponseWriter, r *http.Request) if app.SharingLevel != "" { appSharingLevel = app.SharingLevel } - authed, ok := api.fetchWorkspaceApplicationAuth(rw, r, true, workspace, appSharingLevel) + authed, ok := api.fetchWorkspaceApplicationAuth(rw, r, workspaceAppAccessMethodPath, workspace, appSharingLevel) if !ok { return } @@ -138,12 +145,12 @@ func (api *API) workspaceAppsProxyPath(rw http.ResponseWriter, r *http.Request) } api.proxyWorkspaceApplication(proxyApplication{ - IsPathApp: true, - Workspace: workspace, - Agent: agent, - App: &app, - Port: 0, - Path: chiPath, + AccessMethod: workspaceAppAccessMethodPath, + Workspace: workspace, + Agent: agent, + App: &app, + Port: 0, + Path: chiPath, }, rw, r) } @@ -250,12 +257,12 @@ func (api *API) handleSubdomainApplications(middlewares ...func(http.Handler) ht } api.proxyWorkspaceApplication(proxyApplication{ - IsPathApp: false, - Workspace: workspace, - Agent: agent, - App: workspaceAppPtr, - Port: app.Port, - Path: r.URL.Path, + AccessMethod: workspaceAppAccessMethodSubdomain, + Workspace: workspace, + Agent: agent, + App: workspaceAppPtr, + Port: app.Port, + Path: r.URL.Path, }, rw, r) })).ServeHTTP(rw, r.WithContext(ctx)) }) @@ -425,9 +432,14 @@ func (api *API) lookupWorkspaceApp(rw http.ResponseWriter, r *http.Request, agen } //nolint:revive -func (api *API) authorizeWorkspaceApp(r *http.Request, isPathApp bool, sharingLevel database.AppSharingLevel, workspace database.Workspace) (bool, error) { +func (api *API) authorizeWorkspaceApp(r *http.Request, accessMethod workspaceAppAccessMethod, sharingLevel database.AppSharingLevel, workspace database.Workspace) (bool, error) { ctx := r.Context() + if accessMethod == "" { + accessMethod = workspaceAppAccessMethodPath + } + isPathApp := accessMethod == workspaceAppAccessMethodPath + // If path-based app sharing is disabled (which is the default), we can // force the sharing level to be "owner" so that the user can only access // their own apps. @@ -502,8 +514,8 @@ func (api *API) authorizeWorkspaceApp(r *http.Request, isPathApp bool, sharingLe // for a given app share level in the given workspace. The user's authorization // status is returned. If a server error occurs, a HTML error page is rendered // and false is returned so the caller can return early. -func (api *API) fetchWorkspaceApplicationAuth(rw http.ResponseWriter, r *http.Request, isPathApp bool, workspace database.Workspace, appSharingLevel database.AppSharingLevel) (authed bool, ok bool) { - ok, err := api.authorizeWorkspaceApp(r, isPathApp, appSharingLevel, workspace) +func (api *API) fetchWorkspaceApplicationAuth(rw http.ResponseWriter, r *http.Request, accessMethod workspaceAppAccessMethod, workspace database.Workspace, appSharingLevel database.AppSharingLevel) (authed bool, ok bool) { + ok, err := api.authorizeWorkspaceApp(r, accessMethod, appSharingLevel, workspace) if err != nil { api.Logger.Error(r.Context(), "authorize workspace app", slog.Error(err)) site.RenderStaticErrorPage(rw, r, site.ErrorPageData{ @@ -523,8 +535,8 @@ func (api *API) fetchWorkspaceApplicationAuth(rw http.ResponseWriter, r *http.Re // for a given app share level in the given workspace. If the user is not // authorized or a server error occurs, a discrete HTML error page is rendered // and false is returned so the caller can return early. -func (api *API) checkWorkspaceApplicationAuth(rw http.ResponseWriter, r *http.Request, isPathApp bool, workspace database.Workspace, appSharingLevel database.AppSharingLevel) bool { - authed, ok := api.fetchWorkspaceApplicationAuth(rw, r, isPathApp, workspace, appSharingLevel) +func (api *API) checkWorkspaceApplicationAuth(rw http.ResponseWriter, r *http.Request, accessMethod workspaceAppAccessMethod, workspace database.Workspace, appSharingLevel database.AppSharingLevel) bool { + authed, ok := api.fetchWorkspaceApplicationAuth(rw, r, accessMethod, workspace, appSharingLevel) if !ok { return false } @@ -541,7 +553,7 @@ func (api *API) checkWorkspaceApplicationAuth(rw http.ResponseWriter, r *http.Re // they will be redirected to the route below. If the user does have a session // key but insufficient permissions a static error page will be rendered. func (api *API) verifyWorkspaceApplicationSubdomainAuth(rw http.ResponseWriter, r *http.Request, host string, workspace database.Workspace, appSharingLevel database.AppSharingLevel) bool { - authed, ok := api.fetchWorkspaceApplicationAuth(rw, r, false, workspace, appSharingLevel) + authed, ok := api.fetchWorkspaceApplicationAuth(rw, r, workspaceAppAccessMethodSubdomain, workspace, appSharingLevel) if !ok { return false } @@ -770,9 +782,9 @@ func (api *API) workspaceApplicationAuth(rw http.ResponseWriter, r *http.Request // proxyApplication are the required fields to proxy a workspace application. type proxyApplication struct { - IsPathApp bool - Workspace database.Workspace - Agent database.WorkspaceAgent + AccessMethod workspaceAppAccessMethod + Workspace database.Workspace + Agent database.WorkspaceAgent // Either App or Port must be set, but not both. App *database.WorkspaceApp @@ -792,7 +804,7 @@ func (api *API) proxyWorkspaceApplication(proxyApp proxyApplication, rw http.Res if proxyApp.App != nil && proxyApp.App.SharingLevel != "" { sharingLevel = proxyApp.App.SharingLevel } - if !api.checkWorkspaceApplicationAuth(rw, r, proxyApp.IsPathApp, proxyApp.Workspace, sharingLevel) { + if !api.checkWorkspaceApplicationAuth(rw, r, proxyApp.AccessMethod, proxyApp.Workspace, sharingLevel) { return } From 1bb636a51d80de055d1923183a433ab6c7d095fc Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Wed, 18 Jan 2023 22:46:28 +0000 Subject: [PATCH 4/4] fixup! Merge branch 'main' into dean/path-app-security --- coderd/workspaceapps_test.go | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/coderd/workspaceapps_test.go b/coderd/workspaceapps_test.go index 3df3718f3fe64..a9782dce323e5 100644 --- a/coderd/workspaceapps_test.go +++ b/coderd/workspaceapps_test.go @@ -1306,7 +1306,7 @@ func TestAppSharing(t *testing.T) { } testLevels := func(t *testing.T, isPathApp, pathAppSharingEnabled, siteOwnerPathAppAccessEnabled bool) { - workspace, agent, user, ownerClient, client, clientInOtherOrg, clientWithNoAuth := setup(t, pathAppSharingEnabled, siteOwnerPathAppAccessEnabled) + workspace, agnt, user, ownerClient, client, clientInOtherOrg, clientWithNoAuth := setup(t, pathAppSharingEnabled, siteOwnerPathAppAccessEnabled) allowedUnlessSharingDisabled := !isPathApp || pathAppSharingEnabled siteOwnerCanAccess := !isPathApp || siteOwnerPathAppAccessEnabled @@ -1323,17 +1323,17 @@ func TestAppSharing(t *testing.T) { // Site owner should be able to access all workspaces if // enabled. - verifyAccess(t, isPathApp, user.Username, workspace.Name, agent.Name, proxyTestAppNameOwner, ownerClient, siteOwnerCanAccess, false) + verifyAccess(t, isPathApp, user.Username, workspace.Name, agnt.Name, proxyTestAppNameOwner, ownerClient, siteOwnerCanAccess, false) // Owner should be able to access their own workspace. - verifyAccess(t, isPathApp, user.Username, workspace.Name, agent.Name, proxyTestAppNameOwner, client, true, false) + verifyAccess(t, isPathApp, user.Username, workspace.Name, agnt.Name, proxyTestAppNameOwner, client, true, false) // Authenticated users should not have access to a workspace that // they do not own. - verifyAccess(t, isPathApp, user.Username, workspace.Name, agent.Name, proxyTestAppNameOwner, clientInOtherOrg, false, false) + verifyAccess(t, isPathApp, user.Username, workspace.Name, agnt.Name, proxyTestAppNameOwner, clientInOtherOrg, false, false) // Unauthenticated user should not have any access. - verifyAccess(t, isPathApp, user.Username, workspace.Name, agent.Name, proxyTestAppNameOwner, clientWithNoAuth, false, true) + verifyAccess(t, isPathApp, user.Username, workspace.Name, agnt.Name, proxyTestAppNameOwner, clientWithNoAuth, false, true) }) t.Run("LevelAuthenticated", func(t *testing.T) { @@ -1341,16 +1341,16 @@ func TestAppSharing(t *testing.T) { // Site owner should be able to access all workspaces if // enabled. - verifyAccess(t, isPathApp, user.Username, workspace.Name, agent.Name, proxyTestAppNameAuthenticated, ownerClient, siteOwnerCanAccessShared, false) + verifyAccess(t, isPathApp, user.Username, workspace.Name, agnt.Name, proxyTestAppNameAuthenticated, ownerClient, siteOwnerCanAccessShared, false) // Owner should be able to access their own workspace. - verifyAccess(t, isPathApp, user.Username, workspace.Name, agent.Name, proxyTestAppNameAuthenticated, client, true, false) + verifyAccess(t, isPathApp, user.Username, workspace.Name, agnt.Name, proxyTestAppNameAuthenticated, client, true, false) // Authenticated users should be able to access the workspace. - verifyAccess(t, isPathApp, user.Username, workspace.Name, agent.Name, proxyTestAppNameAuthenticated, clientInOtherOrg, allowedUnlessSharingDisabled, false) + verifyAccess(t, isPathApp, user.Username, workspace.Name, agnt.Name, proxyTestAppNameAuthenticated, clientInOtherOrg, allowedUnlessSharingDisabled, false) // Unauthenticated user should not have any access. - verifyAccess(t, isPathApp, user.Username, workspace.Name, agent.Name, proxyTestAppNameAuthenticated, clientWithNoAuth, false, true) + verifyAccess(t, isPathApp, user.Username, workspace.Name, agnt.Name, proxyTestAppNameAuthenticated, clientWithNoAuth, false, true) }) t.Run("LevelPublic", func(t *testing.T) { @@ -1358,16 +1358,16 @@ func TestAppSharing(t *testing.T) { // Site owner should be able to access all workspaces if // enabled. - verifyAccess(t, isPathApp, user.Username, workspace.Name, agent.Name, proxyTestAppNamePublic, ownerClient, siteOwnerCanAccessShared, false) + verifyAccess(t, isPathApp, user.Username, workspace.Name, agnt.Name, proxyTestAppNamePublic, ownerClient, siteOwnerCanAccessShared, false) // Owner should be able to access their own workspace. - verifyAccess(t, isPathApp, user.Username, workspace.Name, agent.Name, proxyTestAppNamePublic, client, true, false) + verifyAccess(t, isPathApp, user.Username, workspace.Name, agnt.Name, proxyTestAppNamePublic, client, true, false) // Authenticated users should be able to access the workspace. - verifyAccess(t, isPathApp, user.Username, workspace.Name, agent.Name, proxyTestAppNamePublic, clientInOtherOrg, allowedUnlessSharingDisabled, false) + verifyAccess(t, isPathApp, user.Username, workspace.Name, agnt.Name, proxyTestAppNamePublic, clientInOtherOrg, allowedUnlessSharingDisabled, false) // Unauthenticated user should be able to access the workspace. - verifyAccess(t, isPathApp, user.Username, workspace.Name, agent.Name, proxyTestAppNamePublic, clientWithNoAuth, allowedUnlessSharingDisabled, !allowedUnlessSharingDisabled) + verifyAccess(t, isPathApp, user.Username, workspace.Name, agnt.Name, proxyTestAppNamePublic, clientWithNoAuth, allowedUnlessSharingDisabled, !allowedUnlessSharingDisabled) }) }