Skip to content

fix: pin workspace agent API client to intended agent#26600

Open
ethanndickson wants to merge 3 commits into
mainfrom
agentconn-ysxh
Open

fix: pin workspace agent API client to intended agent#26600
ethanndickson wants to merge 3 commits into
mainfrom
agentconn-ysxh

Conversation

@ethanndickson

@ethanndickson ethanndickson commented Jun 23, 2026

Copy link
Copy Markdown
Member

Summary

The control-plane HTTP client used to talk to workspace agents followed HTTP redirects and trusted the redirected host, letting a malicious workspace agent bounce a coderd request onto a different agent on the shared tailnet. Because the agent HTTP API on port 4 is unauthenticated (it relies on tailnet reachability plus control-plane authorization), this allowed cross-tenant file read/write and remote code execution. This PR refuses redirects and pins every dial to the intended agent.

Closes CODAGT-668.

Problem

agentConn.apiClient in codersdk/workspacesdk/agentconn.go constructed an http.Client with no CheckRedirect, so Go's default policy followed up to 10 redirects. Its custom Transport.DialContext parsed the host from the (post-redirect) request URL and dialed that IP over the shared tailnet, validating only that the port was AgentHTTPAPIServerPort (4). It never pinned the connection to the intended AgentID / agentAddress().

A workspace owner (any regular org member, not just admins) controls their own agent and can make its port-4 handler return a 3xx Location pointing at a victim agent's tailnet IP. When a control-plane action (for example a chat tool or the HTTP MCP server) sends an agent API request to the attacker's agent, coderd acts as a confused deputy and replays the request against the victim:

  • 301/302/303 rewrite POST to GET, but 307/308 preserve method and body when the body is replayable. The real callers pass replayable bodies, so a redirected POST /api/v0/write-file writes attacker-controlled content into the victim workspace and a redirected POST /api/v0/processes/start executes it, giving RCE on the victim agent.

The dangerous callers run server-side on coderd's single deployment-wide ServerTailnet, which is authorized to tunnel to any agent, so the blast radius is cross-tenant / cross-organization (limited in practice to victim agents coderd currently has a live tunnel to).

Fix

In agentConn.apiClient:

  • Set CheckRedirect: http.ErrUseLastResponse so the client never follows a redirect. A 3xx is surfaced to the caller as the response (which the existing ReadBodyAsError path turns into an error) instead of being replayed against another host.
  • Capture the intended agent address once from AgentID (agentAddr := netip.AddrPortFrom(c.agentAddress(), AgentHTTPAPIServerPort)), reject any dial whose host or port does not match it, and always dial that pinned address rather than the URL-derived host.

In coderd/aitasks.go, the task app proxy client (taskAppHTTPClient) also now sets CheckRedirect: http.ErrUseLastResponse. This client dials through agentConn.DialContext, which already pins the host to the originating workspace's agent (it takes only the port from the dial address), so it was never cross-agent. The change is hardening for parity so a malicious app cannot bounce the request to a different port on the same agent.

Hardening and defense in depth

The two layers are independent. CheckRedirect removes the redirect-following behavior entirely, and the dial pinning guarantees that even a request constructed with a foreign host can only ever reach the intended agent. Removing either one in the future cannot, on its own, reintroduce the cross-agent vector.

Tests

  • codersdk/workspacesdk/agentconn_redirect_test.go builds a three-peer tailnet (client, attacker, victim). The attacker agent redirects to the victim's port-4 URL, and the test asserts that GET 302, POST 307, and POST 308 all return an error and that the victim is never contacted.
  • coderd/aitasks_internal_test.go adds TestTaskAppHTTPClient_RejectsRedirect, which verifies the task app client surfaces a 307 instead of following it to a stand-in victim.

Why this closes the whole vulnerability class

apiClient is the only HTTP chokepoint to the agent port-4 API, so fixing it covers every server-side caller:

  • Every agent HTTP API method in agentConn funnels through apiClient, either via apiRequest, a direct apiClient(ctx).Do(...) (ExecuteDesktopAction), or as the websocket HTTPClient (WatchContainers, WatchGit, ConnectDesktopVNC). The websocket handshake matters here: coder/websocket follows 3xx during the handshake by default and only requires 101 on the final hop, but it honors the underlying client's CheckRedirect, so reusing apiClient closes the websocket paths too.
  • The HTTP MCP server coderd hosts at /api/experimental/mcp/http registers tools (coder_workspace_bash, _write_file, _read_file, _edit_files, etc.) that reach the agent through workspacesdk.AgentConn methods, so they go through apiClient and are covered. The same is true for agent-hosted MCP, which coderd reaches only via agentConn.CallMCPTool / ListMCPTools. coderd never opens an MCP client connection directly to an agent over the tailnet.
  • Raw-TCP agent services (reconnecting PTY, SSH, speedtest, generic DialContext) speak non-HTTP protocols and have no redirect surface. The workspace apps reverse proxy targets user app ports, not port 4, forwards 3xx to the browser rather than following them, and pins its transport to the request's agent.
  • provisionerd does not talk to the agent HTTP API at all.

No other server-side client follows redirects to an agent-controllable tailnet host, so no further redirect changes are required for this class.

The control-plane HTTP client used to talk to workspace agents
(agentConn.apiClient) followed redirects with Go's default policy, and
its DialContext dialed whatever host the post-redirect URL contained,
checking only that the port was the agent HTTP API port (4). A malicious
workspace agent could return a 3xx redirect to another agent's tailnet
IP, and coderd's shared tailnet would replay the request (including
replayable POST bodies on 307/308) against the victim agent's
unauthenticated HTTP API, enabling cross-tenant file read/write and RCE.

Refuse redirects via CheckRedirect and pin every dial to the intended
agent address, rejecting any request host that differs from it. Apply
the same redirect refusal to the task app client in aitasks.go.

Closes CODAGT-668
@linear-code

linear-code Bot commented Jun 23, 2026

Copy link
Copy Markdown

CODAGT-668

@ethanndickson ethanndickson marked this pull request as ready for review June 23, 2026 04:18
@datadog-coder

This comment has been minimized.

@ethanndickson

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. What shall we delve into next?

Reviewed commit: 032cb84d73

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant