package coderd_test import ( "bufio" "context" "encoding/json" "fmt" "io" "net" "net/http" "net/http/httputil" "net/url" "strings" "testing" "time" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/xerrors" "cdr.dev/slog/sloggers/slogtest" "github.com/coder/coder/agent" "github.com/coder/coder/coderd" "github.com/coder/coder/coderd/coderdtest" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" "github.com/coder/coder/coderd/rbac" "github.com/coder/coder/codersdk" "github.com/coder/coder/codersdk/agentsdk" "github.com/coder/coder/provisioner/echo" "github.com/coder/coder/provisionersdk/proto" "github.com/coder/coder/testutil" ) 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" proxyTestSubdomainRaw = "*.test.coder.com" proxyTestSubdomain = "test.coder.com" ) func TestGetAppHost(t *testing.T) { t.Parallel() cases := []struct { name string accessURL string appHostname string expected string }{ { name: "OK", accessURL: "https://test.coder.com", appHostname: "*.test.coder.com", expected: "*.test.coder.com", }, { name: "None", accessURL: "https://test.coder.com", appHostname: "", expected: "", }, { name: "OKWithPort", accessURL: "https://test.coder.com:8443", appHostname: "*.test.coder.com", expected: "*.test.coder.com:8443", }, { name: "OKWithSuffix", accessURL: "https://test.coder.com:8443", appHostname: "*--suffix.test.coder.com", expected: "*--suffix.test.coder.com:8443", }, } for _, c := range cases { c := c t.Run(c.name, func(t *testing.T) { t.Parallel() accessURL, err := url.Parse(c.accessURL) require.NoError(t, err) client := coderdtest.New(t, &coderdtest.Options{ AccessURL: accessURL, AppHostname: c.appHostname, }) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() // Should not leak to unauthenticated users. host, err := client.AppHost(ctx) require.Error(t, err) require.Equal(t, "", host.Host) _ = coderdtest.CreateFirstUser(t, client) host, err = client.AppHost(ctx) require.NoError(t, err) require.Equal(t, c.expected, host.Host) }) } } 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, 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) server := http.Server{ ReadHeaderTimeout: time.Minute, Handler: 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.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(proxyTestAppBody)) }), } t.Cleanup(func() { _ = server.Close() _ = ln.Close() }) go server.Serve(ln) tcpAddr, ok := ln.Addr().(*net.TCPAddr) require.True(t, ok) 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{ DeploymentConfig: deploymentConfig, AppHostname: opts.AppHost, IncludeProvisionerDaemon: true, AgentStatsRefreshInterval: time.Millisecond * 100, MetricsCacheRefreshInterval: time.Millisecond * 100, RealIPConfig: &httpmw.RealIPConfig{ TrustedOrigins: []*net.IPNet{{ IP: net.ParseIP("127.0.0.1"), Mask: net.CIDRMask(8, 32), }}, TrustedHeaders: []string{ "CF-Connecting-IP", }, }, }) user := coderdtest.CreateFirstUser(t, client) 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 } forceURLTransport(t, client) return client, user, workspace, uint16(tcpAddr.Port) } func createWorkspaceWithApps(t *testing.T, client *codersdk.Client, orgID uuid.UUID, appHost string, port uint16, workspaceMutators ...func(*codersdk.CreateWorkspaceRequest)) codersdk.Workspace { authToken := uuid.NewString() appURL := fmt.Sprintf("http://127.0.0.1:%d?%s", port, proxyTestAppQuery) version := coderdtest.CreateTemplateVersion(t, client, orgID, &echo.Responses{ Parse: echo.ParseComplete, ProvisionPlan: echo.ProvisionComplete, ProvisionApply: []*proto.Provision_Response{{ Type: &proto.Provision_Response_Complete{ Complete: &proto.Provision_Complete{ Resources: []*proto.Resource{{ Name: "example", Type: "aws_instance", Agents: []*proto.Agent{{ Id: uuid.NewString(), Name: proxyTestAgentName, Auth: &proto.Agent_Token{ Token: authToken, }, Apps: []*proto.App{ { Slug: proxyTestAppNameFake, DisplayName: proxyTestAppNameFake, SharingLevel: proto.AppSharingLevel_OWNER, // Hopefully this IP and port doesn't exist. Url: "http://127.1.0.1:65535", }, { Slug: proxyTestAppNameOwner, DisplayName: proxyTestAppNameOwner, SharingLevel: proto.AppSharingLevel_OWNER, Url: appURL, }, { Slug: proxyTestAppNameAuthenticated, DisplayName: proxyTestAppNameAuthenticated, SharingLevel: proto.AppSharingLevel_AUTHENTICATED, Url: appURL, }, { Slug: proxyTestAppNamePublic, DisplayName: proxyTestAppNamePublic, SharingLevel: proto.AppSharingLevel_PUBLIC, Url: appURL, }, }, }}, }}, }, }, }}, }) template := coderdtest.CreateTemplate(t, client, orgID, version.ID) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) 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 := agentsdk.New(client.URL) agentClient.SetSessionToken(authToken) if appHost != "" { metadata, err := agentClient.Metadata(context.Background()) require.NoError(t, err) proxyURL := fmt.Sprintf( "http://{{port}}--%s--%s--%s%s", proxyTestAgentName, workspace.Name, user.Username, strings.ReplaceAll(appHost, "*", ""), ) if client.URL.Port() != "" { proxyURL += fmt.Sprintf(":%s", client.URL.Port()) } require.Equal(t, proxyURL, metadata.VSCodePortProxyURI) } agentCloser := agent.New(agent.Options{ Client: agentClient, Logger: slogtest.Make(t, nil).Named("agent"), }) t.Cleanup(func() { _ = agentCloser.Close() }) coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) return workspace } func TestWorkspaceAppsProxyPath(t *testing.T) { t.Parallel() 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() client := codersdk.New(client.URL) client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse } 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.StatusTemporaryRedirect, resp.StatusCode) loc, err := resp.Location() require.NoError(t, err) require.True(t, loc.Query().Has("message")) require.True(t, loc.Query().Has("redirect")) }) t.Run("NoAccessShould404", func(t *testing.T) { t.Parallel() userClient := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID, rbac.RoleMember()) userClient.HTTPClient.CheckRedirect = client.HTTPClient.CheckRedirect userClient.HTTPClient.Transport = client.HTTPClient.Transport ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() resp, err := requestWithRetries(ctx, t, userClient, http.MethodGet, fmt.Sprintf("/@me/%s/apps/%s", workspace.Name, proxyTestAppNameOwner), nil) require.NoError(t, err) defer resp.Body.Close() require.Equal(t, http.StatusNotFound, resp.StatusCode) }) t.Run("RedirectsWithSlash", func(t *testing.T) { t.Parallel() 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.StatusTemporaryRedirect, resp.StatusCode) }) t.Run("RedirectsWithQuery", func(t *testing.T) { t.Parallel() 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.StatusTemporaryRedirect, resp.StatusCode) loc, err := resp.Location() require.NoError(t, err) require.Equal(t, proxyTestAppQuery, loc.RawQuery) }) t.Run("Proxies", func(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() resp, err := requestWithRetries(ctx, t, client, http.MethodGet, fmt.Sprintf("/@me/%s/apps/%s/?%s", workspace.Name, proxyTestAppNameOwner, proxyTestAppQuery), nil) require.NoError(t, err) defer resp.Body.Close() body, err := io.ReadAll(resp.Body) require.NoError(t, err) require.Equal(t, proxyTestAppBody, string(body)) require.Equal(t, http.StatusOK, resp.StatusCode) }) t.Run("ForwardsIP", func(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() resp, err := requestWithRetries(ctx, t, client, http.MethodGet, fmt.Sprintf("/@me/%s/apps/%s/?%s", workspace.Name, proxyTestAppNameOwner, proxyTestAppQuery), nil, func(r *http.Request) { r.Header.Set("Cf-Connecting-IP", "1.1.1.1") }) require.NoError(t, err) defer resp.Body.Close() body, err := io.ReadAll(resp.Body) require.NoError(t, err) require.Equal(t, proxyTestAppBody, string(body)) require.Equal(t, http.StatusOK, resp.StatusCode) require.Equal(t, "1.1.1.1,127.0.0.1", resp.Header.Get("X-Forwarded-For")) }) t.Run("ProxyError", func(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() resp, err := client.Request(ctx, http.MethodGet, fmt.Sprintf("/@me/%s/apps/%s/", workspace.Name, proxyTestAppNameFake), nil) require.NoError(t, err) defer resp.Body.Close() require.Equal(t, http.StatusBadGateway, resp.StatusCode) }) } func TestWorkspaceApplicationAuth(t *testing.T) { t.Parallel() // The OK test checks the entire end-to-end flow of authentication. t.Run("End-to-End", func(t *testing.T) { t.Parallel() client, firstUser, workspace, _ := setupProxyTest(t, nil) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() // Get the current user and API key. user, err := client.User(ctx, codersdk.Me) require.NoError(t, err) currentAPIKey, err := client.APIKey(ctx, firstUser.UserID.String(), strings.Split(client.SessionToken(), "-")[0]) require.NoError(t, err) // Try to load the application without authentication. subdomain := fmt.Sprintf("%s--%s--%s--%s", proxyTestAppNameOwner, proxyTestAgentName, workspace.Name, user.Username) u, err := url.Parse(fmt.Sprintf("http://%s.%s/test", subdomain, proxyTestSubdomain)) require.NoError(t, err) req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) require.NoError(t, err) var resp *http.Response resp, err = doWithRetries(t, client, req) require.NoError(t, err) resp.Body.Close() // Check that the Location is correct. require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) gotLocation, err := resp.Location() require.NoError(t, err) require.Equal(t, client.URL.Host, gotLocation.Host) require.Equal(t, "/api/v2/applications/auth-redirect", gotLocation.Path) require.Equal(t, u.String(), gotLocation.Query().Get("redirect_uri")) // Load the application auth-redirect endpoint. resp, err = requestWithRetries(ctx, t, client, http.MethodGet, "/api/v2/applications/auth-redirect", nil, codersdk.WithQueryParam( "redirect_uri", u.String(), )) require.NoError(t, err) defer resp.Body.Close() require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) gotLocation, err = resp.Location() require.NoError(t, err) // Copy the query parameters and then check equality. u.RawQuery = gotLocation.RawQuery require.Equal(t, u, gotLocation) // Verify the API key is set. var encryptedAPIKey string for k, v := range gotLocation.Query() { // The query parameter may change dynamically in the future and is // not exported, so we just use a fuzzy check instead. if strings.Contains(k, "api_key") { encryptedAPIKey = v[0] } } require.NotEmpty(t, encryptedAPIKey, "no API key was set in the query parameters") // Decrypt the API key by following the request. t.Log("navigating to: ", gotLocation.String()) req, err = http.NewRequestWithContext(ctx, "GET", gotLocation.String(), nil) require.NoError(t, err) resp, err = doWithRetries(t, client, req) require.NoError(t, err) resp.Body.Close() require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) cookies := resp.Cookies() require.Len(t, cookies, 1) apiKey := cookies[0].Value // Fetch the API key. apiKeyInfo, err := client.APIKey(ctx, firstUser.UserID.String(), strings.Split(apiKey, "-")[0]) require.NoError(t, err) require.Equal(t, user.ID, apiKeyInfo.UserID) require.Equal(t, codersdk.LoginTypePassword, apiKeyInfo.LoginType) require.WithinDuration(t, currentAPIKey.ExpiresAt, apiKeyInfo.ExpiresAt, 5*time.Second) // Verify the API key permissions appClient := codersdk.New(client.URL) appClient.SetSessionToken(apiKey) appClient.HTTPClient.CheckRedirect = client.HTTPClient.CheckRedirect appClient.HTTPClient.Transport = client.HTTPClient.Transport var ( canCreateApplicationConnect = "can-create-application_connect" canReadUserMe = "can-read-user-me" ) authRes, err := appClient.AuthCheck(ctx, codersdk.AuthorizationRequest{ Checks: map[string]codersdk.AuthorizationCheck{ canCreateApplicationConnect: { Object: codersdk.AuthorizationObject{ ResourceType: "application_connect", OwnerID: "me", OrganizationID: firstUser.OrganizationID.String(), }, Action: "create", }, canReadUserMe: { Object: codersdk.AuthorizationObject{ ResourceType: "user", OwnerID: "me", ResourceID: firstUser.UserID.String(), }, Action: "read", }, }, }) require.NoError(t, err) require.True(t, authRes[canCreateApplicationConnect]) require.False(t, authRes[canReadUserMe]) // Load the application page with the API key set. gotLocation, err = resp.Location() require.NoError(t, err) t.Log("navigating to: ", gotLocation.String()) req, err = http.NewRequestWithContext(ctx, "GET", gotLocation.String(), nil) require.NoError(t, err) req.Header.Set(codersdk.SessionTokenHeader, apiKey) resp, err = doWithRetries(t, client, req) require.NoError(t, err) resp.Body.Close() require.Equal(t, http.StatusOK, resp.StatusCode) }) t.Run("VerifyRedirectURI", func(t *testing.T) { t.Parallel() client, _, _, _ := setupProxyTest(t, nil) cases := []struct { name string redirectURI string status int messageContains string }{ { name: "NoRedirectURI", redirectURI: "", status: http.StatusBadRequest, messageContains: "Missing redirect_uri query parameter", }, { name: "InvalidURI", redirectURI: "not a url", status: http.StatusBadRequest, messageContains: "Invalid redirect_uri query parameter", }, { name: "NotMatchAppHostname", redirectURI: "https://app--agent--workspace--user.not-a-match.com", status: http.StatusBadRequest, messageContains: "The redirect_uri query parameter must be a valid app subdomain", }, { name: "InvalidAppURL", redirectURI: "https://not-an-app." + proxyTestSubdomain, status: http.StatusBadRequest, messageContains: "The redirect_uri query parameter must be a valid app subdomain", }, } for _, c := range cases { c := c t.Run(c.name, func(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() resp, err := requestWithRetries(ctx, t, client, http.MethodGet, "/api/v2/applications/auth-redirect", nil, codersdk.WithQueryParam("redirect_uri", c.redirectURI), ) require.NoError(t, err) defer resp.Body.Close() require.Equal(t, http.StatusBadRequest, resp.StatusCode) }) } }) } // This test ensures that the subdomain handler does nothing if --app-hostname // is not set by the admin. func TestWorkspaceAppsProxySubdomainPassthrough(t *testing.T) { t.Parallel() // No AppHostname set. client := coderdtest.New(t, &coderdtest.Options{ AppHostname: "", }) firstUser := coderdtest.CreateFirstUser(t, client) // Configure the HTTP client to always route all requests to the coder test // server. 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() }) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() uri := fmt.Sprintf("http://app--agent--workspace--username.%s/api/v2/users/me", proxyTestSubdomain) resp, err := requestWithRetries(ctx, t, client, http.MethodGet, uri, nil) require.NoError(t, err) defer resp.Body.Close() // Should look like a codersdk.User response. require.Equal(t, http.StatusOK, resp.StatusCode) var user codersdk.User err = json.NewDecoder(resp.Body).Decode(&user) require.NoError(t, err) require.Equal(t, firstUser.UserID, user.ID) } // This test ensures that the subdomain handler blocks the request if it looks // like a workspace app request but the configured app hostname differs from the // request, or the request is not a valid app subdomain but the hostname // matches. func TestWorkspaceAppsProxySubdomainBlocked(t *testing.T) { t.Parallel() setup := func(t *testing.T, appHostname string) *codersdk.Client { client := coderdtest.New(t, &coderdtest.Options{ AppHostname: appHostname, }) _ = coderdtest.CreateFirstUser(t, client) // Configure the HTTP client to always route all requests to the coder test // server. 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() }) return client } t.Run("InvalidSubdomain", func(t *testing.T) { t.Parallel() client := setup(t, proxyTestSubdomainRaw) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() uri := fmt.Sprintf("http://not-an-app-subdomain.%s/api/v2/users/me", proxyTestSubdomain) resp, err := requestWithRetries(ctx, t, client, http.MethodGet, uri, nil) require.NoError(t, err) defer resp.Body.Close() // Should have a HTML error response. require.Equal(t, http.StatusBadRequest, resp.StatusCode) body, err := io.ReadAll(resp.Body) require.NoError(t, err) require.Contains(t, string(body), "Could not parse subdomain application URL") }) } func TestWorkspaceAppsProxySubdomain(t *testing.T) { t.Parallel() client, firstUser, _, port := setupProxyTest(t, nil) // proxyURL generates a URL for the proxy subdomain. The default path is a // slash. proxyURL := func(t *testing.T, client *codersdk.Client, appNameOrPort interface{}, pathAndQuery ...string) string { t.Helper() var ( appName string port uint16 ) if val, ok := appNameOrPort.(string); ok { appName = val } else { port, ok = appNameOrPort.(uint16) require.True(t, ok) } ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() me, err := client.User(ctx, codersdk.Me) require.NoError(t, err, "get current user details") res, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{ Owner: codersdk.Me, }) require.NoError(t, err, "get workspaces") require.Len(t, res.Workspaces, 1, "expected 1 workspace") appHost, err := client.AppHost(ctx) require.NoError(t, err, "get app host") subdomain := httpapi.ApplicationURL{ AppSlug: appName, Port: port, AgentName: proxyTestAgentName, WorkspaceName: res.Workspaces[0].Name, Username: me.Username, }.String() hostname := strings.Replace(appHost.Host, "*", subdomain, 1) actualPath := "/" query := "" if len(pathAndQuery) > 0 { actualPath = pathAndQuery[0] } if len(pathAndQuery) > 1 { query = pathAndQuery[1] } return (&url.URL{ Scheme: "http", Host: hostname, Path: actualPath, RawQuery: query, }).String() } t.Run("NoAccessShould401", func(t *testing.T) { t.Parallel() userClient := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID, rbac.RoleMember()) userClient.HTTPClient.CheckRedirect = client.HTTPClient.CheckRedirect userClient.HTTPClient.Transport = client.HTTPClient.Transport ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() resp, err := requestWithRetries(ctx, t, userClient, http.MethodGet, proxyURL(t, client, proxyTestAppNameOwner), nil) require.NoError(t, err) defer resp.Body.Close() require.Equal(t, http.StatusNotFound, resp.StatusCode) }) t.Run("RedirectsWithSlash", func(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() slashlessURL := proxyURL(t, client, proxyTestAppNameOwner, "") resp, err := requestWithRetries(ctx, t, client, http.MethodGet, slashlessURL, nil) require.NoError(t, err) defer resp.Body.Close() require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) loc, err := resp.Location() require.NoError(t, err) require.Equal(t, slashlessURL+"/?"+proxyTestAppQuery, loc.String()) }) t.Run("RedirectsWithQuery", func(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() querylessURL := proxyURL(t, client, proxyTestAppNameOwner, "/", "") resp, err := requestWithRetries(ctx, t, client, http.MethodGet, querylessURL, nil) require.NoError(t, err) defer resp.Body.Close() require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) loc, err := resp.Location() require.NoError(t, err) require.Equal(t, proxyTestAppQuery, loc.RawQuery) }) t.Run("Proxies", func(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() resp, err := requestWithRetries(ctx, t, client, http.MethodGet, proxyURL(t, client, proxyTestAppNameOwner, "/", proxyTestAppQuery), nil) require.NoError(t, err) defer resp.Body.Close() body, err := io.ReadAll(resp.Body) require.NoError(t, err) require.Equal(t, proxyTestAppBody, string(body)) require.Equal(t, http.StatusOK, resp.StatusCode) }) t.Run("ProxiesPort", func(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() resp, err := requestWithRetries(ctx, t, client, http.MethodGet, proxyURL(t, client, port, "/", proxyTestAppQuery), nil) require.NoError(t, err) defer resp.Body.Close() body, err := io.ReadAll(resp.Body) require.NoError(t, err) require.Equal(t, proxyTestAppBody, string(body)) require.Equal(t, http.StatusOK, resp.StatusCode) }) t.Run("ProxyError", func(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() resp, err := client.Request(ctx, http.MethodGet, proxyURL(t, client, proxyTestAppNameFake, "/", ""), nil) require.NoError(t, err) defer resp.Body.Close() require.Equal(t, http.StatusBadGateway, resp.StatusCode) }) t.Run("ProxyPortMinimumError", func(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() port := uint16(codersdk.WorkspaceAgentMinimumListeningPort - 1) resp, err := requestWithRetries(ctx, t, client, http.MethodGet, proxyURL(t, client, port, "/", proxyTestAppQuery), nil) require.NoError(t, err) defer resp.Body.Close() // Should have an error response. require.Equal(t, http.StatusBadRequest, resp.StatusCode) var resBody codersdk.Response err = json.NewDecoder(resp.Body).Decode(&resBody) require.NoError(t, err) require.Contains(t, resBody.Message, "Coder reserves ports less than") }) t.Run("SuffixWildcardOK", func(t *testing.T) { t.Parallel() client, _, _, _ := setupProxyTest(t, &setupProxyTestOpts{ AppHost: "*-suffix.test.coder.com", }) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() u := proxyURL(t, client, proxyTestAppNameOwner, "/", proxyTestAppQuery) t.Logf("url: %s", u) resp, err := requestWithRetries(ctx, t, client, http.MethodGet, u, nil) require.NoError(t, err) defer resp.Body.Close() body, err := io.ReadAll(resp.Body) require.NoError(t, err) require.Equal(t, proxyTestAppBody, string(body)) require.Equal(t, http.StatusOK, resp.StatusCode) }) t.Run("SuffixWildcardNotMatch", func(t *testing.T) { t.Parallel() client, _, _, _ := setupProxyTest(t, &setupProxyTestOpts{ AppHost: "*-suffix.test.coder.com", }) t.Run("NoSuffix", func(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() u := proxyURL(t, client, proxyTestAppNameOwner, "/", proxyTestAppQuery) // Replace the -suffix with nothing. u = strings.Replace(u, "-suffix", "", 1) resp, err := requestWithRetries(ctx, t, client, http.MethodGet, u, nil) require.NoError(t, err) defer resp.Body.Close() body, err := io.ReadAll(resp.Body) require.NoError(t, err) // It's probably rendering the dashboard, so only ensure that the body // doesn't match. require.NotContains(t, string(body), proxyTestAppBody) }) t.Run("DifferentSuffix", func(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() u := proxyURL(t, client, proxyTestAppNameOwner, "/", proxyTestAppQuery) // Replace the -suffix with something else. u = strings.Replace(u, "-suffix", "-not-suffix", 1) resp, err := requestWithRetries(ctx, t, client, http.MethodGet, u, nil) require.NoError(t, err) defer resp.Body.Close() body, err := io.ReadAll(resp.Body) require.NoError(t, err) // It's probably rendering the dashboard, so only ensure that the body // doesn't match. require.NotContains(t, string(body), proxyTestAppBody) }) }) } func TestAppSubdomainLogout(t *testing.T) { t.Parallel() keyID, keySecret, err := coderd.GenerateAPIKeyIDSecret() require.NoError(t, err) fakeAPIKey := fmt.Sprintf("%s-%s", keyID, keySecret) cases := []struct { name string // The cookie to send with the request. The regular API key header // is also sent to bypass any auth checks on this value, and to // ensure that the logout code is safe when multiple keys are // passed. // Empty value means no cookie is sent, "-" means send a valid // API key, and "bad-secret" means send a valid key ID with a bad // secret. cookie string // You can use "access_url" to use the site access URL as the // redirect URI, or "app_host" to use a valid app host. redirectURI string // If expectedStatus is not an error status, we expect the cookie to // be deleted if it was set. expectedStatus int expectedBodyContains string // If empty, the expected location is the redirectURI if the // expected status code is http.StatusTemporaryRedirect (using the // access URL if not set). // You can use "access_url" to force the access URL. expectedLocation string }{ { name: "OKAccessURL", cookie: "-", redirectURI: "access_url", expectedStatus: http.StatusTemporaryRedirect, }, { name: "OKAppHost", cookie: "-", redirectURI: "app_host", expectedStatus: http.StatusTemporaryRedirect, }, { name: "OKNoAPIKey", cookie: "", redirectURI: "access_url", // Even if the devurl cookie is missing, we still redirect without // any complaints. expectedStatus: http.StatusTemporaryRedirect, }, { name: "OKBadAPIKey", cookie: "test-api-key", redirectURI: "access_url", // Even if the devurl cookie is bad, we still delete the cookie and // redirect without any complaints. expectedStatus: http.StatusTemporaryRedirect, }, { name: "OKUnknownAPIKey", cookie: fakeAPIKey, redirectURI: "access_url", expectedStatus: http.StatusTemporaryRedirect, }, { name: "BadAPIKeySecret", cookie: "bad-secret", redirectURI: "access_url", expectedStatus: http.StatusUnauthorized, expectedBodyContains: "API key secret is invalid", }, { name: "InvalidRedirectURI", cookie: "-", redirectURI: string([]byte{0x00}), expectedStatus: http.StatusBadRequest, expectedBodyContains: "Could not parse redirect URI", }, { name: "DisallowedRedirectURI", cookie: "-", redirectURI: "https://github.com/coder/coder", // We don't allow redirecting to a different host, but we don't // show an error page and just redirect to the access URL to avoid // breaking the logout flow if the user is accessing from the wrong // host. expectedStatus: http.StatusTemporaryRedirect, expectedLocation: "access_url", }, } for _, c := range cases { c := c t.Run(c.name, func(t *testing.T) { t.Parallel() client, _, _, _ := setupProxyTest(t, nil) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() // The token should work. _, err := client.User(ctx, codersdk.Me) require.NoError(t, err) appHost, err := client.AppHost(ctx) require.NoError(t, err, "get app host") if c.cookie == "-" { c.cookie = client.SessionToken() } else if c.cookie == "bad-secret" { keyID, _, err := httpmw.SplitAPIToken(client.SessionToken()) require.NoError(t, err) c.cookie = fmt.Sprintf("%s-%s", keyID, keySecret) } if c.redirectURI == "access_url" { c.redirectURI = client.URL.String() } else if c.redirectURI == "app_host" { c.redirectURI = "http://" + strings.Replace(appHost.Host, "*", "something--something--something--something", 1) + "/" } if c.expectedLocation == "" && c.expectedStatus == http.StatusTemporaryRedirect { c.expectedLocation = c.redirectURI } if c.expectedLocation == "access_url" { c.expectedLocation = client.URL.String() } logoutURL := &url.URL{ Scheme: "http", Host: strings.Replace(appHost.Host, "*", "coder-logout", 1), Path: "/", } if c.redirectURI != "" { q := logoutURL.Query() q.Set("redirect_uri", c.redirectURI) logoutURL.RawQuery = q.Encode() } req, err := http.NewRequestWithContext(ctx, http.MethodGet, logoutURL.String(), nil) require.NoError(t, err, "create logout request") // The header is prioritized over the devurl cookie if both are // set, so this ensures we can trigger the logout code path with // bad cookies during tests. req.Header.Set(codersdk.SessionTokenHeader, client.SessionToken()) if c.cookie != "" { req.AddCookie(&http.Cookie{ Name: httpmw.DevURLSessionTokenCookie, Value: c.cookie, }) } client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse } resp, err := client.HTTPClient.Do(req) require.NoError(t, err, "do logout request") defer resp.Body.Close() require.Equal(t, c.expectedStatus, resp.StatusCode, "logout response status code") if c.expectedStatus < 400 && c.cookie != "" { cookies := resp.Cookies() require.Len(t, cookies, 1, "logout response cookies") cookie := cookies[0] require.Equal(t, httpmw.DevURLSessionTokenCookie, cookie.Name) require.Equal(t, "", cookie.Value) require.True(t, cookie.Expires.Before(time.Now()), "cookie should be expired") // The token shouldn't work anymore if it was the original valid // session token. if c.cookie == client.SessionToken() { _, err = client.User(ctx, codersdk.Me) require.Error(t, err) } } if c.expectedBodyContains != "" { body, err := io.ReadAll(resp.Body) require.NoError(t, err) require.Contains(t, string(body), c.expectedBodyContains, "logout response body") } if c.expectedLocation != "" { location := resp.Header.Get("Location") require.Equal(t, c.expectedLocation, location, "logout response location") } }) } } func TestAppSharing(t *testing.T) { t.Parallel() 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" 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) 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) 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, } for _, app := range agnt.Apps { found[app.DisplayName] = app.SharingLevel } require.Equal(t, expected, found, "apps have incorrect sharing levels") // Create a user in a different org. otherOrg, err := ownerClient.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{ Name: "a-different-org", }) require.NoError(t, err) userInOtherOrg, err := ownerClient.CreateUser(ctx, codersdk.CreateUserRequest{ Email: "no-template-access@coder.com", Username: "no-template-access", Password: password, OrganizationID: otherOrg.ID, }) require.NoError(t, err) clientInOtherOrg = codersdk.New(client.URL) loginRes, err = clientInOtherOrg.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{ Email: userInOtherOrg.Email, Password: password, }) require.NoError(t, err) clientInOtherOrg.SetSessionToken(loginRes.SessionToken) 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, isPathApp bool, username, workspaceName, agentName, appName string, client *codersdk.Client, shouldHaveAccess, shouldRedirectToLogin bool) { t.Helper() ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() // If the client has a session token, we also want to check that a // scoped key works. clients := []*codersdk.Client{client} if client.SessionToken() != "" { token, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{ Scope: codersdk.APIKeyScopeApplicationConnect, }) require.NoError(t, err) 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) } for i, client := range clients { msg := fmt.Sprintf("client %d", i) 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) 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) 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. assert.Equal(t, http.StatusNotFound, res.StatusCode, "should not have access, expected not found. "+msg) } } if shouldHaveAccess { assert.Equal(t, http.StatusOK, res.StatusCode, "should have access, expected ok. "+msg) assert.Contains(t, string(dump), "hello world", "should have access, expected hello world. "+msg) } } } testLevels := func(t *testing.T, isPathApp, pathAppSharingEnabled, siteOwnerPathAppAccessEnabled bool) { workspace, agnt, user, ownerClient, client, clientInOtherOrg, clientWithNoAuth := setup(t, pathAppSharingEnabled, siteOwnerPathAppAccessEnabled) allowedUnlessSharingDisabled := !isPathApp || pathAppSharingEnabled siteOwnerCanAccess := !isPathApp || siteOwnerPathAppAccessEnabled siteOwnerCanAccessShared := siteOwnerCanAccess || pathAppSharingEnabled deploymentConfig, err := ownerClient.DeploymentConfig(context.Background()) require.NoError(t, err) 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, 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, agnt.Name, proxyTestAppNameOwner, clientInOtherOrg, false, false) // Unauthenticated user should not have any access. verifyAccess(t, isPathApp, user.Username, workspace.Name, agnt.Name, proxyTestAppNameOwner, clientWithNoAuth, false, true) }) 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, 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, agnt.Name, proxyTestAppNameAuthenticated, clientInOtherOrg, allowedUnlessSharingDisabled, false) // Unauthenticated user should not have any access. verifyAccess(t, isPathApp, user.Username, workspace.Name, agnt.Name, proxyTestAppNameAuthenticated, clientWithNoAuth, false, true) }) 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, 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, agnt.Name, proxyTestAppNamePublic, clientInOtherOrg, allowedUnlessSharingDisabled, false) // Unauthenticated user should be able to access the workspace. 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) { t.Parallel() setupNonCanonicalHeadersTest := func(t *testing.T, customAppHost ...string) (*codersdk.Client, codersdk.CreateFirstUserResponse, codersdk.Workspace, uint16) { // Start a TCP server that manually parses the request. Golang's HTTP // server canonicalizes all HTTP request headers it receives, so we // can't use it to test that we forward non-canonical headers. // #nosec ln, err := net.Listen("tcp", ":0") require.NoError(t, err) go func() { for { c, err := ln.Accept() if xerrors.Is(err, net.ErrClosed) { return } require.NoError(t, err) go func() { s := bufio.NewScanner(c) // Read request line. assert.True(t, s.Scan()) reqLine := s.Text() assert.True(t, strings.HasPrefix(reqLine, fmt.Sprintf("GET /?%s HTTP/1.1", proxyTestAppQuery))) // Read headers and discard them. We collect the // Sec-WebSocket-Key header (with a capital S) to respond // with. secWebSocketKey := "(none found)" for s.Scan() { if s.Text() == "" { break } line := strings.TrimSpace(s.Text()) if strings.HasPrefix(line, "Sec-WebSocket-Key: ") { secWebSocketKey = strings.TrimPrefix(line, "Sec-WebSocket-Key: ") } } // Write response containing text/plain with the // Sec-WebSocket-Key header. res := fmt.Sprintf("HTTP/1.1 204 No Content\r\nSec-WebSocket-Key: %s\r\nConnection: close\r\n\r\n", secWebSocketKey) _, err = c.Write([]byte(res)) assert.NoError(t, err) err = c.Close() assert.NoError(t, err) }() } }() t.Cleanup(func() { _ = ln.Close() }) tcpAddr, ok := ln.Addr().(*net.TCPAddr) require.True(t, ok) appHost := proxyTestSubdomainRaw if len(customAppHost) > 0 { appHost = customAppHost[0] } client := coderdtest.New(t, &coderdtest.Options{ AppHostname: appHost, IncludeProvisionerDaemon: true, AgentStatsRefreshInterval: time.Millisecond * 100, MetricsCacheRefreshInterval: time.Millisecond * 100, RealIPConfig: &httpmw.RealIPConfig{ TrustedOrigins: []*net.IPNet{{ IP: net.ParseIP("127.0.0.1"), Mask: net.CIDRMask(8, 32), }}, TrustedHeaders: []string{ "CF-Connecting-IP", }, }, }) user := coderdtest.CreateFirstUser(t, client) workspace := createWorkspaceWithApps(t, client, user.OrganizationID, appHost, uint16(tcpAddr.Port)) // 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() }) return client, user, workspace, uint16(tcpAddr.Port) } t.Run("ProxyPath", func(t *testing.T) { t.Parallel() client, _, workspace, _ := setupNonCanonicalHeadersTest(t) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() u, err := client.URL.Parse(fmt.Sprintf("/@me/%s/apps/%s/?%s", workspace.Name, proxyTestAppNameOwner, proxyTestAppQuery)) require.NoError(t, err) req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) require.NoError(t, err) // Use a non-canonical header name. The S in Sec-WebSocket-Key should be // capitalized according to the websocket spec, but Golang will // lowercase it to match the HTTP/1 spec. // // Setting the header on the map directly will force the header to not // be canonicalized on the client, but it will be canonicalized on the // server. secWebSocketKey := "test-dean-was-here" req.Header["Sec-WebSocket-Key"] = []string{secWebSocketKey} req.Header.Set(codersdk.SessionTokenHeader, client.SessionToken()) resp, err := doWithRetries(t, client, req) require.NoError(t, err) defer resp.Body.Close() // The response should be a 204 No Content with the Sec-WebSocket-Key // header set to the value we sent. res, err := httputil.DumpResponse(resp, true) require.NoError(t, err) t.Log(string(res)) require.Equal(t, http.StatusNoContent, resp.StatusCode) require.Equal(t, secWebSocketKey, resp.Header.Get("Sec-WebSocket-Key")) }) t.Run("Subdomain", func(t *testing.T) { t.Parallel() appHost := proxyTestSubdomainRaw client, _, workspace, _ := setupNonCanonicalHeadersTest(t, appHost) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() user, err := client.User(ctx, codersdk.Me) require.NoError(t, err) u := fmt.Sprintf( "http://%s--%s--%s--%s%s?%s", proxyTestAppNameOwner, proxyTestAgentName, workspace.Name, user.Username, strings.ReplaceAll(appHost, "*", ""), proxyTestAppQuery, ) // Re-enable the default redirect behavior. client.HTTPClient.CheckRedirect = nil req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) require.NoError(t, err) // Use a non-canonical header name. The S in Sec-WebSocket-Key should be // capitalized according to the websocket spec, but Golang will // lowercase it to match the HTTP/1 spec. // // Setting the header on the map directly will force the header to not // be canonicalized on the client, but it will be canonicalized on the // server. secWebSocketKey := "test-dean-was-here" req.Header["Sec-WebSocket-Key"] = []string{secWebSocketKey} req.Header.Set(codersdk.SessionTokenHeader, client.SessionToken()) resp, err := doWithRetries(t, client, req) require.NoError(t, err) defer resp.Body.Close() // The response should be a 204 No Content with the Sec-WebSocket-Key // header set to the value we sent. res, err := httputil.DumpResponse(resp, true) require.NoError(t, err) t.Log(string(res)) require.Equal(t, http.StatusNoContent, resp.StatusCode) 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() }) }