diff --git a/cli/deployment/config.go b/cli/deployment/config.go index 3a36218277c7e..0b4e0cd5b76cd 100644 --- a/cli/deployment/config.go +++ b/cli/deployment/config.go @@ -500,6 +500,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 797ca82d5d8be..d72d9944533dd 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 --experiments strings Enable one or more experiments. These are not ready for production. Separate multiple experiments with commas, or diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 3355084edd85a..faf82ac0b13d9 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -5732,6 +5732,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": { @@ -5764,9 +5775,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": { "description": "DEPRECATED: Use Experiments instead.", "allOf": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 50339d9e3e44b..66d3e3cac2300 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -5081,6 +5081,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": { @@ -5113,9 +5124,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": { "description": "DEPRECATED: Use Experiments instead.", "allOf": [ diff --git a/coderd/workspaceapps.go b/coderd/workspaceapps.go index fe6cd564dfd8d..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 @@ -89,6 +96,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 +118,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, workspaceAppAccessMethodPath, workspace, appSharingLevel) if !ok { return } @@ -127,11 +145,12 @@ func (api *API) workspaceAppsProxyPath(rw http.ResponseWriter, r *http.Request) } api.proxyWorkspaceApplication(proxyApplication{ - Workspace: workspace, - Agent: agent, - App: &app, - Port: 0, - Path: chiPath, + AccessMethod: workspaceAppAccessMethodPath, + Workspace: workspace, + Agent: agent, + App: &app, + Port: 0, + Path: chiPath, }, rw, r) } @@ -238,11 +257,12 @@ func (api *API) handleSubdomainApplications(middlewares ...func(http.Handler) ht } api.proxyWorkspaceApplication(proxyApplication{ - 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)) }) @@ -411,9 +431,25 @@ 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, 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. + // + // 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 + } + // Short circuit if not authenticated. roles, ok := httpmw.UserAuthorizationOptional(r) if !ok { @@ -422,6 +458,21 @@ func (api *API) authorizeWorkspaceApp(r *http.Request, sharingLevel database.App return sharingLevel == database.AppSharingLevelPublic, nil } + // 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 // other RBAC rules that may be in place. // @@ -463,8 +514,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, 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{ @@ -484,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, 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, accessMethod workspaceAppAccessMethod, workspace database.Workspace, appSharingLevel database.AppSharingLevel) bool { + authed, ok := api.fetchWorkspaceApplicationAuth(rw, r, accessMethod, workspace, appSharingLevel) if !ok { return false } @@ -502,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, workspace, appSharingLevel) + authed, ok := api.fetchWorkspaceApplicationAuth(rw, r, workspaceAppAccessMethodSubdomain, workspace, appSharingLevel) if !ok { return false } @@ -731,8 +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 { - 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 @@ -752,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.Workspace, sharingLevel) { + if !api.checkWorkspaceApplicationAuth(rw, r, proxyApp.AccessMethod, proxyApp.Workspace, sharingLevel) { return } diff --git a/coderd/workspaceapps_test.go b/coderd/workspaceapps_test.go index 3b450153fec3b..a9782dce323e5 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,23 +173,18 @@ 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. 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) } @@ -234,6 +246,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 +261,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 +283,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 +429,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 +556,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 +700,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 +874,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 +896,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 +1040,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 +1134,54 @@ 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, + }) + forceURLTransport(t, ownerClient) 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 + } + forceURLTransport(t, client) + + // 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 +1199,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 +1212,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, }) @@ -1136,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, 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) { + 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) @@ -1164,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) } @@ -1171,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() + _ = 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. @@ -1200,51 +1305,100 @@ func TestAppSharing(t *testing.T) { } } - t.Run("Level", func(t *testing.T) { - t.Parallel() + testLevels := func(t *testing.T, isPathApp, pathAppSharingEnabled, siteOwnerPathAppAccessEnabled bool) { + workspace, agnt, user, ownerClient, client, clientInOtherOrg, clientWithNoAuth := setup(t, pathAppSharingEnabled, siteOwnerPathAppAccessEnabled) - workspace, agent, user, client, clientInOtherOrg, clientWithNoAuth := setup(t) + allowedUnlessSharingDisabled := !isPathApp || pathAppSharingEnabled + siteOwnerCanAccess := !isPathApp || siteOwnerPathAppAccessEnabled + siteOwnerCanAccessShared := siteOwnerCanAccess || pathAppSharingEnabled + + deploymentConfig, err := ownerClient.DeploymentConfig(context.Background()) + require.NoError(t, err) - t.Run("Owner", func(t *testing.T) { + assert.Equal(t, pathAppSharingEnabled, deploymentConfig.Dangerous.AllowPathAppSharing.Value) + assert.Equal(t, siteOwnerPathAppAccessEnabled, deploymentConfig.Dangerous.AllowPathAppSiteOwnerAccess.Value) + + t.Run("LevelOwner", func(t *testing.T) { t.Parallel() + // Site owner should be able to access all workspaces if + // enabled. + verifyAccess(t, isPathApp, user.Username, workspace.Name, agnt.Name, proxyTestAppNameOwner, ownerClient, siteOwnerCanAccess, false) + // Owner should be able to access their own workspace. - verifyAccess(t, 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, 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, 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("Authenticated", func(t *testing.T) { + t.Run("LevelAuthenticated", func(t *testing.T) { t.Parallel() + // Site owner should be able to access all workspaces if + // enabled. + verifyAccess(t, isPathApp, user.Username, workspace.Name, agnt.Name, proxyTestAppNameAuthenticated, ownerClient, siteOwnerCanAccessShared, false) + // Owner should be able to access their own workspace. - verifyAccess(t, 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, user.Username, workspace.Name, agent.Name, proxyTestAppNameAuthenticated, clientInOtherOrg, true, false) + verifyAccess(t, isPathApp, user.Username, workspace.Name, agnt.Name, proxyTestAppNameAuthenticated, clientInOtherOrg, allowedUnlessSharingDisabled, false) // Unauthenticated user should not have any access. - verifyAccess(t, 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("Public", func(t *testing.T) { + t.Run("LevelPublic", func(t *testing.T) { t.Parallel() + // Site owner should be able to access all workspaces if + // enabled. + verifyAccess(t, isPathApp, user.Username, workspace.Name, agnt.Name, proxyTestAppNamePublic, ownerClient, siteOwnerCanAccessShared, false) + // Owner should be able to access their own workspace. - verifyAccess(t, 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, user.Username, workspace.Name, agent.Name, proxyTestAppNamePublic, clientInOtherOrg, true, 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, user.Username, workspace.Name, agent.Name, proxyTestAppNamePublic, clientWithNoAuth, true, false) + verifyAccess(t, isPathApp, user.Username, workspace.Name, agnt.Name, proxyTestAppNamePublic, clientWithNoAuth, allowedUnlessSharingDisabled, !allowedUnlessSharingDisabled) + }) + } + + t.Run("Path", func(t *testing.T) { + t.Parallel() + + t.Run("Default", func(t *testing.T) { + t.Parallel() + testLevels(t, true, false, false) + }) + + t.Run("AppSharingEnabled", func(t *testing.T) { + t.Parallel() + testLevels(t, true, true, false) + }) + + t.Run("SiteOwnerAccessEnabled", func(t *testing.T) { + t.Parallel() + testLevels(t, true, false, true) + }) + + 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) { @@ -1438,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() + }) +} diff --git a/codersdk/deploymentconfig.go b/codersdk/deploymentconfig.go index 1b7c9ddf072a3..6aaa326cb86ea 100644 --- a/codersdk/deploymentconfig.go +++ b/codersdk/deploymentconfig.go @@ -11,10 +11,8 @@ import ( // DeploymentConfig is the central configuration for the coder server. type DeploymentConfig struct { - AccessURL *DeploymentConfigField[string] `json:"access_url" typescript:",notnull"` - WildcardAccessURL *DeploymentConfigField[string] `json:"wildcard_access_url" typescript:",notnull"` - // DEPRECATED: Use HTTPAddress or TLS.Address instead. - Address *DeploymentConfigField[string] `json:"address" typescript:",notnull"` + AccessURL *DeploymentConfigField[string] `json:"access_url" typescript:",notnull"` + WildcardAccessURL *DeploymentConfigField[string] `json:"wildcard_access_url" typescript:",notnull"` HTTPAddress *DeploymentConfigField[string] `json:"http_address" typescript:",notnull"` AutobuildPollInterval *DeploymentConfigField[time.Duration] `json:"autobuild_poll_interval" typescript:",notnull"` DERP *DERP `json:"derp" typescript:",notnull"` @@ -46,7 +44,11 @@ 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"` + // DEPRECATED: Use HTTPAddress or TLS.Address instead. + Address *DeploymentConfigField[string] `json:"address" typescript:",notnull"` // DEPRECATED: Use Experiments instead. Experimental *DeploymentConfigField[bool] `json:"experimental" typescript:",notnull"` } @@ -165,6 +167,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 447e6dde907ad..102c31395c33f 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 83cb1ba074c80..68de4258f685e 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, @@ -2068,7 +2139,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 | | Experimental Use Experiments instead. | | `experiments` | [codersdk.DeploymentConfigField-array_string](#codersdkdeploymentconfigfield-array_string) | false | | | | `gitauth` | [codersdk.DeploymentConfigField-array_codersdk_GitAuthConfig](#codersdkdeploymentconfigfield-array_codersdk_gitauthconfig) | false | | | diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 6793d66ca0fbf..9c81a6f96a840 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -280,11 +280,16 @@ 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 readonly wildcard_access_url: DeploymentConfigField - readonly address: DeploymentConfigField readonly http_address: DeploymentConfigField readonly autobuild_poll_interval: DeploymentConfigField readonly derp: DERP @@ -316,6 +321,9 @@ export interface DeploymentConfig { readonly max_token_lifetime: DeploymentConfigField readonly swagger: SwaggerConfig readonly logging: LoggingConfig + readonly dangerous: DangerousConfig + readonly disable_path_apps: DeploymentConfigField + readonly address: DeploymentConfigField readonly experimental: DeploymentConfigField }