fix(coderd/workspaceapps): verify workspace owner matches app username (#26085)

fix(coderd/workspaceapps): verify workspace owner matches app username

When resolving a workspace app by workspace UUID, the URL's username
segment was never reconciled against the resolved workspace's owner.
A user could serve their own workspace app from a hostname embedding
another user's username, so the parsed origin username belonged to the
victim.  Combined with the username-equality CORS check, this allowed
credentialed cross-origin reads of the victim's app responses.

Reject the request with a 404 when the resolved workspace's owner does
not match the user named in the request.

Refs: https://linear.app/codercom/issue/PLAT-260
This commit is contained in:
George K
2026-06-10 16:26:45 -07:00
committed by GitHub
parent 77522c3945
commit 0b99e67ce7
3 changed files with 75 additions and 0 deletions
+44
View File
@@ -908,6 +908,50 @@ func Test_ResolveRequest(t *testing.T) {
require.Len(t, connLogger.ConnectionLogs(), 0)
})
// Security (PLAT-260): a UUID workspace lookup must reject when
// the URL's username segment names a different owner. Otherwise a
// same-owner origin can be spoofed for credentialed cross-origin
// reads.
t.Run("WorkspaceUUIDOwnerMismatch", func(t *testing.T) {
t.Parallel()
req := (workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: secondUser.Username,
WorkspaceNameOrID: workspace.ID.String(),
AgentNameOrID: agentName,
AppSlugOrPort: appNamePublic,
}).Normalize()
connLogger := connectionlog.NewFake()
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, connLogger, 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()
defer 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, connLogger.ConnectionLogs(), 0)
})
t.Run("RedirectSubdomainAuth", func(t *testing.T) {
t.Parallel()