diff --git a/coderd/activitybump_test.go b/coderd/activitybump_test.go index b9bb5d7c45f5b..6e5615fef4d45 100644 --- a/coderd/activitybump_test.go +++ b/coderd/activitybump_test.go @@ -9,10 +9,14 @@ import ( "github.com/stretchr/testify/require" "cdr.dev/slog/sloggers/slogtest" + "github.com/coder/coder/agent" "github.com/coder/coder/coderd/coderdtest" "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/schedule" "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" ) @@ -29,7 +33,6 @@ func TestWorkspaceActivityBump(t *testing.T) { } client = coderdtest.New(t, &coderdtest.Options{ - AppHostname: proxyTestSubdomainRaw, IncludeProvisionerDaemon: true, // Agent stats trigger the activity bump, so we want to report // very frequently in tests. @@ -47,9 +50,45 @@ func TestWorkspaceActivityBump(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) ttlMillis := int64(ttl / time.Millisecond) - workspace = createWorkspaceWithApps(t, client, user.OrganizationID, "", 1234, func(cwr *codersdk.CreateWorkspaceRequest) { + agentToken := uuid.NewString() + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &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: "agent", + Auth: &proto.Agent_Token{ + Token: agentToken, + }, + }}, + }}, + }, + }, + }}, + }) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) { cwr.TTLMillis = &ttlMillis }) + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + + agentClient := agentsdk.New(client.URL) + agentClient.SetSessionToken(agentToken) + 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) // Sanity-check that deadline is near. workspace, err := client.Workspace(ctx, workspace.ID) diff --git a/coderd/client_test.go b/coderd/client_test.go index 56fb4355e46a7..0ba31c8d32014 100644 --- a/coderd/client_test.go +++ b/coderd/client_test.go @@ -14,23 +14,6 @@ import ( // While running tests in parallel, the web server seems to be overloaded and responds with HTTP 502. // require.Eventually expects correct HTTP responses. -func doWithRetries(t require.TestingT, client *codersdk.Client, req *http.Request) (*http.Response, error) { - var resp *http.Response - var err error - require.Eventually(t, func() bool { - // nolint // only requests which are not passed upstream have a body closed - resp, err = client.HTTPClient.Do(req) - if resp != nil && resp.StatusCode == http.StatusBadGateway { - if resp.Body != nil { - resp.Body.Close() - } - return false - } - return true - }, testutil.WaitLong, testutil.IntervalFast) - return resp, err -} - func requestWithRetries(ctx context.Context, t require.TestingT, client *codersdk.Client, method, path string, body interface{}, opts ...codersdk.RequestOption) (*http.Response, error) { var resp *http.Response var err error diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index dc38b99242574..5ef3250c33146 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -1,9 +1,7 @@ package coderd_test import ( - "bufio" "context" - "encoding/json" "fmt" "net" "net/http" @@ -446,88 +444,6 @@ func TestWorkspaceAgentTailnet(t *testing.T) { require.Equal(t, "test", strings.TrimSpace(string(output))) } -func TestWorkspaceAgentPTY(t *testing.T) { - t.Parallel() - if runtime.GOOS == "windows" { - // This might be our implementation, or ConPTY itself. - // It's difficult to find extensive tests for it, so - // it seems like it could be either. - t.Skip("ConPTY appears to be inconsistent on Windows.") - } - client := coderdtest.New(t, &coderdtest.Options{ - IncludeProvisionerDaemon: true, - }) - user := coderdtest.CreateFirstUser(t, client) - authToken := uuid.NewString() - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, - ProvisionApply: echo.ProvisionApplyWithAgent(authToken), - }) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) - coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) - - agentClient := agentsdk.New(client.URL) - agentClient.SetSessionToken(authToken) - agentCloser := agent.New(agent.Options{ - Client: agentClient, - Logger: slogtest.Make(t, nil).Named("agent").Leveled(slog.LevelDebug), - }) - defer func() { - _ = agentCloser.Close() - }() - resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() - - conn, err := client.WorkspaceAgentReconnectingPTY(ctx, resources[0].Agents[0].ID, uuid.New(), 80, 80, "/bin/bash") - require.NoError(t, err) - defer conn.Close() - - // First attempt to resize the TTY. - // The websocket will close if it fails! - data, err := json.Marshal(codersdk.ReconnectingPTYRequest{ - Height: 250, - Width: 250, - }) - require.NoError(t, err) - _, err = conn.Write(data) - require.NoError(t, err) - bufRead := bufio.NewReader(conn) - - // Brief pause to reduce the likelihood that we send keystrokes while - // the shell is simultaneously sending a prompt. - time.Sleep(100 * time.Millisecond) - - data, err = json.Marshal(codersdk.ReconnectingPTYRequest{ - Data: "echo test\r\n", - }) - require.NoError(t, err) - _, err = conn.Write(data) - require.NoError(t, err) - - expectLine := func(matcher func(string) bool) { - for { - line, err := bufRead.ReadString('\n') - require.NoError(t, err) - if matcher(line) { - break - } - } - } - matchEchoCommand := func(line string) bool { - return strings.Contains(line, "echo test") - } - matchEchoOutput := func(line string) bool { - return strings.Contains(line, "test") && !strings.Contains(line, "echo") - } - - expectLine(matchEchoCommand) - expectLine(matchEchoOutput) -} - func TestWorkspaceAgentListeningPorts(t *testing.T) { t.Parallel() diff --git a/coderd/workspaceapps/apptest/apptest.go b/coderd/workspaceapps/apptest/apptest.go new file mode 100644 index 0000000000000..6ef8f31458e30 --- /dev/null +++ b/coderd/workspaceapps/apptest/apptest.go @@ -0,0 +1,1151 @@ +package apptest + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/http/cookiejar" + "net/http/httputil" + "net/url" + "runtime" + "strconv" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/xerrors" + + "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/coderd/rbac" + "github.com/coder/coder/codersdk" + "github.com/coder/coder/testutil" +) + +// Run runs the entire workspace app test suite against deployments minted +// by the provided factory. +func Run(t *testing.T, factory DeploymentFactory) { + setupProxyTest := func(t *testing.T, opts *DeploymentOptions) *AppDetails { + return setupProxyTestWithFactory(t, factory, opts) + } + + t.Run("ReconnectingPTY", func(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + // This might be our implementation, or ConPTY itself. + // It's difficult to find extensive tests for it, so + // it seems like it could be either. + t.Skip("ConPTY appears to be inconsistent on Windows.") + } + + appDetails := setupProxyTest(t, nil) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // Run the test against the path app hostname since that's where the + // reconnecting-pty proxy server we want to test is mounted. + client := codersdk.New(appDetails.PathAppBaseURL) + client.SetSessionToken(appDetails.Client.SessionToken()) + + conn, err := client.WorkspaceAgentReconnectingPTY(ctx, appDetails.Agent.ID, uuid.New(), 80, 80, "/bin/bash") + require.NoError(t, err) + defer conn.Close() + + // First attempt to resize the TTY. + // The websocket will close if it fails! + data, err := json.Marshal(codersdk.ReconnectingPTYRequest{ + Height: 250, + Width: 250, + }) + require.NoError(t, err) + _, err = conn.Write(data) + require.NoError(t, err) + bufRead := bufio.NewReader(conn) + + // Brief pause to reduce the likelihood that we send keystrokes while + // the shell is simultaneously sending a prompt. + time.Sleep(100 * time.Millisecond) + + data, err = json.Marshal(codersdk.ReconnectingPTYRequest{ + Data: "echo test\r\n", + }) + require.NoError(t, err) + _, err = conn.Write(data) + require.NoError(t, err) + + expectLine := func(matcher func(string) bool) { + for { + line, err := bufRead.ReadString('\n') + require.NoError(t, err) + if matcher(line) { + break + } + } + } + matchEchoCommand := func(line string) bool { + return strings.Contains(line, "echo test") + } + matchEchoOutput := func(line string) bool { + return strings.Contains(line, "test") && !strings.Contains(line, "echo") + } + + expectLine(matchEchoCommand) + expectLine(matchEchoOutput) + }) + + t.Run("WorkspaceAppsProxyPath", func(t *testing.T) { + t.Parallel() + + appDetails := setupProxyTest(t, nil) + + t.Run("Disabled", func(t *testing.T) { + t.Parallel() + + appDetails := setupProxyTest(t, &DeploymentOptions{ + DisablePathApps: true, + }) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, appDetails.PathAppURL(appDetails.OwnerApp).String(), 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() + + // Clone the client to strip auth. + unauthedClient := codersdk.New(appDetails.Client.URL) + unauthedClient.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, unauthedClient, http.MethodGet, appDetails.PathAppURL(appDetails.OwnerApp).String(), 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, appDetails.Client, appDetails.FirstUser.OrganizationID, rbac.RoleMember()) + userClient.HTTPClient.CheckRedirect = appDetails.Client.HTTPClient.CheckRedirect + userClient.HTTPClient.Transport = appDetails.Client.HTTPClient.Transport + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + resp, err := requestWithRetries(ctx, t, userClient, http.MethodGet, appDetails.PathAppURL(appDetails.OwnerApp).String(), 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() + + u := appDetails.PathAppURL(appDetails.OwnerApp) + u.Path = strings.TrimSuffix(u.Path, "/") + resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, u.String(), 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() + + u := appDetails.PathAppURL(appDetails.OwnerApp) + u.RawQuery = "" + resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, u.String(), 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() + + u := appDetails.PathAppURL(appDetails.OwnerApp) + resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, u.String(), 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) + + var appTokenCookie *http.Cookie + for _, c := range resp.Cookies() { + if c.Name == codersdk.DevURLSignedAppTokenCookie { + appTokenCookie = c + break + } + } + require.NotNil(t, appTokenCookie, "no signed app token cookie in response") + require.Equal(t, appTokenCookie.Path, u.Path, "incorrect path on app token cookie") + + // Ensure the signed app token cookie is valid. + appTokenClient := codersdk.New(appDetails.Client.URL) + appTokenClient.HTTPClient.CheckRedirect = appDetails.Client.HTTPClient.CheckRedirect + appTokenClient.HTTPClient.Transport = appDetails.Client.HTTPClient.Transport + appTokenClient.HTTPClient.Jar, err = cookiejar.New(nil) + require.NoError(t, err) + appTokenClient.HTTPClient.Jar.SetCookies(u, []*http.Cookie{appTokenCookie}) + + resp, err = requestWithRetries(ctx, t, appTokenClient, http.MethodGet, u.String(), 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("BlocksMe", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + app := appDetails.OwnerApp + app.Username = codersdk.Me + + resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, appDetails.PathAppURL(app).String(), nil) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusNotFound, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Contains(t, string(body), "must be accessed with the full username, not @me") + }) + + t.Run("ForwardsIP", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, appDetails.PathAppURL(appDetails.OwnerApp).String(), 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 := appDetails.Client.Request(ctx, http.MethodGet, appDetails.PathAppURL(appDetails.FakeApp).String(), nil) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusBadGateway, resp.StatusCode) + }) + + t.Run("NoProxyPort", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + resp, err := appDetails.Client.Request(ctx, http.MethodGet, appDetails.PathAppURL(appDetails.PortApp).String(), nil) + require.NoError(t, err) + defer resp.Body.Close() + // TODO(@deansheather): This should be 400. There's a todo in the + // resolve request code to fix this. + require.Equal(t, http.StatusInternalServerError, resp.StatusCode) + }) + }) + + t.Run("WorkspaceApplicationAuth", func(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() + + appDetails := setupProxyTest(t, nil) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // Get the current user and API key. + user, err := appDetails.Client.User(ctx, codersdk.Me) + require.NoError(t, err) + currentAPIKey, err := appDetails.Client.APIKeyByID(ctx, appDetails.FirstUser.UserID.String(), strings.Split(appDetails.Client.SessionToken(), "-")[0]) + require.NoError(t, err) + + // Try to load the application without authentication. + subdomain := fmt.Sprintf("%s--%s--%s--%s", proxyTestAppNameOwner, proxyTestAgentName, appDetails.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, appDetails.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, appDetails.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, appDetails.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, appDetails.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 := appDetails.Client.APIKeyByID(ctx, appDetails.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) + require.EqualValues(t, currentAPIKey.LifetimeSeconds, apiKeyInfo.LifetimeSeconds) + + // Verify the API key permissions + appClient := codersdk.New(appDetails.Client.URL) + appClient.SetSessionToken(apiKey) + appClient.HTTPClient.CheckRedirect = appDetails.Client.HTTPClient.CheckRedirect + appClient.HTTPClient.Transport = appDetails.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: appDetails.FirstUser.OrganizationID.String(), + }, + Action: "create", + }, + canReadUserMe: { + Object: codersdk.AuthorizationObject{ + ResourceType: "user", + OwnerID: "me", + ResourceID: appDetails.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, appDetails.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() + + appDetails := 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, appDetails.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. + t.Run("WorkspaceAppsProxySubdomainPassthrough", func(t *testing.T) { + t.Parallel() + + // No Hostname set. + appDetails := setupProxyTest(t, &DeploymentOptions{ + AppHost: "", + DisableSubdomainApps: true, + noWorkspace: true, + }) + + 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, appDetails.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, appDetails.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. + t.Run("WorkspaceAppsProxySubdomainBlocked", func(t *testing.T) { + t.Parallel() + + appDetails := setupProxyTest(t, &DeploymentOptions{ + noWorkspace: true, + }) + + t.Run("InvalidSubdomain", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + host := strings.Replace(appDetails.Options.AppHost, "*", "not-an-app-subdomain", 1) + uri := fmt.Sprintf("http://%s/api/v2/users/me", host) + resp, err := requestWithRetries(ctx, t, appDetails.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") + }) + }) + + t.Run("WorkspaceAppsProxySubdomain", func(t *testing.T) { + t.Parallel() + + appDetails := setupProxyTest(t, nil) + + t.Run("NoAccessShould401", func(t *testing.T) { + t.Parallel() + + userClient, _ := coderdtest.CreateAnotherUser(t, appDetails.Client, appDetails.FirstUser.OrganizationID, rbac.RoleMember()) + userClient.HTTPClient.CheckRedirect = appDetails.Client.HTTPClient.CheckRedirect + userClient.HTTPClient.Transport = appDetails.Client.HTTPClient.Transport + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + resp, err := requestWithRetries(ctx, t, userClient, http.MethodGet, appDetails.SubdomainAppURL(appDetails.OwnerApp).String(), 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() + + u := appDetails.SubdomainAppURL(appDetails.OwnerApp) + u.Path = "" + u.RawQuery = "" + resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, u.String(), 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, appDetails.SubdomainAppURL(appDetails.OwnerApp).Path, loc.Path) + }) + + t.Run("RedirectsWithQuery", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + u := appDetails.SubdomainAppURL(appDetails.OwnerApp) + u.RawQuery = "" + resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, u.String(), 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, appDetails.SubdomainAppURL(appDetails.OwnerApp).RawQuery, loc.RawQuery) + }) + + t.Run("Proxies", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + u := appDetails.SubdomainAppURL(appDetails.OwnerApp) + resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, u.String(), 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) + + var appTokenCookie *http.Cookie + for _, c := range resp.Cookies() { + if c.Name == codersdk.DevURLSignedAppTokenCookie { + appTokenCookie = c + break + } + } + require.NotNil(t, appTokenCookie, "no signed token cookie in response") + require.Equal(t, appTokenCookie.Path, "/", "incorrect path on signed token cookie") + + // Ensure the session token cookie is valid. + appTokenClient := codersdk.New(appDetails.Client.URL) + appTokenClient.HTTPClient.CheckRedirect = appDetails.Client.HTTPClient.CheckRedirect + appTokenClient.HTTPClient.Transport = appDetails.Client.HTTPClient.Transport + appTokenClient.HTTPClient.Jar, err = cookiejar.New(nil) + require.NoError(t, err) + appTokenClient.HTTPClient.Jar.SetCookies(u, []*http.Cookie{appTokenCookie}) + + resp, err = requestWithRetries(ctx, t, appTokenClient, http.MethodGet, u.String(), 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, appDetails.Client, http.MethodGet, appDetails.SubdomainAppURL(appDetails.PortApp).String(), 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 := appDetails.Client.Request(ctx, http.MethodGet, appDetails.SubdomainAppURL(appDetails.FakeApp).String(), 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() + + app := appDetails.PortApp + app.AppSlugOrPort = strconv.Itoa(codersdk.WorkspaceAgentMinimumListeningPort - 1) + resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, appDetails.SubdomainAppURL(app).String(), 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() + + appDetails := setupProxyTest(t, &DeploymentOptions{ + AppHost: "*-suffix.test.coder.com", + }) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + u := appDetails.SubdomainAppURL(appDetails.OwnerApp) + t.Logf("url: %s", u) + + resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, u.String(), 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() + + appDetails := setupProxyTest(t, &DeploymentOptions{ + AppHost: "*-suffix.test.coder.com", + }) + + t.Run("NoSuffix", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + u := appDetails.SubdomainAppURL(appDetails.OwnerApp) + // Replace the -suffix with nothing. + u.Host = strings.Replace(u.Host, "-suffix", "", 1) + t.Logf("url: %s", u) + + resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, u.String(), 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 := appDetails.SubdomainAppURL(appDetails.OwnerApp) + // Replace the -suffix with something else. + u.Host = strings.Replace(u.Host, "-suffix", "-not-suffix", 1) + t.Logf("url: %s", u) + + resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, u.String(), 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("AppSharing", func(t *testing.T) { + t.Parallel() + + setup := func(t *testing.T, allowPathAppSharing, allowSiteOwnerAccess bool) (appDetails *AppDetails, 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 = "SomeSecurePassword!" + + appDetails = setupProxyTest(t, &DeploymentOptions{ + DangerousAllowPathAppSharing: allowPathAppSharing, + DangerousAllowPathAppSiteOwnerAccess: allowSiteOwnerAccess, + // we make the workspace below + noWorkspace: true, + }) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + t.Cleanup(cancel) + + // Create a template-admin user in the same org. We don't use an owner + // since they have access to everything. + ownerClient = appDetails.Client + user, err := ownerClient.CreateUser(ctx, codersdk.CreateUserRequest{ + Email: "user@coder.com", + Username: "user", + Password: password, + OrganizationID: appDetails.FirstUser.OrganizationID, + }) + 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. + port := appServer(t) + workspace, agnt = createWorkspaceWithApps(t, client, user.OrganizationIDs[0], user, proxyTestSubdomainRaw, port) + + // Verify that the apps have the correct sharing levels set. + workspaceBuild, err := client.WorkspaceBuild(ctx, workspace.LatestBuild.ID) + require.NoError(t, err) + require.NotEmpty(t, workspaceBuild.Resources, "workspace build has no resources") + require.NotEmpty(t, workspaceBuild.Resources[0].Agents, "workspace build has no agents") + 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 appDetails, workspace, agnt, user, ownerClient, client, clientInOtherOrg, clientWithNoAuth + } + + verifyAccess := func(t *testing.T, appDetails *AppDetails, 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) + + app := App{ + AppSlugOrPort: appName, + AgentName: agentName, + WorkspaceName: workspaceName, + Username: username, + Query: proxyTestAppQuery, + } + u := appDetails.SubdomainAppURL(app) + if isPathApp { + u = appDetails.PathAppURL(app) + } + + res, err := requestWithRetries(ctx, t, client, http.MethodGet, u.String(), nil) + require.NoError(t, err, msg) + + dump, err := httputil.DumpResponse(res, true) + _ = res.Body.Close() + require.NoError(t, err, msg) + t.Log(u) + 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) { + appDetails, 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.Values.Dangerous.AllowPathAppSharing.Value()) + assert.Equal(t, siteOwnerPathAppAccessEnabled, deploymentConfig.Values.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, appDetails, isPathApp, user.Username, workspace.Name, agnt.Name, proxyTestAppNameOwner, ownerClient, siteOwnerCanAccess, false) + + // Owner should be able to access their own workspace. + verifyAccess(t, appDetails, 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, appDetails, isPathApp, user.Username, workspace.Name, agnt.Name, proxyTestAppNameOwner, clientInOtherOrg, false, false) + + // Unauthenticated user should not have any access. + verifyAccess(t, appDetails, 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, appDetails, isPathApp, user.Username, workspace.Name, agnt.Name, proxyTestAppNameAuthenticated, ownerClient, siteOwnerCanAccessShared, false) + + // Owner should be able to access their own workspace. + verifyAccess(t, appDetails, isPathApp, user.Username, workspace.Name, agnt.Name, proxyTestAppNameAuthenticated, client, true, false) + + // Authenticated users should be able to access the workspace. + verifyAccess(t, appDetails, isPathApp, user.Username, workspace.Name, agnt.Name, proxyTestAppNameAuthenticated, clientInOtherOrg, allowedUnlessSharingDisabled, false) + + // Unauthenticated user should not have any access. + verifyAccess(t, appDetails, 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, appDetails, isPathApp, user.Username, workspace.Name, agnt.Name, proxyTestAppNamePublic, ownerClient, siteOwnerCanAccessShared, false) + + // Owner should be able to access their own workspace. + verifyAccess(t, appDetails, isPathApp, user.Username, workspace.Name, agnt.Name, proxyTestAppNamePublic, client, true, false) + + // Authenticated users should be able to access the workspace. + verifyAccess(t, appDetails, isPathApp, user.Username, workspace.Name, agnt.Name, proxyTestAppNamePublic, clientInOtherOrg, allowedUnlessSharingDisabled, false) + + // Unauthenticated user should be able to access the workspace. + verifyAccess(t, appDetails, 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) + }) + }) + + t.Run("WorkspaceAppsNonCanonicalHeaders", func(t *testing.T) { + t.Parallel() + + // 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) + + appDetails := setupProxyTest(t, &DeploymentOptions{ + port: uint16(tcpAddr.Port), + }) + + cases := []struct { + name string + u *url.URL + }{ + { + name: "ProxyPath", + u: appDetails.PathAppURL(appDetails.OwnerApp), + }, + { + name: "ProxySubdomain", + u: appDetails.SubdomainAppURL(appDetails.OwnerApp), + }, + } + + 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() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.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, appDetails.Client.SessionToken()) + resp, err := doWithRetries(t, appDetails.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")) + }) + } + }) +} diff --git a/coderd/workspaceapps/apptest/setup.go b/coderd/workspaceapps/apptest/setup.go new file mode 100644 index 0000000000000..dff4a52e5b725 --- /dev/null +++ b/coderd/workspaceapps/apptest/setup.go @@ -0,0 +1,401 @@ +package apptest + +import ( + "context" + "fmt" + "net" + "net/http" + "net/url" + "path" + "strconv" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "cdr.dev/slog/sloggers/slogtest" + "github.com/coder/coder/agent" + "github.com/coder/coder/coderd/coderdtest" + "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" +) + +// DeploymentOptions are the options for creating a *Deployment with a +// DeploymentFactory. +type DeploymentOptions struct { + AppHost string + DisablePathApps bool + DisableSubdomainApps bool + DangerousAllowPathAppSharing bool + DangerousAllowPathAppSiteOwnerAccess bool + + // The following fields are only used by setupProxyTestWithFactory. + noWorkspace bool + port uint16 +} + +// Deployment is a license-agnostic deployment with all the fields that apps +// tests need. +type Deployment struct { + Options *DeploymentOptions + + // Client should be logged in as the admin user. + Client *codersdk.Client + FirstUser codersdk.CreateFirstUserResponse + PathAppBaseURL *url.URL +} + +// DeploymentFactory generates a deployment with an API client, a path base URL, +// and a subdomain app host URL. +type DeploymentFactory func(t *testing.T, opts *DeploymentOptions) *Deployment + +// App is similar to httpapi.ApplicationURL but with a Query field. +type App struct { + Username string + WorkspaceName string + // AgentName is optional, except for when proxying to a port. AgentName is + // always ignored when making a path app URL. + // + // Set WorkspaceName to `workspace.agent` if you want to generate a path app + // URL with an agent name. + AgentName string + AppSlugOrPort string + + Query string +} + +// AppDetails are the full test details returned from setupProxyTestWithFactory. +type AppDetails struct { + *Deployment + + Me codersdk.User + + // The following fields are not set if setupProxyTest was called with + // `withWorkspace` set to `false`. + + Workspace *codersdk.Workspace + Agent *codersdk.WorkspaceAgent + AppPort uint16 + + FakeApp App + OwnerApp App + AuthenticatedApp App + PublicApp App + PortApp App +} + +// PathAppURL returns the URL for the given path app. +func (d *AppDetails) PathAppURL(app App) *url.URL { + appPath := fmt.Sprintf("/@%s/%s/apps/%s", app.Username, app.WorkspaceName, app.AppSlugOrPort) + + u := *d.PathAppBaseURL + u.Path = path.Join(u.Path, appPath) + u.Path += "/" + u.RawQuery = app.Query + return &u +} + +// SubdomainAppURL returns the URL for the given subdomain app. +func (d *AppDetails) SubdomainAppURL(app App) *url.URL { + if d.Options.DisableSubdomainApps || d.Options.AppHost == "" { + panic("subdomain apps are disabled") + } + + host := fmt.Sprintf("%s--%s--%s--%s", app.AppSlugOrPort, app.AgentName, app.WorkspaceName, app.Username) + + u := *d.PathAppBaseURL + u.Host = strings.Replace(d.Options.AppHost, "*", host, 1) + u.Path = "/" + u.RawQuery = app.Query + return &u +} + +// setupProxyTestWithFactory does the following: +// 1. Create a deployment with the factory. +// 2. Start a test app server. +// 3. Create a template version, template and workspace with many apps. +// 4. Start a workspace agent. +// 5. Returns details about the deployment and its apps. +func setupProxyTestWithFactory(t *testing.T, factory DeploymentFactory, opts *DeploymentOptions) *AppDetails { + if opts == nil { + opts = &DeploymentOptions{} + } + if opts.AppHost == "" { + opts.AppHost = proxyTestSubdomainRaw + } + if opts.DisableSubdomainApps { + opts.AppHost = "" + } + + deployment := factory(t, opts) + + // Configure the HTTP client to not follow redirects and to route all + // requests regardless of hostname to the coderd test server. + deployment.Client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + } + forceURLTransport(t, deployment.Client) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium) + defer cancel() + + me, err := deployment.Client.User(ctx, codersdk.Me) + require.NoError(t, err) + + if opts.noWorkspace { + return &AppDetails{ + Deployment: deployment, + Me: me, + } + } + + if opts.port == 0 { + opts.port = appServer(t) + } + workspace, agnt := createWorkspaceWithApps(t, deployment.Client, deployment.FirstUser.OrganizationID, me, opts.AppHost, opts.port) + + return &AppDetails{ + Deployment: deployment, + Me: me, + Workspace: &workspace, + Agent: &agnt, + AppPort: opts.port, + + FakeApp: App{ + Username: me.Username, + WorkspaceName: workspace.Name, + AgentName: agnt.Name, + AppSlugOrPort: proxyTestAppNameFake, + }, + OwnerApp: App{ + Username: me.Username, + WorkspaceName: workspace.Name, + AgentName: agnt.Name, + AppSlugOrPort: proxyTestAppNameOwner, + Query: proxyTestAppQuery, + }, + AuthenticatedApp: App{ + Username: me.Username, + WorkspaceName: workspace.Name, + AgentName: agnt.Name, + AppSlugOrPort: proxyTestAppNameAuthenticated, + Query: proxyTestAppQuery, + }, + PublicApp: App{ + Username: me.Username, + WorkspaceName: workspace.Name, + AgentName: agnt.Name, + AppSlugOrPort: proxyTestAppNamePublic, + Query: proxyTestAppQuery, + }, + PortApp: App{ + Username: me.Username, + WorkspaceName: workspace.Name, + AgentName: agnt.Name, + AppSlugOrPort: strconv.Itoa(int(opts.port)), + }, + } +} + +func appServer(t *testing.T) uint16 { + // Start a listener on a random port greater than the minimum app port. + var ( + ln net.Listener + tcpAddr *net.TCPAddr + ) + for i := 0; i < 10; i++ { + var err error + // #nosec + ln, err = net.Listen("tcp", ":0") + require.NoError(t, err) + + var ok bool + tcpAddr, ok = ln.Addr().(*net.TCPAddr) + require.True(t, ok) + if tcpAddr.Port < codersdk.WorkspaceAgentMinimumListeningPort { + _ = ln.Close() + time.Sleep(20 * time.Millisecond) + continue + } + } + + 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 func() { + _ = server.Serve(ln) + }() + + return uint16(tcpAddr.Port) +} + +func createWorkspaceWithApps(t *testing.T, client *codersdk.Client, orgID uuid.UUID, me codersdk.User, appHost string, port uint16, workspaceMutators ...func(*codersdk.CreateWorkspaceRequest)) (codersdk.Workspace, codersdk.WorkspaceAgent) { + 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) + + agentClient := agentsdk.New(client.URL) + agentClient.SetSessionToken(authToken) + if appHost != "" { + manifest, err := agentClient.Manifest(context.Background()) + require.NoError(t, err) + proxyURL := fmt.Sprintf( + "http://{{port}}--%s--%s--%s%s", + proxyTestAgentName, + workspace.Name, + me.Username, + strings.ReplaceAll(appHost, "*", ""), + ) + if client.URL.Port() != "" { + proxyURL += fmt.Sprintf(":%s", client.URL.Port()) + } + require.Equal(t, proxyURL, manifest.VSCodePortProxyURI) + } + agentCloser := agent.New(agent.Options{ + Client: agentClient, + Logger: slogtest.Make(t, nil).Named("agent"), + }) + t.Cleanup(func() { + _ = agentCloser.Close() + }) + + resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) + agents := make([]codersdk.WorkspaceAgent, 0, 1) + for _, resource := range resources { + agents = append(agents, resource.Agents...) + } + require.Len(t, agents, 1) + + return workspace, agents[0] +} + +func doWithRetries(t require.TestingT, client *codersdk.Client, req *http.Request) (*http.Response, error) { + var resp *http.Response + var err error + require.Eventually(t, func() bool { + // nolint // only requests which are not passed upstream have a body closed + resp, err = client.HTTPClient.Do(req) + if resp != nil && resp.StatusCode == http.StatusBadGateway { + if resp.Body != nil { + resp.Body.Close() + } + return false + } + return true + }, testutil.WaitLong, testutil.IntervalFast) + return resp, err +} + +func requestWithRetries(ctx context.Context, t require.TestingT, client *codersdk.Client, method, urlOrPath string, body interface{}, opts ...codersdk.RequestOption) (*http.Response, error) { + var resp *http.Response + var err error + require.Eventually(t, func() bool { + // nolint // only requests which are not passed upstream have a body closed + resp, err = client.Request(ctx, method, urlOrPath, body, opts...) + if resp != nil && resp.StatusCode == http.StatusBadGateway { + if resp.Body != nil { + resp.Body.Close() + } + return false + } + return true + }, testutil.WaitLong, testutil.IntervalFast) + return resp, err +} + +// 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/coderd/workspaceapps_test.go b/coderd/workspaceapps_test.go index 02a1ad3aa1fc9..41ca4631006ad 100644 --- a/coderd/workspaceapps_test.go +++ b/coderd/workspaceapps_test.go @@ -1,53 +1,20 @@ package coderd_test import ( - "bufio" "context" - "encoding/json" - "fmt" - "io" "net" - "net/http" - "net/http/cookiejar" - "net/http/httputil" "net/url" - "strconv" - "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/cli/clibase" "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/coderd/workspaceapps/apptest" "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() @@ -111,1280 +78,19 @@ 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, opts *setupProxyTestOpts) (*codersdk.Client, codersdk.CreateFirstUserResponse, *codersdk.Workspace, uint16) { - if opts == nil { - opts = &setupProxyTestOpts{} - } - if opts.AppHost == "" { - opts.AppHost = proxyTestSubdomainRaw - } - - // Start a listener on a random port greater than the minimum app port. - var ( - ln net.Listener - tcpAddr *net.TCPAddr - ) - for i := 0; i < 10; i++ { - var err error - // #nosec - ln, err = net.Listen("tcp", ":0") - require.NoError(t, err) - - var ok bool - tcpAddr, ok = ln.Addr().(*net.TCPAddr) - require.True(t, ok) - if tcpAddr.Port < codersdk.WorkspaceAgentMinimumListeningPort { - _ = ln.Close() - time.Sleep(20 * time.Millisecond) - continue - } - } - - 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) - - deploymentValues := coderdtest.DeploymentValues(t) - deploymentValues.DisablePathApps = clibase.Bool(opts.DisablePathApps) - deploymentValues.Dangerous.AllowPathAppSharing = clibase.Bool(opts.DangerousAllowPathAppSharing) - deploymentValues.Dangerous.AllowPathAppSiteOwnerAccess = clibase.Bool(opts.DangerousAllowPathAppSiteOwnerAccess) - - client := coderdtest.New(t, &coderdtest.Options{ - DeploymentValues: deploymentValues, - AppHostname: opts.AppHost, - IncludeProvisionerDaemon: true, - 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 != "" { - manifest, err := agentClient.Manifest(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, manifest.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) { +func TestWorkspaceApps(t *testing.T) { t.Parallel() - client, firstUser, workspace, _ := setupProxyTest(t, nil) - - t.Run("Disabled", func(t *testing.T) { - t.Parallel() + apptest.Run(t, func(t *testing.T, opts *apptest.DeploymentOptions) *apptest.Deployment { deploymentValues := coderdtest.DeploymentValues(t) - deploymentValues.DisablePathApps = true - - client := coderdtest.New(t, &coderdtest.Options{ - DeploymentValues: deploymentValues, - 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("/@%s/%s/apps/%s", coderdtest.FirstUserParams.Username, 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("/@%s/%s/apps/%s", coderdtest.FirstUserParams.Username, 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("/@%s/%s/apps/%s", coderdtest.FirstUserParams.Username, 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("/@%s/%s/apps/%s", coderdtest.FirstUserParams.Username, 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("/@%s/%s/apps/%s/", coderdtest.FirstUserParams.Username, 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() - - basePath := fmt.Sprintf("/@%s/%s/apps/%s/", coderdtest.FirstUserParams.Username, workspace.Name, proxyTestAppNameOwner) - path := fmt.Sprintf("%s?%s", basePath, proxyTestAppQuery) - resp, err := requestWithRetries(ctx, t, client, http.MethodGet, path, 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) - - var appTokenCookie *http.Cookie - for _, c := range resp.Cookies() { - if c.Name == codersdk.DevURLSignedAppTokenCookie { - appTokenCookie = c - break - } - } - require.NotNil(t, appTokenCookie, "no signed app token cookie in response") - require.Equal(t, appTokenCookie.Path, basePath, "incorrect path on app token cookie") - - // Ensure the session token cookie is valid. - appTokenClient := codersdk.New(client.URL) - appTokenClient.HTTPClient.CheckRedirect = client.HTTPClient.CheckRedirect - appTokenClient.HTTPClient.Transport = client.HTTPClient.Transport - u, err := appTokenClient.URL.Parse(path) - require.NoError(t, err) - appTokenClient.HTTPClient.Jar, err = cookiejar.New(nil) - require.NoError(t, err) - appTokenClient.HTTPClient.Jar.SetCookies(u, []*http.Cookie{appTokenCookie}) - - resp, err = requestWithRetries(ctx, t, appTokenClient, http.MethodGet, path, 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("BlocksMe", 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() - require.Equal(t, http.StatusNotFound, resp.StatusCode) - - body, err := io.ReadAll(resp.Body) - require.NoError(t, err) - require.Contains(t, string(body), "must be accessed with the full username, not @me") - }) - - 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("/@%s/%s/apps/%s/?%s", coderdtest.FirstUserParams.Username, 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("/@%s/%s/apps/%s/", coderdtest.FirstUserParams.Username, workspace.Name, proxyTestAppNameFake), nil) - require.NoError(t, err) - defer resp.Body.Close() - require.Equal(t, http.StatusBadGateway, resp.StatusCode) - }) - - t.Run("NoProxyPort", 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("/@%s/%s/apps/%d/", coderdtest.FirstUserParams.Username, workspace.Name, 8080), nil) - require.NoError(t, err) - defer resp.Body.Close() - // TODO(@deansheather): This should be 400. There's a todo in the - // resolve request code to fix this. - require.Equal(t, http.StatusInternalServerError, 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.APIKeyByID(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.APIKeyByID(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) - require.EqualValues(t, currentAPIKey.LifetimeSeconds, apiKeyInfo.LifetimeSeconds) - - // 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 Hostname 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, appSlugOrPort interface{}, pathAndQuery ...string) string { - t.Helper() - - appSlugOrPortStr := "" - if val, ok := appSlugOrPort.(string); ok { - appSlugOrPortStr = val - } else { - port, ok := appSlugOrPort.(uint16) - require.True(t, ok) - appSlugOrPortStr = strconv.Itoa(int(port)) - } - - 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{ - AppSlugOrPort: appSlugOrPortStr, - 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() - - uStr := proxyURL(t, client, proxyTestAppNameOwner, "/", proxyTestAppQuery) - u, err := url.Parse(uStr) - require.NoError(t, err) - resp, err := requestWithRetries(ctx, t, client, http.MethodGet, uStr, 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) - - var appTokenCookie *http.Cookie - for _, c := range resp.Cookies() { - if c.Name == codersdk.DevURLSignedAppTokenCookie { - appTokenCookie = c - break - } - } - require.NotNil(t, appTokenCookie, "no signed token cookie in response") - require.Equal(t, appTokenCookie.Path, "/", "incorrect path on signed token cookie") - - // Ensure the session token cookie is valid. - appTokenClient := codersdk.New(client.URL) - appTokenClient.HTTPClient.CheckRedirect = client.HTTPClient.CheckRedirect - appTokenClient.HTTPClient.Transport = client.HTTPClient.Transport - appTokenClient.HTTPClient.Jar, err = cookiejar.New(nil) - require.NoError(t, err) - appTokenClient.HTTPClient.Jar.SetCookies(u, []*http.Cookie{appTokenCookie}) - - resp, err = requestWithRetries(ctx, t, appTokenClient, http.MethodGet, uStr, 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 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 = "SomeSecurePassword!" - - 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) - require.NotEmpty(t, workspaceBuild.Resources, "workspace build has no resources") - require.NotEmpty(t, workspaceBuild.Resources[0].Agents, "workspace build has no agents") - 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{ - AppSlugOrPort: 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.Values.Dangerous.AllowPathAppSharing.Value()) - assert.Equal(t, siteOwnerPathAppAccessEnabled, deploymentConfig.Values.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] - } + deploymentValues.DisablePathApps = clibase.Bool(opts.DisablePathApps) + deploymentValues.Dangerous.AllowPathAppSharing = clibase.Bool(opts.DangerousAllowPathAppSharing) + deploymentValues.Dangerous.AllowPathAppSiteOwnerAccess = clibase.Bool(opts.DangerousAllowPathAppSiteOwnerAccess) client := coderdtest.New(t, &coderdtest.Options{ - AppHostname: appHost, - IncludeProvisionerDaemon: true, - AgentStatsRefreshInterval: time.Millisecond * 100, - MetricsCacheRefreshInterval: time.Millisecond * 100, + DeploymentValues: deploymentValues, + AppHostname: opts.AppHost, + IncludeProvisionerDaemon: true, RealIPConfig: &httpmw.RealIPConfig{ TrustedOrigins: []*net.IPNet{{ IP: net.ParseIP("127.0.0.1"), @@ -1398,129 +104,11 @@ func TestWorkspaceAppsNonCanonicalHeaders(t *testing.T) { 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) + return &apptest.Deployment{ + Options: opts, + Client: client, + FirstUser: user, + PathAppBaseURL: client.URL, } - 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("/@%s/%s/apps/%s/?%s", coderdtest.FirstUserParams.Username, 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() }) } diff --git a/scripts/lib.sh b/scripts/lib.sh index fcb5a72067246..f5cf7a97c0669 100644 --- a/scripts/lib.sh +++ b/scripts/lib.sh @@ -39,7 +39,8 @@ realpath() { } # We have to define realpath before these otherwise it fails on Mac's bash. -SCRIPT_DIR="$(realpath "$(dirname "${BASH_SOURCE[1]}")")" +SCRIPT="${BASH_SOURCE[1]:-${BASH_SOURCE[0]}}" +SCRIPT_DIR="$(realpath "$(dirname "$SCRIPT")")" PROJECT_ROOT="$(cd "$SCRIPT_DIR" && realpath "$(git rev-parse --show-toplevel)")" # pushd is a silent alternative to the real pushd shell command.