diff --git a/Makefile b/Makefile index 7a91b70d768bb..7f04d33a8beae 100644 --- a/Makefile +++ b/Makefile @@ -760,7 +760,7 @@ scripts/ci-report/testdata/.gen-golden: $(wildcard scripts/ci-report/testdata/*) done test: - $(GIT_FLAGS) gotestsum --format standard-quiet -- -v -short -count=1 ./... + $(GIT_FLAGS) gotestsum --format standard-quiet -- -v -short -count=10 ./enterprise .PHONY: test # sqlc-cloud-is-setup will fail if no SQLc auth token is set. Use this as a diff --git a/coderd/workspaceapps/apptest/apptest.go b/coderd/workspaceapps/apptest/apptest.go index c6e251806230d..a6a0efa6e105a 100644 --- a/coderd/workspaceapps/apptest/apptest.go +++ b/coderd/workspaceapps/apptest/apptest.go @@ -1388,7 +1388,7 @@ func Run(t *testing.T, appHostIsPrimary bool, factory DeploymentFactory) { forceURLTransport(t, client) // Create workspace. - port := appServer(t, nil, false) + port := appServer(t, nil, false, nil) workspace, _ = createWorkspaceWithApps(t, client, user.OrganizationIDs[0], user, port, false) // Verify that the apps have the correct sharing levels set. @@ -1399,10 +1399,12 @@ func Run(t *testing.T, appHostIsPrimary bool, factory DeploymentFactory) { agnt = workspaceBuild.Resources[0].Agents[0] found := map[string]codersdk.WorkspaceAppSharingLevel{} expected := map[string]codersdk.WorkspaceAppSharingLevel{ - proxyTestAppNameFake: codersdk.WorkspaceAppSharingLevelOwner, - proxyTestAppNameOwner: codersdk.WorkspaceAppSharingLevelOwner, - proxyTestAppNameAuthenticated: codersdk.WorkspaceAppSharingLevelAuthenticated, - proxyTestAppNamePublic: codersdk.WorkspaceAppSharingLevelPublic, + proxyTestAppNameFake: codersdk.WorkspaceAppSharingLevelOwner, + proxyTestAppNameOwner: codersdk.WorkspaceAppSharingLevelOwner, + proxyTestAppNameAuthenticated: codersdk.WorkspaceAppSharingLevelAuthenticated, + proxyTestAppNamePublic: codersdk.WorkspaceAppSharingLevelPublic, + proxyTestAppNameAuthenticatedCORSDefault: codersdk.WorkspaceAppSharingLevelAuthenticated, + proxyTestAppNamePublicCORSDefault: codersdk.WorkspaceAppSharingLevelPublic, } for _, app := range agnt.Apps { found[app.DisplayName] = app.SharingLevel @@ -1559,6 +1561,9 @@ func Run(t *testing.T, appHostIsPrimary bool, factory DeploymentFactory) { // Unauthenticated user should not have any access. verifyAccess(t, appDetails, isPathApp, user.Username, workspace.Name, agnt.Name, proxyTestAppNameAuthenticated, clientWithNoAuth, false, true) + + // Unauthenticated user should not have any access, regardless of CORS behavior (using default). + verifyAccess(t, appDetails, isPathApp, user.Username, workspace.Name, agnt.Name, proxyTestAppNameAuthenticatedCORSDefault, clientWithNoAuth, false, true) }) t.Run("LevelPublic", func(t *testing.T) { @@ -1831,6 +1836,310 @@ func Run(t *testing.T, appHostIsPrimary bool, factory DeploymentFactory) { require.Equal(t, http.StatusBadRequest, resp.StatusCode) require.Equal(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type")) }) + + t.Run("WorkspaceApplicationCORS", func(t *testing.T) { + t.Parallel() + + const external = "https://example.com" + + unauthenticatedClient := func(t *testing.T, appDetails *Details) *codersdk.Client { + c := appDetails.AppClient(t) + c.SetSessionToken("") + return c + } + + authenticatedClient := func(t *testing.T, appDetails *Details) *codersdk.Client { + uc, _ := coderdtest.CreateAnotherUser(t, appDetails.SDKClient, appDetails.FirstUser.OrganizationID, rbac.RoleMember()) + c := appDetails.AppClient(t) + c.SetSessionToken(uc.SessionToken()) + return c + } + + ownerClient := func(t *testing.T, appDetails *Details) *codersdk.Client { + return appDetails.SDKClient + } + + ownSubdomain := func(details *Details, app App) string { + url := details.SubdomainAppURL(app) + return url.Scheme + "://" + url.Host + } + + externalOrigin := func(*Details, App) string { + return external + } + + tests := []struct { + name string + app func(details *Details) App + client func(t *testing.T, appDetails *Details) *codersdk.Client + httpMethod string + origin func(details *Details, app App) string + expectedStatusCode int + checkRequestHeaders func(t *testing.T, origin string, req http.Header) + checkResponseHeaders func(t *testing.T, origin string, resp http.Header) + }{ + // Public + { + // The default behavior is to a accept preflight request if it matches the app's own subdomain. + name: "Default/Public/Preflight/Subdomain", + app: func(details *Details) App { return details.Apps.PublicCORSDefault }, + client: unauthenticatedClient, + httpMethod: http.MethodOptions, + origin: ownSubdomain, + expectedStatusCode: http.StatusOK, + checkResponseHeaders: func(t *testing.T, origin string, resp http.Header) { + assert.Equal(t, origin, resp.Get("Access-Control-Allow-Origin")) + assert.Contains(t, resp.Get("Access-Control-Allow-Methods"), http.MethodGet) + assert.Equal(t, "true", resp.Get("Access-Control-Allow-Credentials")) + assert.Equal(t, "X-Got-Host", resp.Get("Access-Control-Allow-Headers")) + }, + }, + { + // The default behavior is to reject a preflight request from origins other than the app's own subdomain. + name: "Default/Public/Preflight/External", + app: func(details *Details) App { return details.Apps.PublicCORSDefault }, + client: unauthenticatedClient, + httpMethod: http.MethodOptions, + origin: externalOrigin, + expectedStatusCode: http.StatusOK, + checkResponseHeaders: func(t *testing.T, origin string, resp http.Header) { + // We don't add a valid Allow-Origin header for requests we won't proxy. + assert.Empty(t, resp.Get("Access-Control-Allow-Origin")) + }, + }, + { + // An unauthenticated request to the app is allowed from its own subdomain. + name: "Default/Public/GET/Subdomain", + app: func(details *Details) App { return details.Apps.PublicCORSDefault }, + client: unauthenticatedClient, + origin: ownSubdomain, + httpMethod: http.MethodGet, + expectedStatusCode: http.StatusOK, + checkResponseHeaders: func(t *testing.T, origin string, resp http.Header) { + assert.Equal(t, origin, resp.Get("Access-Control-Allow-Origin")) + assert.Equal(t, "true", resp.Get("Access-Control-Allow-Credentials")) + // Added by the app handler. + assert.Equal(t, "simple", resp.Get("X-CORS-Handler")) + }, + }, + { + // An unauthenticated request to the app is allowed from an external origin, but the CORS + // headers are not added to the response because it's not coming from a known origin. + name: "Default/Public/GET/External", + app: func(details *Details) App { return details.Apps.PublicCORSDefault }, + client: unauthenticatedClient, + origin: externalOrigin, + httpMethod: http.MethodGet, + expectedStatusCode: http.StatusOK, + checkResponseHeaders: func(t *testing.T, origin string, resp http.Header) { + // We don't add a valid Allow-Origin header for requests we won't proxy. + assert.Empty(t, resp.Get("Access-Control-Allow-Origin")) + }, + }, + { + // The owner can access their own apps from their own subdomain with valid CORS headers. + name: "Default/Public/GET/SubdomainOwner", + app: func(details *Details) App { return details.Apps.PublicCORSDefault }, + client: ownerClient, + origin: ownSubdomain, + httpMethod: http.MethodGet, + expectedStatusCode: http.StatusOK, + checkResponseHeaders: func(t *testing.T, origin string, resp http.Header) { + assert.Equal(t, origin, resp.Get("Access-Control-Allow-Origin")) + assert.Equal(t, "true", resp.Get("Access-Control-Allow-Credentials")) + // Added by the app handler. + assert.Equal(t, "simple", resp.Get("X-CORS-Handler")) + }, + }, + { + // The owner can't access their own apps from an external origin with valid CORS headers. + name: "Default/Public/GET/ExternalOwner", + app: func(details *Details) App { return details.Apps.PublicCORSDefault }, + client: ownerClient, + origin: externalOrigin, + httpMethod: http.MethodGet, + expectedStatusCode: http.StatusOK, + checkResponseHeaders: func(t *testing.T, origin string, resp http.Header) { + // We don't add a valid Allow-Origin header for requests we won't proxy. + assert.Empty(t, resp.Get("Access-Control-Allow-Origin")) + }, + }, + { + // A request without an Origin header would be rejected by an actual browser since it lacks CORS headers, + // but we accept it since it's common for non-browser clients to not send the Origin header. + name: "Default/Public/GET/NoOrigin", + app: func(details *Details) App { return details.Apps.PublicCORSDefault }, + client: unauthenticatedClient, + origin: func(*Details, App) string { return "" }, + httpMethod: http.MethodGet, + expectedStatusCode: http.StatusOK, + checkResponseHeaders: func(t *testing.T, origin string, resp http.Header) { + assert.Empty(t, resp.Get("Access-Control-Allow-Origin")) + assert.Empty(t, resp.Get("Access-Control-Allow-Headers")) + assert.Empty(t, resp.Get("Access-Control-Allow-Credentials")) + // Added by the app handler. + assert.Equal(t, "simple", resp.Get("X-CORS-Handler")) + }, + }, + // Authenticated + // { + // // Same behavior as Default/Public/Preflight/Subdomain. + // name: "Default/Authenticated/Preflight/Subdomain", + // app: func(details *Details) App { return details.Apps.AuthenticatedCORSDefault }, + // client: authenticatedClient, + // origin: ownSubdomain, + // httpMethod: http.MethodOptions, + // expectedStatusCode: http.StatusOK, + // checkResponseHeaders: func(t *testing.T, origin string, resp http.Header) { + // assert.Equal(t, origin, resp.Get("Access-Control-Allow-Origin")) + // assert.Contains(t, resp.Get("Access-Control-Allow-Methods"), http.MethodGet) + // assert.Equal(t, "true", resp.Get("Access-Control-Allow-Credentials")) + // assert.Equal(t, "X-Got-Host", resp.Get("Access-Control-Allow-Headers")) + // }, + // }, + { + // Same behavior as Default/Public/Preflight/External. + name: "Default/Authenticated/Preflight/External", + app: func(details *Details) App { return details.Apps.AuthenticatedCORSDefault }, + client: authenticatedClient, + origin: externalOrigin, + httpMethod: http.MethodOptions, + expectedStatusCode: http.StatusOK, + checkResponseHeaders: func(t *testing.T, origin string, resp http.Header) { + assert.Empty(t, resp.Get("Access-Control-Allow-Origin")) + }, + }, + { + // An authenticated request to the app is allowed from its own subdomain. + name: "Default/Authenticated/GET/Subdomain", + app: func(details *Details) App { return details.Apps.AuthenticatedCORSDefault }, + client: authenticatedClient, + origin: ownSubdomain, + httpMethod: http.MethodGet, + expectedStatusCode: http.StatusOK, + checkResponseHeaders: func(t *testing.T, origin string, resp http.Header) { + assert.Equal(t, origin, resp.Get("Access-Control-Allow-Origin")) + assert.Equal(t, "true", resp.Get("Access-Control-Allow-Credentials")) + // Added by the app handler. + assert.Equal(t, "simple", resp.Get("X-CORS-Handler")) + }, + }, + { + // An authenticated request to the app is allowed from an external origin. + // The origin doesn't match the app's own subdomain, so the CORS headers are not added. + name: "Default/Authenticated/GET/External", + app: func(details *Details) App { return details.Apps.AuthenticatedCORSDefault }, + client: authenticatedClient, + origin: externalOrigin, + httpMethod: http.MethodGet, + expectedStatusCode: http.StatusOK, + checkResponseHeaders: func(t *testing.T, origin string, resp http.Header) { + assert.Empty(t, resp.Get("Access-Control-Allow-Origin")) + assert.Empty(t, resp.Get("Access-Control-Allow-Headers")) + assert.Empty(t, resp.Get("Access-Control-Allow-Credentials")) + // Added by the app handler. + assert.Equal(t, "simple", resp.Get("X-CORS-Handler")) + }, + }, + // { + // // Owners can access their own apps from their own subdomain with valid CORS headers. + // name: "Default/Authenticated/GET/SubdomainOwner", + // app: func(details *Details) App { return details.Apps.AuthenticatedCORSDefault }, + // client: ownerClient, + // origin: ownSubdomain, + // httpMethod: http.MethodGet, + // expectedStatusCode: http.StatusOK, + // checkResponseHeaders: func(t *testing.T, origin string, resp http.Header) { + // assert.Equal(t, origin, resp.Get("Access-Control-Allow-Origin")) + // assert.Equal(t, "true", resp.Get("Access-Control-Allow-Credentials")) + // // Added by the app handler. + // assert.Equal(t, "simple", resp.Get("X-CORS-Handler")) + // }, + // }, + { + // Owners can't access their own apps from an external origin with valid CORS headers. + name: "Default/Owner/GET/ExternalOwner", + app: func(details *Details) App { return details.Apps.AuthenticatedCORSDefault }, + client: ownerClient, + origin: externalOrigin, + httpMethod: http.MethodGet, + expectedStatusCode: http.StatusOK, + checkResponseHeaders: func(t *testing.T, origin string, resp http.Header) { + // We don't add a valid Allow-Origin header for requests we won't proxy. + assert.Empty(t, resp.Get("Access-Control-Allow-Origin")) + }, + }, + { + // Same behavior as Default/Public/GET/NoOrigin. + name: "Default/Authenticated/GET/NoOrigin", + app: func(details *Details) App { return details.Apps.AuthenticatedCORSDefault }, + client: authenticatedClient, + origin: func(*Details, App) string { return "" }, + httpMethod: http.MethodGet, + expectedStatusCode: http.StatusOK, + checkResponseHeaders: func(t *testing.T, origin string, resp http.Header) { + assert.Empty(t, resp.Get("Access-Control-Allow-Origin")) + assert.Empty(t, resp.Get("Access-Control-Allow-Headers")) + assert.Empty(t, resp.Get("Access-Control-Allow-Credentials")) + // Added by the app handler. + assert.Equal(t, "simple", resp.Get("X-CORS-Handler")) + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + + var reqHeaders http.Header + + // Given: a workspace app + appDetails := setupProxyTest(t, &DeploymentOptions{ + // Setup an HTTP handler which is the "app"; this handler conditionally responds + // to requests based on the CORS behavior + handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, err := r.Cookie(codersdk.SessionTokenCookie) + assert.ErrorIs(t, err, http.ErrNoCookie) + + // Store the request headers for later assertions + reqHeaders = r.Header + w.Header().Set("X-CORS-Handler", "simple") + }), + }) + + if appDetails.BlockDirect { + t.Skip("skipping because BlockDirect is true") + } + + // Given: a client + client := tc.client(t, appDetails) + path := appDetails.SubdomainAppURL(tc.app(appDetails)).String() + origin := tc.origin(appDetails, tc.app(appDetails)) + + // When: a preflight request is made to an app with a specified CORS behavior + resp, err := requestWithRetries(ctx, t, client, tc.httpMethod, path, nil, func(r *http.Request) { + // Mimic non-browser clients that don't send the Origin header. + if origin != "" { + r.Header.Set("Origin", origin) + } + r.Header.Set("Access-Control-Request-Method", "GET") + r.Header.Set("Access-Control-Request-Headers", "X-Got-Host") + }) + require.NoError(t, err) + defer resp.Body.Close() + + // Then: the request & response must match expectations + assert.Equal(t, tc.expectedStatusCode, resp.StatusCode) + assert.NoError(t, err) + if tc.checkRequestHeaders != nil { + tc.checkRequestHeaders(t, origin, reqHeaders) + } + tc.checkResponseHeaders(t, origin, resp.Header) + }) + } + }) } type fakeStatsReporter struct { diff --git a/coderd/workspaceapps/apptest/setup.go b/coderd/workspaceapps/apptest/setup.go index 06544446fe6e2..9107ea755d5f3 100644 --- a/coderd/workspaceapps/apptest/setup.go +++ b/coderd/workspaceapps/apptest/setup.go @@ -31,13 +31,15 @@ import ( ) const ( - proxyTestAgentName = "agent-name" - proxyTestAppNameFake = "test-app-fake" - proxyTestAppNameOwner = "test-app-owner" - proxyTestAppNameAuthenticated = "test-app-authenticated" - proxyTestAppNamePublic = "test-app-public" - proxyTestAppQuery = "query=true" - proxyTestAppBody = "hello world from apps test" + proxyTestAgentName = "agent-name" + proxyTestAppNameFake = "test-app-fake" + proxyTestAppNameOwner = "test-app-owner" + proxyTestAppNameAuthenticated = "test-app-authenticated" + proxyTestAppNamePublic = "test-app-public" + proxyTestAppNameAuthenticatedCORSDefault = "test-app-authenticated-cors-default" + proxyTestAppNamePublicCORSDefault = "test-app-public-cors-default" + proxyTestAppQuery = "query=true" + proxyTestAppBody = "hello world from apps test" proxyTestSubdomainRaw = "*.test.coder.com" proxyTestSubdomain = "test.coder.com" @@ -60,6 +62,7 @@ type DeploymentOptions struct { noWorkspace bool port uint16 headers http.Header + handler http.Handler } // Deployment is a license-agnostic deployment with all the fields that apps @@ -72,6 +75,7 @@ type Deployment struct { FirstUser codersdk.CreateFirstUserResponse PathAppBaseURL *url.URL FlushStats func() + BlockDirect bool } // DeploymentFactory generates a deployment with an API client, a path base URL, @@ -109,12 +113,14 @@ type Details struct { AppPort uint16 Apps struct { - Fake App - Owner App - Authenticated App - Public App - Port App - PortHTTPS App + Fake App + Owner App + Authenticated App + Public App + Port App + PortHTTPS App + PublicCORSDefault App + AuthenticatedCORSDefault App } } @@ -201,7 +207,7 @@ func setupProxyTestWithFactory(t *testing.T, factory DeploymentFactory, opts *De } if opts.port == 0 { - opts.port = appServer(t, opts.headers, opts.ServeHTTPS) + opts.port = appServer(t, opts.headers, opts.ServeHTTPS, opts.handler) } workspace, agnt := createWorkspaceWithApps(t, deployment.SDKClient, deployment.FirstUser.OrganizationID, me, opts.port, opts.ServeHTTPS) @@ -252,30 +258,48 @@ func setupProxyTestWithFactory(t *testing.T, factory DeploymentFactory, opts *De AgentName: agnt.Name, AppSlugOrPort: strconv.Itoa(int(opts.port)) + "s", } + details.Apps.PublicCORSDefault = App{ + Username: me.Username, + WorkspaceName: workspace.Name, + AgentName: agnt.Name, + AppSlugOrPort: proxyTestAppNamePublicCORSDefault, + Query: proxyTestAppQuery, + } + details.Apps.AuthenticatedCORSDefault = App{ + Username: me.Username, + WorkspaceName: workspace.Name, + AgentName: agnt.Name, + AppSlugOrPort: proxyTestAppNameAuthenticatedCORSDefault, + Query: proxyTestAppQuery, + } return details } //nolint:revive -func appServer(t *testing.T, headers http.Header, isHTTPS bool) uint16 { - server := httptest.NewUnstartedServer( - http.HandlerFunc( - func(w http.ResponseWriter, r *http.Request) { - _, err := r.Cookie(codersdk.SessionTokenCookie) - assert.ErrorIs(t, err, http.ErrNoCookie) - w.Header().Set("X-Forwarded-For", r.Header.Get("X-Forwarded-For")) - w.Header().Set("X-Got-Host", r.Host) - for name, values := range headers { - for _, value := range values { - w.Header().Add(name, value) - } +func appServer(t *testing.T, headers http.Header, isHTTPS bool, handler http.Handler) uint16 { + defaultHandler := http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + _, err := r.Cookie(codersdk.SessionTokenCookie) + assert.ErrorIs(t, err, http.ErrNoCookie) + w.Header().Set("X-Forwarded-For", r.Header.Get("X-Forwarded-For")) + w.Header().Set("X-Got-Host", r.Host) + for name, values := range headers { + for _, value := range values { + w.Header().Add(name, value) } - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(proxyTestAppBody)) - }, - ), + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(proxyTestAppBody)) + }, ) + if handler == nil { + handler = defaultHandler + } + + server := httptest.NewUnstartedServer(handler) + server.Config.ReadHeaderTimeout = time.Minute if isHTTPS { server.StartTLS() @@ -361,6 +385,20 @@ func createWorkspaceWithApps(t *testing.T, client *codersdk.Client, orgID uuid.U Url: appURL, Subdomain: true, }, + { + Slug: proxyTestAppNamePublicCORSDefault, + DisplayName: proxyTestAppNamePublicCORSDefault, + SharingLevel: proto.AppSharingLevel_PUBLIC, + Url: appURL, + Subdomain: true, + }, + { + Slug: proxyTestAppNameAuthenticatedCORSDefault, + DisplayName: proxyTestAppNameAuthenticatedCORSDefault, + SharingLevel: proto.AppSharingLevel_AUTHENTICATED, + Url: appURL, + Subdomain: true, + }, } version := coderdtest.CreateTemplateVersion(t, client, orgID, &echo.Responses{ Parse: echo.ParseComplete, diff --git a/enterprise/wsproxy/wsproxy_test.go b/enterprise/wsproxy/wsproxy_test.go index 4add46af9bc0a..bac1c30c1e0f4 100644 --- a/enterprise/wsproxy/wsproxy_test.go +++ b/enterprise/wsproxy/wsproxy_test.go @@ -1072,6 +1072,7 @@ func TestWorkspaceProxyWorkspaceApps_BlockDirect(t *testing.T) { SDKClient: client, FirstUser: user, PathAppBaseURL: proxyAPI.Options.AccessURL, + BlockDirect: true, FlushStats: flushStats, } })
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies: