Skip to content

fix: base workspace-app CORS on resolved owner instead of URL username#26086

Closed
geokat wants to merge 1 commit into
george/plat-260/1-minimal-fixfrom
george/plat-260/2-defense-in-depth
Closed

fix: base workspace-app CORS on resolved owner instead of URL username#26086
geokat wants to merge 1 commit into
george/plat-260/1-minimal-fixfrom
george/plat-260/2-defense-in-depth

Conversation

@geokat

@geokat geokat commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Stacked on #26085 (the minimum fix).

The workspace-app CORS check compared the username parsed from the origin's subdomain hostname against the target app's username. That value is attacker-controllable, so a same-owner origin could be spoofed and credentialed cross-origin reads allowed.

Compare resolved owner IDs instead: the target app's owner comes from the issued signed token, and the origin app's owner is resolved from the database as the owner of the origin's workspace. Permit the credentialed cross-origin read (emit the CORS allow-origin header) only when the two match. wsproxy has no database access, so it resolves the origin owner through a new coderd endpoint. The check fails closed when the owner cannot be resolved.

Because the origin owner is read from the resolved workspace rather than the URL username, this check denies the spoofed cross-origin read on its own, even if #26085 were absent or its resolution-time guard regressed. The check runs on every CORS request, but while #26085 is in place no origin an attacker can serve will exercise that independence: the only distinguishing input is the spoofed-username origin #26085 prevents from resolving. So the standalone behavior isn't separately tested.

Refs: https://linear.app/codercom/issue/PLAT-260


This PR is part of a stack that merges into main:

  1. fix(coderd/workspaceapps): verify workspace owner matches app username #26085
  2. fix: base workspace-app CORS on resolved owner instead of URL username #26086 👈

Created with stakk

@linear-code

linear-code Bot commented Jun 5, 2026

Copy link
Copy Markdown

PLAT-260

@geokat geokat force-pushed the george/plat-260/2-defense-in-depth branch from d3bd31b to 75e1b53 Compare June 6, 2026 00:26
@github-actions

github-actions Bot commented Jun 6, 2026

Copy link
Copy Markdown

Docs preview

📖 View docs preview for docs/reference/api/schemas.md

@geokat geokat force-pushed the george/plat-260/2-defense-in-depth branch from 75e1b53 to 302a2ba Compare June 6, 2026 00:33
@geokat geokat force-pushed the george/plat-260/1-minimal-fix branch from 07f7dcc to 510ba49 Compare June 8, 2026 18:53
@geokat geokat force-pushed the george/plat-260/2-defense-in-depth branch 3 times, most recently from 4a77e06 to b45e9e5 Compare June 8, 2026 20:19
The workspace-app CORS check trusted the username parsed from the
origin's subdomain hostname, comparing it against the target app's
username. Because that value is attacker-controllable, a same-owner
origin could be spoofed and credentialed cross-origin reads allowed.

Compare the target app's owner, taken from the issued signed token,
against the origin app's owner, resolved from the database as the owner
of the origin's workspace. Allow the request only when the two owner IDs
match. wsproxy resolves the origin owner through a new coderd endpoint
since it has no database access. Fail closed when the owner cannot be
resolved. Rename the wsproxy TokenProvider to AppsProvider to reflect
its expanded responsibility.

Resolving the origin owner from its workspace rather than the URL
username keeps this check sound even if the resolution-time guard that
rejects owner/username mismatches is ever removed, giving a second line
of defense against the spoof.

Refs: https://linear.app/codercom/issue/PLAT-260
@geokat geokat force-pushed the george/plat-260/1-minimal-fix branch from 510ba49 to c773cce Compare June 8, 2026 20:38
@geokat geokat force-pushed the george/plat-260/2-defense-in-depth branch from b45e9e5 to 725749e Compare June 8, 2026 20:38

//nolint:gocritic // The caller may not have permission to read the
// workspace, but CORS needs authoritative app identity.
dbReq, err := req.getDatabase(dbauthz.AsSystemRestricted(ctx), p.Database)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Heads up, not blocking: this resolves the origin owner from the DB on every CORS request in simple mode (AllowOriginFunc calls ResolveAppOwnerID for each cross-origin hit, not just preflights), and on the wsproxy path it's an HTTP round-trip to coderd per request via /resolve-app-owner. Previously this was an in-memory string compare. For app-heavy workspaces behind a proxy that's a real per-request cost.

Worth caching the normalized origin ApplicationURL -> owner ID with a short TTL, both here and in enterprise/wsproxy/AppsProvider.ResolveAppOwnerID. TTL should stay short since this gates a credentialed (AllowCredentials) cross-origin read and ownership can change (transfer/delete).

Planning to file a follow-up issue for the cache, unless you think it's in-scope?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we probably do need to cache this result, we don't want to be issuing round-trips from proxies to the primary on every request.

@geokat

geokat commented Jun 8, 2026

Copy link
Copy Markdown
Contributor Author

Note on the rename churn (happy to revert): this PR renames the wsproxy provider type TokenProviderAppsProvider (file enterprise/wsproxy/tokenprovider.go → appsprovider.go, which GitHub shows as a delete + add since the file also gained ResolveAppOwnerID), and on the coderd side changes the provider's declared type from SignedTokenProvider to a new Provider interface plus renames the ServerOptions.SignedTokenProvider field to WorkspaceAppsProvider.

These are cosmetic — they reflect the provider now also resolving owners, not just issuing tokens. Provider just embeds SignedTokenProvider plus the new AppOwnerResolver. If the churn distracts from the security change, I'm happy to drop the renames and keep the functional diff.

@geokat geokat marked this pull request as ready for review June 8, 2026 21:41
@geokat geokat requested review from deansheather and johnstcn June 8, 2026 22:22

@deansheather deansheather left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry it took so long for the review.

Do we need to implement this defense-in-depth measure? While it's true that attackers could change their username, they can't change their username to the same as another user. I'd like to see a PoC validated before making a significant change like this to the wsproxy-facing API and adding extra complexity/latency to wsproxy requests.


//nolint:gocritic // The caller may not have permission to read the
// workspace, but CORS needs authoritative app identity.
dbReq, err := req.getDatabase(dbauthz.AsSystemRestricted(ctx), p.Database)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we probably do need to cache this result, we don't want to be issuing round-trips from proxies to the primary on every request.

@geokat

geokat commented Jun 10, 2026

Copy link
Copy Markdown
Contributor Author

@deansheather
Thank you for the review!

Do we need to implement this defense-in-depth measure? While it's true that attackers could change their username, they can't change their username to the same as another user. I'd like to see a PoC validated before making a significant change like this to the wsproxy-facing API and adding extra complexity/latency to wsproxy requests.

The only way I can produce a PoC is to disable the owner/username check from #26085, and that regression is already guarded by the WorkspaceUUIDOwnerMismatch tests added in that PR.

You're right, this PR (plus an owner-lookup cache) is too much complexity to defend against a regression that tests already catch.

I'll close this for now unless there are objections.

geokat added a commit that referenced this pull request Jun 10, 2026
They are now the sole line of defense since we decided not to merge #26086 (defense-in-depth)
@geokat geokat closed this Jun 10, 2026
@github-actions github-actions Bot locked and limited conversation to collaborators Jun 10, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants