Files
coder/coderd/workspaceapps/db_test.go

1316 lines
43 KiB
Go

package workspaceapps_test
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"net/http/httputil"
"net/url"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/go-jose/go-jose/v4/jwt"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/agent/agenttest"
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/jwtutils"
"github.com/coder/coder/v2/coderd/tracing"
"github.com/coder/coder/v2/coderd/workspaceapps"
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/provisioner/echo"
"github.com/coder/coder/v2/provisionersdk/proto"
"github.com/coder/coder/v2/testutil"
)
func Test_ResolveRequest(t *testing.T) {
t.Parallel()
const (
agentName = "agent"
appNameOwner = "app-owner"
appNameAuthed = "app-authed"
appNamePublic = "app-public"
appNameInvalidURL = "app-invalid-url"
// Users can access unhealthy and initializing apps (as of 2024-02).
appNameUnhealthy = "app-unhealthy"
appNameInitializing = "app-initializing"
appNameEndsInS = "app-ends-in-s"
// This agent will never connect, so it will never become "connected".
// Users cannot access unhealthy agents.
agentNameUnhealthy = "agent-unhealthy"
appNameAgentUnhealthy = "app-agent-unhealthy"
// This is not a valid URL we listen on in the test, but it needs to be
// set to a value.
appURL = "http://localhost:8080"
)
allApps := []string{appNameOwner, appNameAuthed, appNamePublic}
// Start a listener for a server that always responds with 500 for the
// unhealthy app.
unhealthySrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("unhealthy"))
}))
t.Cleanup(unhealthySrv.Close)
// Start a listener for a server that never responds.
initializingServer, err := net.Listen("tcp", "localhost:0")
require.NoError(t, err)
t.Cleanup(func() {
_ = initializingServer.Close()
})
initializingURL := fmt.Sprintf("http://%s", initializingServer.Addr().String())
deploymentValues := coderdtest.DeploymentValues(t)
deploymentValues.DisablePathApps = false
deploymentValues.Dangerous.AllowPathAppSharing = true
deploymentValues.Dangerous.AllowPathAppSiteOwnerAccess = true
auditor := audit.NewMock()
t.Cleanup(func() {
if t.Failed() {
return
}
assert.Len(t, auditor.AuditLogs(), 0, "one or more test cases produced unexpected audit logs, did you replace the auditor or forget to call ResetLogs?")
})
client, closer, api := coderdtest.NewWithAPI(t, &coderdtest.Options{
AppHostname: "*.test.coder.com",
DeploymentValues: deploymentValues,
IncludeProvisionerDaemon: true,
AgentStatsRefreshInterval: time.Millisecond * 100,
MetricsCacheRefreshInterval: time.Millisecond * 100,
RealIPConfig: &httpmw.RealIPConfig{
TrustedOrigins: []*net.IPNet{{
IP: net.ParseIP("127.0.0.1"),
Mask: net.CIDRMask(8, 32),
}},
TrustedHeaders: []string{
"CF-Connecting-IP",
},
},
Auditor: auditor,
})
t.Cleanup(func() {
_ = closer.Close()
})
ctx := testutil.Context(t, testutil.WaitMedium)
firstUser := coderdtest.CreateFirstUser(t, client)
me, err := client.User(ctx, codersdk.Me)
require.NoError(t, err)
secondUserClient, secondUser := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
agentAuthToken := uuid.NewString()
version := coderdtest.CreateTemplateVersion(t, client, firstUser.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionPlan: echo.PlanComplete,
ProvisionApply: []*proto.Response{{
Type: &proto.Response_Apply{
Apply: &proto.ApplyComplete{
Resources: []*proto.Resource{{
Name: "example",
Type: "aws_instance",
Agents: []*proto.Agent{
{
Id: uuid.NewString(),
Name: agentName,
Auth: &proto.Agent_Token{
Token: agentAuthToken,
},
Apps: []*proto.App{
{
Slug: appNameOwner,
DisplayName: appNameOwner,
SharingLevel: proto.AppSharingLevel_OWNER,
Url: appURL,
},
{
Slug: appNameAuthed,
DisplayName: appNameAuthed,
SharingLevel: proto.AppSharingLevel_AUTHENTICATED,
Url: appURL,
},
{
Slug: appNamePublic,
DisplayName: appNamePublic,
SharingLevel: proto.AppSharingLevel_PUBLIC,
Url: appURL,
},
{
Slug: appNameInvalidURL,
DisplayName: appNameInvalidURL,
SharingLevel: proto.AppSharingLevel_PUBLIC,
Url: "test:path/to/app",
},
{
Slug: appNameUnhealthy,
DisplayName: appNameUnhealthy,
SharingLevel: proto.AppSharingLevel_PUBLIC,
Url: appURL,
Healthcheck: &proto.Healthcheck{
Url: unhealthySrv.URL,
Interval: 1,
Threshold: 1,
},
},
{
Slug: appNameInitializing,
DisplayName: appNameInitializing,
SharingLevel: proto.AppSharingLevel_PUBLIC,
Url: appURL,
Healthcheck: &proto.Healthcheck{
Url: initializingURL,
Interval: 30,
Threshold: 1000,
},
},
{
Slug: appNameEndsInS,
DisplayName: appNameEndsInS,
SharingLevel: proto.AppSharingLevel_OWNER,
Url: appURL,
},
},
},
{
Id: uuid.NewString(),
Name: agentNameUnhealthy,
Auth: &proto.Agent_Token{
Token: uuid.NewString(),
},
Apps: []*proto.App{
{
Slug: appNameAgentUnhealthy,
DisplayName: appNameAgentUnhealthy,
SharingLevel: proto.AppSharingLevel_PUBLIC,
Url: appURL,
},
},
},
},
}},
},
},
}},
})
template := coderdtest.CreateTemplate(t, client, firstUser.OrganizationID, version.ID)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
_ = agenttest.New(t, client.URL, agentAuthToken)
resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID, agentName)
agentID := uuid.Nil
for _, resource := range resources {
for _, agnt := range resource.Agents {
if agnt.Name == agentName {
agentID = agnt.ID
break
}
}
}
require.NotEqual(t, uuid.Nil, agentID)
//nolint:gocritic // This is a test, allow dbauthz.AsSystemRestricted.
agent, err := api.Database.GetWorkspaceAgentByID(dbauthz.AsSystemRestricted(ctx), agentID)
require.NoError(t, err)
//nolint:gocritic // This is a test, allow dbauthz.AsSystemRestricted.
apps, err := api.Database.GetWorkspaceAppsByAgentID(dbauthz.AsSystemRestricted(ctx), agentID)
require.NoError(t, err)
appsBySlug := make(map[string]database.WorkspaceApp, len(apps))
for _, app := range apps {
appsBySlug[app.Slug] = app
}
// Reset audit logs so cleanup check can pass.
auditor.ResetLogs()
assertAuditAgent := auditAsserter[database.WorkspaceAgent](workspace)
assertAuditApp := auditAsserter[database.WorkspaceApp](workspace)
t.Run("OK", func(t *testing.T) {
t.Parallel()
cases := []struct {
name string
workspaceNameOrID string
agentNameOrID string
}{
{
name: "Names",
workspaceNameOrID: workspace.Name,
agentNameOrID: agentName,
},
{
name: "IDs",
workspaceNameOrID: workspace.ID.String(),
agentNameOrID: agentID.String(),
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
// Try resolving a request for each app as the owner, without a
// token, then use the token to resolve each app.
for _, app := range allApps {
req := (workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: me.Username,
WorkspaceNameOrID: c.workspaceNameOrID,
AgentNameOrID: c.agentNameOrID,
AppSlugOrPort: app,
}).Normalize()
auditor := audit.NewMock()
auditableIP := testutil.RandomIPv6(t)
auditableUA := "Tidua"
t.Log("app", app)
rw := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/app", nil)
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
r.RemoteAddr = auditableIP
r.Header.Set("User-Agent", auditableUA)
// Try resolving the request without a token.
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
PathAppBaseURL: api.AccessURL,
AppHostname: api.AppHostname,
AppRequest: req,
})
w := rw.Result()
if !assert.True(t, ok) {
dump, err := httputil.DumpResponse(w, true)
require.NoError(t, err, "error dumping failed response")
t.Log(string(dump))
return
}
_ = w.Body.Close()
require.Equal(t, &workspaceapps.SignedToken{
RegisteredClaims: jwtutils.RegisteredClaims{
Expiry: jwt.NewNumericDate(token.Expiry.Time()),
},
Request: req,
UserID: me.ID,
WorkspaceID: workspace.ID,
AgentID: agentID,
AppURL: appURL,
}, token)
require.NotZero(t, token.Expiry)
require.WithinDuration(t, time.Now().Add(workspaceapps.DefaultTokenExpiry), token.Expiry.Time(), time.Minute)
// Check that the token was set in the response and is valid.
require.Len(t, w.Cookies(), 1)
cookie := w.Cookies()[0]
require.Equal(t, codersdk.SignedAppTokenCookie, cookie.Name)
require.Equal(t, req.BasePath, cookie.Path)
assertAuditApp(t, rw, r, auditor, appsBySlug[app], me.ID, nil)
require.Len(t, auditor.AuditLogs(), 1, "audit log count")
var parsedToken workspaceapps.SignedToken
err := jwtutils.Verify(ctx, api.AppSigningKeyCache, cookie.Value, &parsedToken)
require.NoError(t, err)
// normalize expiry
require.WithinDuration(t, token.Expiry.Time(), parsedToken.Expiry.Time(), 2*time.Second)
parsedToken.Expiry = token.Expiry
require.Equal(t, token, &parsedToken)
// Try resolving the request with the token only.
rw = httptest.NewRecorder()
r = httptest.NewRequest("GET", "/app", nil)
r.AddCookie(cookie)
r.RemoteAddr = auditableIP
secondToken, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
PathAppBaseURL: api.AccessURL,
AppHostname: api.AppHostname,
AppRequest: req,
})
require.True(t, ok)
// normalize expiry
require.WithinDuration(t, token.Expiry.Time(), secondToken.Expiry.Time(), 2*time.Second)
secondToken.Expiry = token.Expiry
require.Equal(t, token, secondToken)
require.Len(t, auditor.AuditLogs(), 1, "no new audit log, FromRequest returned the same token and is not audited")
}
})
}
})
t.Run("AuthenticatedOtherUser", func(t *testing.T) {
t.Parallel()
for _, app := range allApps {
req := (workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: me.Username,
WorkspaceNameOrID: workspace.Name,
AgentNameOrID: agentName,
AppSlugOrPort: app,
}).Normalize()
auditor := audit.NewMock()
auditableIP := testutil.RandomIPv6(t)
t.Log("app", app)
rw := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/app", nil)
r.Header.Set(codersdk.SessionTokenHeader, secondUserClient.SessionToken())
r.RemoteAddr = auditableIP
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
PathAppBaseURL: api.AccessURL,
AppHostname: api.AppHostname,
AppRequest: req,
})
w := rw.Result()
_ = w.Body.Close()
if app == appNameOwner {
require.False(t, ok)
require.Nil(t, token)
require.NotZero(t, w.StatusCode)
require.Equal(t, http.StatusNotFound, w.StatusCode)
return
}
require.True(t, ok)
require.NotNil(t, token)
require.Zero(t, w.StatusCode)
assertAuditApp(t, rw, r, auditor, appsBySlug[app], secondUser.ID, nil)
require.Len(t, auditor.AuditLogs(), 1, "single audit log")
}
})
t.Run("Unauthenticated", func(t *testing.T) {
t.Parallel()
for _, app := range allApps {
req := (workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: me.Username,
WorkspaceNameOrID: workspace.Name,
AgentNameOrID: agentName,
AppSlugOrPort: app,
}).Normalize()
auditor := audit.NewMock()
auditableIP := testutil.RandomIPv6(t)
t.Log("app", app)
rw := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/app", nil)
r.RemoteAddr = auditableIP
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
PathAppBaseURL: api.AccessURL,
AppHostname: api.AppHostname,
AppRequest: req,
})
w := rw.Result()
if app != appNamePublic {
require.False(t, ok)
require.Nil(t, token)
require.NotZero(t, rw.Code)
require.NotEqual(t, http.StatusOK, rw.Code)
assertAuditApp(t, rw, r, auditor, appsBySlug[app], uuid.Nil, nil)
require.Len(t, auditor.AuditLogs(), 1, "audit log for unauthenticated requests")
} else {
if !assert.True(t, ok) {
dump, err := httputil.DumpResponse(w, true)
require.NoError(t, err, "error dumping failed response")
t.Log(string(dump))
return
}
require.NotNil(t, token)
if rw.Code != 0 && rw.Code != http.StatusOK {
t.Fatalf("expected 200 (or unset) response code, got %d", rw.Code)
}
assertAuditApp(t, rw, r, auditor, appsBySlug[app], uuid.Nil, nil)
require.Len(t, auditor.AuditLogs(), 1, "single audit log")
}
_ = w.Body.Close()
}
})
t.Run("Invalid", func(t *testing.T) {
t.Parallel()
req := (workspaceapps.Request{
AccessMethod: "invalid",
}).Normalize()
auditor := audit.NewMock()
auditableIP := testutil.RandomIPv6(t)
rw := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/app", nil)
r.RemoteAddr = auditableIP
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
PathAppBaseURL: api.AccessURL,
AppHostname: api.AppHostname,
AppRequest: req,
})
require.False(t, ok)
require.Nil(t, token)
require.Len(t, auditor.AuditLogs(), 0, "no audit logs for invalid requests")
})
t.Run("SplitWorkspaceAndAgent", func(t *testing.T) {
t.Parallel()
cases := []struct {
name string
workspaceAndAgent string
workspace string
agent string
ok bool
}{
{
name: "WorkspaceOnly",
workspaceAndAgent: workspace.Name,
workspace: workspace.Name,
agent: "",
ok: true,
},
{
name: "WorkspaceAndAgent",
workspaceAndAgent: fmt.Sprintf("%s.%s", workspace.Name, agentName),
workspace: workspace.Name,
agent: agentName,
ok: true,
},
{
name: "WorkspaceID",
workspaceAndAgent: workspace.ID.String(),
workspace: workspace.ID.String(),
agent: "",
ok: true,
},
{
name: "WorkspaceIDAndAgentID",
workspaceAndAgent: fmt.Sprintf("%s.%s", workspace.ID, agentID),
workspace: workspace.ID.String(),
agent: agentID.String(),
ok: true,
},
{
name: "Invalid1",
workspaceAndAgent: "invalid",
ok: false,
},
{
name: "Invalid2",
workspaceAndAgent: ".",
ok: false,
},
{
name: "Slash",
workspaceAndAgent: fmt.Sprintf("%s/%s", workspace.Name, agentName),
ok: false,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
req := (workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: me.Username,
WorkspaceAndAgent: c.workspaceAndAgent,
AppSlugOrPort: appNamePublic,
}).Normalize()
auditor := audit.NewMock()
auditableIP := testutil.RandomIPv6(t)
rw := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/app", nil)
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
r.RemoteAddr = auditableIP
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
PathAppBaseURL: api.AccessURL,
AppHostname: api.AppHostname,
AppRequest: req,
})
w := rw.Result()
if !assert.Equal(t, c.ok, ok) {
dump, err := httputil.DumpResponse(w, true)
require.NoError(t, err, "error dumping failed response")
t.Log(string(dump))
return
}
if c.ok {
require.NotNil(t, token)
require.Equal(t, token.WorkspaceNameOrID, c.workspace)
require.Equal(t, token.AgentNameOrID, c.agent)
require.Equal(t, token.WorkspaceID, workspace.ID)
require.Equal(t, token.AgentID, agentID)
assertAuditApp(t, rw, r, auditor, appsBySlug[token.AppSlugOrPort], me.ID, nil)
require.Len(t, auditor.AuditLogs(), 1, "single audit log")
} else {
require.Nil(t, token)
require.Len(t, auditor.AuditLogs(), 0, "no audit logs")
}
_ = w.Body.Close()
})
}
})
t.Run("TokenDoesNotMatchRequest", func(t *testing.T) {
t.Parallel()
badToken := workspaceapps.SignedToken{
Request: (workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: me.Username,
WorkspaceNameOrID: workspace.Name,
AgentNameOrID: agentName,
// App name differs
AppSlugOrPort: appNamePublic,
}).Normalize(),
RegisteredClaims: jwtutils.RegisteredClaims{
Expiry: jwt.NewNumericDate(time.Now().Add(time.Minute)),
},
UserID: me.ID,
WorkspaceID: workspace.ID,
AgentID: agentID,
AppURL: appURL,
}
badTokenStr, err := jwtutils.Sign(ctx, api.AppSigningKeyCache, badToken)
require.NoError(t, err)
req := (workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: me.Username,
WorkspaceNameOrID: workspace.Name,
AgentNameOrID: agentName,
// App name differs
AppSlugOrPort: appNameOwner,
}).Normalize()
auditor := audit.NewMock()
auditableIP := testutil.RandomIPv6(t)
rw := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/app", nil)
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
r.AddCookie(&http.Cookie{
Name: codersdk.SignedAppTokenCookie,
Value: badTokenStr,
})
r.RemoteAddr = auditableIP
// Even though the token is invalid, we should still perform request
// resolution without failure since we'll just ignore the bad token.
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
PathAppBaseURL: api.AccessURL,
AppHostname: api.AppHostname,
AppRequest: req,
})
require.True(t, ok)
require.NotNil(t, token)
require.Equal(t, appNameOwner, token.AppSlugOrPort)
// Cookie should be set in response, and it should be a different
// token.
w := rw.Result()
_ = w.Body.Close()
cookies := w.Cookies()
require.Len(t, cookies, 1)
require.Equal(t, cookies[0].Name, codersdk.SignedAppTokenCookie)
require.NotEqual(t, cookies[0].Value, badTokenStr)
var parsedToken workspaceapps.SignedToken
err = jwtutils.Verify(ctx, api.AppSigningKeyCache, cookies[0].Value, &parsedToken)
require.NoError(t, err)
require.Equal(t, appNameOwner, parsedToken.AppSlugOrPort)
assertAuditApp(t, rw, r, auditor, appsBySlug[appNameOwner], me.ID, nil)
require.Len(t, auditor.AuditLogs(), 1, "single audit log")
})
t.Run("PortPathBlocked", func(t *testing.T) {
t.Parallel()
req := (workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: me.Username,
WorkspaceNameOrID: workspace.Name,
AgentNameOrID: agentName,
AppSlugOrPort: "8080",
}).Normalize()
auditor := audit.NewMock()
auditableIP := testutil.RandomIPv6(t)
rw := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/app", nil)
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
r.RemoteAddr = auditableIP
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
PathAppBaseURL: api.AccessURL,
AppHostname: api.AppHostname,
AppRequest: req,
})
require.False(t, ok)
require.Nil(t, token)
w := rw.Result()
_ = w.Body.Close()
// TODO(mafredri): Verify this is the correct status code.
require.Equal(t, http.StatusInternalServerError, w.StatusCode)
require.Len(t, auditor.AuditLogs(), 0, "no audit logs for port path blocked requests")
})
t.Run("PortSubdomain", func(t *testing.T) {
t.Parallel()
req := (workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodSubdomain,
BasePath: "/",
UsernameOrID: me.Username,
WorkspaceNameOrID: workspace.Name,
AgentNameOrID: agentName,
AppSlugOrPort: "9090",
}).Normalize()
auditor := audit.NewMock()
auditableIP := testutil.RandomIPv6(t)
rw := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/", nil)
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
r.RemoteAddr = auditableIP
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
PathAppBaseURL: api.AccessURL,
AppHostname: api.AppHostname,
AppRequest: req,
})
require.True(t, ok)
require.Equal(t, req.AppSlugOrPort, token.AppSlugOrPort)
require.Equal(t, "http://127.0.0.1:9090", token.AppURL)
assertAuditAgent(t, rw, r, auditor, agent, me.ID, map[string]any{
"slug_or_port": "9090",
})
require.Len(t, auditor.AuditLogs(), 1, "single audit log")
})
t.Run("PortSubdomainHTTPSS", func(t *testing.T) {
t.Parallel()
req := (workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodSubdomain,
BasePath: "/",
UsernameOrID: me.Username,
WorkspaceNameOrID: workspace.Name,
AgentNameOrID: agentName,
AppSlugOrPort: "9090ss",
}).Normalize()
auditor := audit.NewMock()
auditableIP := testutil.RandomIPv6(t)
rw := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/", nil)
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
r.RemoteAddr = auditableIP
_, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
PathAppBaseURL: api.AccessURL,
AppHostname: api.AppHostname,
AppRequest: req,
})
// should parse as app and fail to find app "9090ss"
require.False(t, ok)
w := rw.Result()
_ = w.Body.Close()
b, err := io.ReadAll(w.Body)
require.NoError(t, err)
require.Contains(t, string(b), "404 - Application Not Found")
require.Equal(t, http.StatusNotFound, w.StatusCode)
require.Len(t, auditor.AuditLogs(), 0, "no audit logs for invalid requests")
})
t.Run("SubdomainEndsInS", func(t *testing.T) {
t.Parallel()
req := (workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodSubdomain,
BasePath: "/",
UsernameOrID: me.Username,
WorkspaceNameOrID: workspace.Name,
AgentNameOrID: agentName,
AppSlugOrPort: appNameEndsInS,
}).Normalize()
auditor := audit.NewMock()
auditableIP := testutil.RandomIPv6(t)
rw := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/", nil)
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
r.RemoteAddr = auditableIP
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
PathAppBaseURL: api.AccessURL,
AppHostname: api.AppHostname,
AppRequest: req,
})
require.True(t, ok)
require.Equal(t, req.AppSlugOrPort, token.AppSlugOrPort)
assertAuditApp(t, rw, r, auditor, appsBySlug[appNameEndsInS], me.ID, nil)
require.Len(t, auditor.AuditLogs(), 1, "single audit log")
})
t.Run("Terminal", func(t *testing.T) {
t.Parallel()
req := (workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodTerminal,
BasePath: "/app",
AgentNameOrID: agentID.String(),
}).Normalize()
auditor := audit.NewMock()
auditableIP := testutil.RandomIPv6(t)
rw := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/app", nil)
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
r.RemoteAddr = auditableIP
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
PathAppBaseURL: api.AccessURL,
AppHostname: api.AppHostname,
AppRequest: req,
})
require.True(t, ok)
require.Equal(t, req.AccessMethod, token.AccessMethod)
require.Equal(t, req.BasePath, token.BasePath)
require.Empty(t, token.UsernameOrID)
require.Empty(t, token.WorkspaceNameOrID)
require.Equal(t, req.AgentNameOrID, token.Request.AgentNameOrID)
require.Empty(t, token.AppSlugOrPort)
require.Empty(t, token.AppURL)
assertAuditAgent(t, rw, r, auditor, agent, me.ID, map[string]any{
"slug_or_port": "terminal",
})
require.Len(t, auditor.AuditLogs(), 1, "single audit log")
})
t.Run("InsufficientPermissions", func(t *testing.T) {
t.Parallel()
req := (workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: me.Username,
WorkspaceNameOrID: workspace.Name,
AgentNameOrID: agentName,
AppSlugOrPort: appNameOwner,
}).Normalize()
auditor := audit.NewMock()
auditableIP := testutil.RandomIPv6(t)
rw := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/app", nil)
r.Header.Set(codersdk.SessionTokenHeader, secondUserClient.SessionToken())
r.RemoteAddr = auditableIP
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
PathAppBaseURL: api.AccessURL,
AppHostname: api.AppHostname,
AppRequest: req,
})
require.False(t, ok)
require.Nil(t, token)
assertAuditApp(t, rw, r, auditor, appsBySlug[appNameOwner], secondUser.ID, nil)
require.Len(t, auditor.AuditLogs(), 1, "single audit log")
})
t.Run("UserNotFound", func(t *testing.T) {
t.Parallel()
req := (workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: "thisuserdoesnotexist",
WorkspaceNameOrID: workspace.Name,
AgentNameOrID: agentName,
AppSlugOrPort: appNameOwner,
}).Normalize()
auditor := audit.NewMock()
auditableIP := testutil.RandomIPv6(t)
rw := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/app", nil)
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
r.RemoteAddr = auditableIP
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
PathAppBaseURL: api.AccessURL,
AppHostname: api.AppHostname,
AppRequest: req,
})
require.False(t, ok)
require.Nil(t, token)
require.Len(t, auditor.AuditLogs(), 0, "no audit logs for user not found")
})
t.Run("RedirectSubdomainAuth", func(t *testing.T) {
t.Parallel()
req := (workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodSubdomain,
BasePath: "/",
UsernameOrID: me.Username,
WorkspaceNameOrID: workspace.Name,
AgentNameOrID: agentName,
AppSlugOrPort: appNameOwner,
}).Normalize()
auditor := audit.NewMock()
auditableIP := testutil.RandomIPv6(t)
rw := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/some-path", nil)
// Should not be used as the hostname in the redirect URI.
r.Host = "app.com"
r.RemoteAddr = auditableIP
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
PathAppBaseURL: api.AccessURL,
AppHostname: api.AppHostname,
AppRequest: req,
AppPath: "/some-path",
})
require.False(t, ok)
require.Nil(t, token)
w := rw.Result()
defer w.Body.Close()
require.Equal(t, http.StatusSeeOther, w.StatusCode)
// Note that we don't capture the owner UUID here because the apiKey
// check/authorization exits early.
assertAuditApp(t, rw, r, auditor, appsBySlug[appNameOwner], uuid.Nil, nil)
require.Len(t, auditor.AuditLogs(), 1, "autit log entry for redirect")
loc, err := w.Location()
require.NoError(t, err)
require.Equal(t, api.AccessURL.Scheme, loc.Scheme)
require.Equal(t, api.AccessURL.Host, loc.Host)
require.Equal(t, "/api/v2/applications/auth-redirect", loc.Path)
redirectURIStr := loc.Query().Get(workspaceapps.RedirectURIQueryParam)
redirectURI, err := url.Parse(redirectURIStr)
require.NoError(t, err)
appHost := appurl.ApplicationURL{
Prefix: "",
AppSlugOrPort: req.AppSlugOrPort,
AgentName: req.AgentNameOrID,
WorkspaceName: req.WorkspaceNameOrID,
Username: req.UsernameOrID,
}
host := strings.Replace(api.AppHostname, "*", appHost.String(), 1)
require.Equal(t, "http", redirectURI.Scheme)
require.Equal(t, host, redirectURI.Host)
require.Equal(t, "/some-path", redirectURI.Path)
})
t.Run("UnhealthyAgent", func(t *testing.T) {
t.Parallel()
req := (workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: me.Username,
WorkspaceNameOrID: workspace.Name,
AgentNameOrID: agentNameUnhealthy,
AppSlugOrPort: appNameAgentUnhealthy,
}).Normalize()
auditor := audit.NewMock()
auditableIP := testutil.RandomIPv6(t)
rw := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/app", nil)
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
r.RemoteAddr = auditableIP
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
PathAppBaseURL: api.AccessURL,
AppHostname: api.AppHostname,
AppRequest: req,
})
require.False(t, ok, "request succeeded even though agent is not connected")
require.Nil(t, token)
w := rw.Result()
defer w.Body.Close()
require.Equal(t, http.StatusBadGateway, w.StatusCode)
assertAuditApp(t, rw, r, auditor, appsBySlug[appNameAgentUnhealthy], me.ID, nil)
require.Len(t, auditor.AuditLogs(), 1, "single audit log")
body, err := io.ReadAll(w.Body)
require.NoError(t, err)
bodyStr := string(body)
bodyStr = strings.ReplaceAll(bodyStr, """, `"`)
// It'll either be "connecting" or "disconnected". Both are OK for this
// test.
require.Contains(t, bodyStr, `Agent state is "`)
})
// Initializing apps are now permitted to connect anyways. This wasn't
// always the case, but we're testing the behavior to ensure it doesn't
// change back accidentally.
t.Run("InitializingAppPermitted", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
agent, err := client.WorkspaceAgent(ctx, agentID)
require.NoError(t, err)
for _, app := range agent.Apps {
if app.Slug == appNameInitializing {
t.Log("app is", app.Health)
require.Equal(t, codersdk.WorkspaceAppHealthInitializing, app.Health)
break
}
}
req := (workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: me.Username,
WorkspaceNameOrID: workspace.Name,
AgentNameOrID: agentName,
AppSlugOrPort: appNameInitializing,
}).Normalize()
auditor := audit.NewMock()
auditableIP := testutil.RandomIPv6(t)
rw := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/app", nil)
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
r.RemoteAddr = auditableIP
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
PathAppBaseURL: api.AccessURL,
AppHostname: api.AppHostname,
AppRequest: req,
})
require.True(t, ok, "ResolveRequest failed, should pass even though app is initializing")
require.NotNil(t, token)
assertAuditApp(t, rw, r, auditor, appsBySlug[token.AppSlugOrPort], me.ID, nil)
require.Len(t, auditor.AuditLogs(), 1, "single audit log")
})
// Unhealthy apps are now permitted to connect anyways. This wasn't always
// the case, but we're testing the behavior to ensure it doesn't change back
// accidentally.
t.Run("UnhealthyAppPermitted", func(t *testing.T) {
t.Parallel()
require.Eventually(t, func() bool {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
agent, err := client.WorkspaceAgent(ctx, agentID)
if err != nil {
t.Log("could not get agent", err)
return false
}
for _, app := range agent.Apps {
if app.Slug == appNameUnhealthy {
t.Log("app is", app.Health)
return app.Health == codersdk.WorkspaceAppHealthUnhealthy
}
}
t.Log("could not find app")
return false
}, testutil.WaitLong, testutil.IntervalFast, "wait for app to become unhealthy")
req := (workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: me.Username,
WorkspaceNameOrID: workspace.Name,
AgentNameOrID: agentName,
AppSlugOrPort: appNameUnhealthy,
}).Normalize()
auditor := audit.NewMock()
auditableIP := testutil.RandomIPv6(t)
rw := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/app", nil)
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
r.RemoteAddr = auditableIP
token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
PathAppBaseURL: api.AccessURL,
AppHostname: api.AppHostname,
AppRequest: req,
})
require.True(t, ok, "ResolveRequest failed, should pass even though app is unhealthy")
require.NotNil(t, token)
assertAuditApp(t, rw, r, auditor, appsBySlug[token.AppSlugOrPort], me.ID, nil)
require.Len(t, auditor.AuditLogs(), 1, "single audit log")
})
t.Run("AuditLogging", func(t *testing.T) {
t.Parallel()
for _, app := range allApps {
req := (workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: me.Username,
WorkspaceNameOrID: workspace.Name,
AgentNameOrID: agentName,
AppSlugOrPort: app,
}).Normalize()
auditor := audit.NewMock()
auditableIP := testutil.RandomIPv6(t)
t.Log("app", app)
// First request, new audit log.
rw := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/app", nil)
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
r.RemoteAddr = auditableIP
_, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
PathAppBaseURL: api.AccessURL,
AppHostname: api.AppHostname,
AppRequest: req,
})
require.True(t, ok)
assertAuditApp(t, rw, r, auditor, appsBySlug[app], me.ID, nil)
require.Len(t, auditor.AuditLogs(), 1, "single audit log")
// Second request, no audit log because the session is active.
rw = httptest.NewRecorder()
r = httptest.NewRequest("GET", "/app", nil)
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
r.RemoteAddr = auditableIP
_, ok = workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
PathAppBaseURL: api.AccessURL,
AppHostname: api.AppHostname,
AppRequest: req,
})
require.True(t, ok)
require.Len(t, auditor.AuditLogs(), 1, "single audit log, previous session active")
// Third request, session timed out, new audit log.
rw = httptest.NewRecorder()
r = httptest.NewRequest("GET", "/app", nil)
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
r.RemoteAddr = auditableIP
sessionTimeoutTokenProvider := signedTokenProviderWithAuditor(t, api.WorkspaceAppsProvider, auditor, 0)
_, ok = workspaceappsResolveRequest(t, nil, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: sessionTimeoutTokenProvider,
DashboardURL: api.AccessURL,
PathAppBaseURL: api.AccessURL,
AppHostname: api.AppHostname,
AppRequest: req,
})
require.True(t, ok)
assertAuditApp(t, rw, r, auditor, appsBySlug[app], me.ID, nil)
require.Len(t, auditor.AuditLogs(), 2, "two audit logs, session timed out")
// Fourth request, new IP produces new audit log.
auditableIP = testutil.RandomIPv6(t)
rw = httptest.NewRecorder()
r = httptest.NewRequest("GET", "/app", nil)
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
r.RemoteAddr = auditableIP
_, ok = workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{
Logger: api.Logger,
SignedTokenProvider: api.WorkspaceAppsProvider,
DashboardURL: api.AccessURL,
PathAppBaseURL: api.AccessURL,
AppHostname: api.AppHostname,
AppRequest: req,
})
require.True(t, ok)
assertAuditApp(t, rw, r, auditor, appsBySlug[app], me.ID, nil)
require.Len(t, auditor.AuditLogs(), 3, "three audit logs, new IP")
}
})
}
func workspaceappsResolveRequest(t testing.TB, auditor audit.Auditor, w http.ResponseWriter, r *http.Request, opts workspaceapps.ResolveRequestOptions) (token *workspaceapps.SignedToken, ok bool) {
t.Helper()
if opts.SignedTokenProvider != nil && auditor != nil {
opts.SignedTokenProvider = signedTokenProviderWithAuditor(t, opts.SignedTokenProvider, auditor, time.Hour)
}
tracing.StatusWriterMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
httpmw.AttachRequestID(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token, ok = workspaceapps.ResolveRequest(w, r, opts)
})).ServeHTTP(w, r)
})).ServeHTTP(w, r)
return token, ok
}
func signedTokenProviderWithAuditor(t testing.TB, provider workspaceapps.SignedTokenProvider, auditor audit.Auditor, sessionTimeout time.Duration) workspaceapps.SignedTokenProvider {
t.Helper()
p, ok := provider.(*workspaceapps.DBTokenProvider)
require.True(t, ok, "provider is not a DBTokenProvider")
shallowCopy := *p
shallowCopy.Auditor = &atomic.Pointer[audit.Auditor]{}
shallowCopy.Auditor.Store(&auditor)
shallowCopy.WorkspaceAppAuditSessionTimeout = sessionTimeout
return &shallowCopy
}
func auditAsserter[T audit.Auditable](workspace codersdk.Workspace) func(t testing.TB, rr *httptest.ResponseRecorder, r *http.Request, auditor *audit.MockAuditor, auditable T, userID uuid.UUID, additionalFields map[string]any) {
return func(t testing.TB, rr *httptest.ResponseRecorder, r *http.Request, auditor *audit.MockAuditor, auditable T, userID uuid.UUID, additionalFields map[string]any) {
t.Helper()
resp := rr.Result()
defer resp.Body.Close()
require.True(t, auditor.Contains(t, database.AuditLog{
OrganizationID: workspace.OrganizationID,
Action: database.AuditActionOpen,
ResourceType: audit.ResourceType(auditable),
ResourceID: audit.ResourceID(auditable),
ResourceTarget: audit.ResourceTarget(auditable),
UserID: userID,
Ip: audit.ParseIP(r.RemoteAddr),
UserAgent: sql.NullString{Valid: r.UserAgent() != "", String: r.UserAgent()},
StatusCode: int32(resp.StatusCode), //nolint:gosec
}), "audit log")
// Verify additional fields, assume the last log entry.
alog := auditor.AuditLogs()[len(auditor.AuditLogs())-1]
// Contains does not verify uuid.Nil.
if userID == uuid.Nil {
require.Equal(t, uuid.Nil, alog.UserID, "unauthenticated user")
}
add := make(map[string]any)
if len(alog.AdditionalFields) > 0 {
err := json.Unmarshal([]byte(alog.AdditionalFields), &add)
require.NoError(t, err, "audit log unmarhsal additional fields")
}
for k, v := range additionalFields {
require.Equal(t, v, add[k], "audit log additional field %s: additional fields: %v", k, add)
}
}
}