From 7800b6532b988ea7766294f6052e19f52146065e Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 23 Jun 2026 04:11:32 +0000 Subject: [PATCH 1/6] fix: pin workspace agent API client to intended agent 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 --- coderd/aitasks.go | 24 ++- coderd/aitasks_internal_test.go | 40 ++++ codersdk/workspacesdk/agentconn.go | 28 ++- .../workspacesdk/agentconn_redirect_test.go | 183 ++++++++++++++++++ 4 files changed, 262 insertions(+), 13 deletions(-) create mode 100644 codersdk/workspacesdk/agentconn_redirect_test.go diff --git a/coderd/aitasks.go b/coderd/aitasks.go index 7518a98d335..75dd2596ebc 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -1086,14 +1086,28 @@ func (api *API) authAndDoWithTaskAppClient( } defer release() - client := &http.Client{ + client := taskAppHTTPClient(agentConn.DialContext) + return do(ctx, client, parsedURL) +} + +// taskAppHTTPClient builds the HTTP client used to reach a workspace's task app +// over its agent connection, dialing via the provided dial function. +// +// The client never follows redirects. The task app endpoint is served by an +// untrusted workspace agent, and dial pins every connection to this workspace's +// own agent (it takes only the port from the dial address), so a redirect can +// never reach a different agent. Redirects are refused anyway so a malicious +// agent cannot bounce the request to a different port on the same agent via a +// 3xx Location header. +func taskAppHTTPClient(dial func(ctx context.Context, network, addr string) (net.Conn, error)) *http.Client { + return &http.Client{ + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, Transport: &http.Transport{ - DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { - return agentConn.DialContext(ctx, network, addr) - }, + DialContext: dial, }, } - return do(ctx, client, parsedURL) } const ( diff --git a/coderd/aitasks_internal_test.go b/coderd/aitasks_internal_test.go index 0c087c653be..08319c95f5e 100644 --- a/coderd/aitasks_internal_test.go +++ b/coderd/aitasks_internal_test.go @@ -1,6 +1,11 @@ package coderd import ( + "context" + "net" + "net/http" + "net/http/httptest" + "sync/atomic" "testing" "time" @@ -221,3 +226,38 @@ func TestDeriveTaskCurrentState_Unit(t *testing.T) { }) } } + +// TestTaskAppHTTPClient_RejectsRedirect verifies the client built for dialing a +// workspace task app never follows redirects. A malicious app must not be able +// to bounce coderd's request to a different address via a 3xx Location header. +func TestTaskAppHTTPClient_RejectsRedirect(t *testing.T) { + t.Parallel() + + // victim stands in for a different address (e.g. a different port) that a + // followed redirect would reach. It must never be contacted. + var victimHits atomic.Int64 + victim := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + victimHits.Add(1) + w.WriteHeader(http.StatusOK) + })) + defer victim.Close() + + // The task app redirects every request to the victim address. + app := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, victim.URL+r.URL.Path, http.StatusTemporaryRedirect) + })) + defer app.Close() + + // The dial honors addr like the real agent transport, so following the + // redirect would actually reach the victim. + client := taskAppHTTPClient(func(ctx context.Context, network, addr string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, network, addr) + }) + + resp, err := client.Get(app.URL) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode, "redirect must be surfaced, not followed") + require.Zero(t, victimHits.Load(), "redirect target must not be contacted") +} diff --git a/codersdk/workspacesdk/agentconn.go b/codersdk/workspacesdk/agentconn.go index 2b4ab3384bc..7ff85655e4d 100644 --- a/codersdk/workspacesdk/agentconn.go +++ b/codersdk/workspacesdk/agentconn.go @@ -1386,7 +1386,18 @@ func (c *agentConn) apiRequest(ctx context.Context, method, path string, body in // scoped to a single request: its transport cancels in-flight dials // once reqCtx ends. func (c *agentConn) apiClient(reqCtx context.Context) *http.Client { + // agentAddr is the only address this client is ever allowed to dial. It is + // derived from the intended AgentID rather than any request URL, so a + // redirect (or any other attacker-controlled host) can never retarget the + // request at a different agent on the shared tailnet. + agentAddr := netip.AddrPortFrom(c.agentAddress(), AgentHTTPAPIServerPort) return &http.Client{ + // Never follow redirects. A malicious agent could otherwise return a + // 3xx that bounces the request to another agent. Combined with the + // pinned dial below, this keeps every request on the intended agent. + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, Transport: &http.Transport{ // Disable keep alives as we're usually only making a single // request, and this triggers goleak in tests @@ -1396,15 +1407,20 @@ func (c *agentConn) apiClient(reqCtx context.Context) *http.Client { return nil, xerrors.Errorf("network must be tcp") } + // Reject any request whose host or port is not the intended + // agent's HTTP API. apiRequest always sets the host to the + // agent address, so a mismatch here only comes from an + // attacker-controlled URL or redirect. host, port, err := net.SplitHostPort(addr) if err != nil { return nil, xerrors.Errorf("split host port %q: %w", addr, err) } - - // Verify that the port is TailnetStatisticsPort. if port != strconv.Itoa(AgentHTTPAPIServerPort) { return nil, xerrors.Errorf("request %q does not appear to be for http api", addr) } + if reqAddr, err := netip.ParseAddr(host); err != nil || reqAddr != agentAddr.Addr() { + return nil, xerrors.Errorf("request host %q does not match intended agent %q", host, agentAddr.Addr()) + } // http.Transport detaches ctx from the request context so // a pending dial can outlive its request and serve future @@ -1422,12 +1438,8 @@ func (c *agentConn) apiClient(reqCtx context.Context) *http.Client { return nil, xerrors.Errorf("workspace agent not reachable in time: %v", ctx.Err()) } - ipAddr, err := netip.ParseAddr(host) - if err != nil { - return nil, xerrors.Errorf("parse host addr: %w", err) - } - - conn, err := c.Conn.DialContextTCP(ctx, netip.AddrPortFrom(ipAddr, AgentHTTPAPIServerPort)) + // Always dial the pinned agent address, never the request host. + conn, err := c.Conn.DialContextTCP(ctx, agentAddr) if err != nil { return nil, xerrors.Errorf("dial http api: %w", err) } diff --git a/codersdk/workspacesdk/agentconn_redirect_test.go b/codersdk/workspacesdk/agentconn_redirect_test.go new file mode 100644 index 00000000000..c059c588915 --- /dev/null +++ b/codersdk/workspacesdk/agentconn_redirect_test.go @@ -0,0 +1,183 @@ +package workspacesdk_test + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "net/netip" + "strings" + "sync/atomic" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "tailscale.com/tailcfg" + + "github.com/coder/coder/v2/codersdk/workspacesdk" + "github.com/coder/coder/v2/tailnet" + "github.com/coder/coder/v2/tailnet/proto" + "github.com/coder/coder/v2/tailnet/tailnettest" + "github.com/coder/coder/v2/testutil" +) + +func TestAgentConnRejectsCrossAgentRedirects(t *testing.T) { + t.Parallel() + + derpMap, _ := tailnettest.RunDERPAndSTUN(t) + cases := []struct { + name string + status int + invoke func(context.Context, workspacesdk.AgentConn) error + }{ + { + name: "get 302", + status: http.StatusFound, + invoke: func(ctx context.Context, conn workspacesdk.AgentConn) error { + _, err := conn.ListeningPorts(ctx) + return err + }, + }, + { + name: "post 307", + status: http.StatusTemporaryRedirect, + invoke: func(ctx context.Context, conn workspacesdk.AgentConn) error { + return conn.WriteFile(ctx, "/tmp/attacker", strings.NewReader("redirect-body")) + }, + }, + { + name: "post 308", + status: http.StatusPermanentRedirect, + invoke: func(ctx context.Context, conn workspacesdk.AgentConn) error { + return conn.WriteFile(ctx, "/tmp/attacker", strings.NewReader("redirect-body")) + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium) + t.Cleanup(cancel) + + clientID := uuid.New() + attackerID := uuid.New() + victimID := uuid.New() + clientConn, _ := newRedirectTestTailnetConn(t, derpMap, clientID, "client") + attackerConn, attackerIP := newRedirectTestTailnetConn(t, derpMap, attackerID, "attacker") + victimConn, victimIP := newRedirectTestTailnetConn(t, derpMap, victimID, "victim") + coordinateRedirectTestTailnet(t, map[uuid.UUID]*tailnet.Conn{ + clientID: clientConn, + attackerID: attackerConn, + victimID: victimConn, + }) + + var victimHit atomic.Bool + victimRouter := http.NewServeMux() + victimRouter.HandleFunc("/api/v0/listening-ports", func(rw http.ResponseWriter, _ *http.Request) { + victimHit.Store(true) + rw.Header().Set("Content-Type", "application/json") + _, _ = rw.Write([]byte(`{"ports":[]}`)) + }) + victimRouter.HandleFunc("/api/v0/write-file", func(rw http.ResponseWriter, _ *http.Request) { + victimHit.Store(true) + rw.WriteHeader(http.StatusOK) + }) + serveRedirectTestTailnetHTTP(t, victimConn, victimRouter) + + victimBaseURL := fmt.Sprintf("http://[%s]:%d", victimIP, workspacesdk.AgentHTTPAPIServerPort) + attackerRouter := http.NewServeMux() + attackerRouter.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) { + http.Redirect(rw, r, victimBaseURL+r.URL.RequestURI(), tc.status) + }) + serveRedirectTestTailnetHTTP(t, attackerConn, attackerRouter) + + require.True(t, clientConn.AwaitReachable(ctx, attackerIP)) + require.True(t, clientConn.AwaitReachable(ctx, victimIP)) + + conn := workspacesdk.NewAgentConn(clientConn, workspacesdk.AgentConnOptions{ + AgentID: attackerID, + }) + + err := tc.invoke(ctx, conn) + require.Error(t, err) + require.False(t, victimHit.Load()) + }) + } +} + +func newRedirectTestTailnetConn(t *testing.T, derpMap *tailcfg.DERPMap, id uuid.UUID, name string) (*tailnet.Conn, netip.Addr) { + t.Helper() + + addr := tailnet.TailscaleServicePrefix.AddrFromUUID(id) + conn, err := tailnet.NewConn(&tailnet.Options{ + ID: id, + Addresses: []netip.Prefix{netip.PrefixFrom(addr, 128)}, + Logger: testutil.Logger(t).Named(name), + DERPMap: derpMap, + }) + require.NoError(t, err) + t.Cleanup(func() { + assert.NoError(t, conn.Close()) + }) + + return conn, addr +} + +func serveRedirectTestTailnetHTTP(t *testing.T, conn *tailnet.Conn, handler http.Handler) { + t.Helper() + + ln, err := conn.Listen("tcp", fmt.Sprintf(":%d", workspacesdk.AgentHTTPAPIServerPort)) + require.NoError(t, err) + + server := &http.Server{Handler: handler} + t.Cleanup(func() { + assert.NoError(t, server.Close()) + assert.NoError(t, ln.Close()) + }) + + go func() { + err := server.Serve(ln) + if err != nil && !errors.Is(err, net.ErrClosed) && !errors.Is(err, http.ErrServerClosed) { + assert.NoError(t, err) + } + }() +} + +func coordinateRedirectTestTailnet(t *testing.T, conns map[uuid.UUID]*tailnet.Conn) { + t.Helper() + + sendNode := func(srcID uuid.UUID, node *tailnet.Node) { + protoNode, err := tailnet.NodeToProto(node) + if !assert.NoError(t, err) { + return + } + for dstID, dst := range conns { + if dstID == srcID { + continue + } + err = dst.UpdatePeers([]*proto.CoordinateResponse_PeerUpdate{{ + Id: srcID[:], + Node: protoNode, + Kind: proto.CoordinateResponse_PeerUpdate_NODE, + }}) + assert.NoError(t, err) + } + } + + for srcID, src := range conns { + src.SetNodeCallback(func(node *tailnet.Node) { + sendNode(srcID, node) + }) + if node := src.Node(); node != nil { + sendNode(srcID, node) + } + } + + t.Cleanup(func() { + for _, conn := range conns { + conn.SetNodeCallback(nil) + } + }) +} From 4f981d97cdaf44727b41ab2f6626fc27dc10c024 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 23 Jun 2026 04:14:04 +0000 Subject: [PATCH 2/6] test: move cross-agent redirect tests into agentconn_test.go --- .../workspacesdk/agentconn_redirect_test.go | 183 ------------------ codersdk/workspacesdk/agentconn_test.go | 169 ++++++++++++++++ 2 files changed, 169 insertions(+), 183 deletions(-) delete mode 100644 codersdk/workspacesdk/agentconn_redirect_test.go diff --git a/codersdk/workspacesdk/agentconn_redirect_test.go b/codersdk/workspacesdk/agentconn_redirect_test.go deleted file mode 100644 index c059c588915..00000000000 --- a/codersdk/workspacesdk/agentconn_redirect_test.go +++ /dev/null @@ -1,183 +0,0 @@ -package workspacesdk_test - -import ( - "context" - "errors" - "fmt" - "net" - "net/http" - "net/netip" - "strings" - "sync/atomic" - "testing" - - "github.com/google/uuid" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "tailscale.com/tailcfg" - - "github.com/coder/coder/v2/codersdk/workspacesdk" - "github.com/coder/coder/v2/tailnet" - "github.com/coder/coder/v2/tailnet/proto" - "github.com/coder/coder/v2/tailnet/tailnettest" - "github.com/coder/coder/v2/testutil" -) - -func TestAgentConnRejectsCrossAgentRedirects(t *testing.T) { - t.Parallel() - - derpMap, _ := tailnettest.RunDERPAndSTUN(t) - cases := []struct { - name string - status int - invoke func(context.Context, workspacesdk.AgentConn) error - }{ - { - name: "get 302", - status: http.StatusFound, - invoke: func(ctx context.Context, conn workspacesdk.AgentConn) error { - _, err := conn.ListeningPorts(ctx) - return err - }, - }, - { - name: "post 307", - status: http.StatusTemporaryRedirect, - invoke: func(ctx context.Context, conn workspacesdk.AgentConn) error { - return conn.WriteFile(ctx, "/tmp/attacker", strings.NewReader("redirect-body")) - }, - }, - { - name: "post 308", - status: http.StatusPermanentRedirect, - invoke: func(ctx context.Context, conn workspacesdk.AgentConn) error { - return conn.WriteFile(ctx, "/tmp/attacker", strings.NewReader("redirect-body")) - }, - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium) - t.Cleanup(cancel) - - clientID := uuid.New() - attackerID := uuid.New() - victimID := uuid.New() - clientConn, _ := newRedirectTestTailnetConn(t, derpMap, clientID, "client") - attackerConn, attackerIP := newRedirectTestTailnetConn(t, derpMap, attackerID, "attacker") - victimConn, victimIP := newRedirectTestTailnetConn(t, derpMap, victimID, "victim") - coordinateRedirectTestTailnet(t, map[uuid.UUID]*tailnet.Conn{ - clientID: clientConn, - attackerID: attackerConn, - victimID: victimConn, - }) - - var victimHit atomic.Bool - victimRouter := http.NewServeMux() - victimRouter.HandleFunc("/api/v0/listening-ports", func(rw http.ResponseWriter, _ *http.Request) { - victimHit.Store(true) - rw.Header().Set("Content-Type", "application/json") - _, _ = rw.Write([]byte(`{"ports":[]}`)) - }) - victimRouter.HandleFunc("/api/v0/write-file", func(rw http.ResponseWriter, _ *http.Request) { - victimHit.Store(true) - rw.WriteHeader(http.StatusOK) - }) - serveRedirectTestTailnetHTTP(t, victimConn, victimRouter) - - victimBaseURL := fmt.Sprintf("http://[%s]:%d", victimIP, workspacesdk.AgentHTTPAPIServerPort) - attackerRouter := http.NewServeMux() - attackerRouter.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) { - http.Redirect(rw, r, victimBaseURL+r.URL.RequestURI(), tc.status) - }) - serveRedirectTestTailnetHTTP(t, attackerConn, attackerRouter) - - require.True(t, clientConn.AwaitReachable(ctx, attackerIP)) - require.True(t, clientConn.AwaitReachable(ctx, victimIP)) - - conn := workspacesdk.NewAgentConn(clientConn, workspacesdk.AgentConnOptions{ - AgentID: attackerID, - }) - - err := tc.invoke(ctx, conn) - require.Error(t, err) - require.False(t, victimHit.Load()) - }) - } -} - -func newRedirectTestTailnetConn(t *testing.T, derpMap *tailcfg.DERPMap, id uuid.UUID, name string) (*tailnet.Conn, netip.Addr) { - t.Helper() - - addr := tailnet.TailscaleServicePrefix.AddrFromUUID(id) - conn, err := tailnet.NewConn(&tailnet.Options{ - ID: id, - Addresses: []netip.Prefix{netip.PrefixFrom(addr, 128)}, - Logger: testutil.Logger(t).Named(name), - DERPMap: derpMap, - }) - require.NoError(t, err) - t.Cleanup(func() { - assert.NoError(t, conn.Close()) - }) - - return conn, addr -} - -func serveRedirectTestTailnetHTTP(t *testing.T, conn *tailnet.Conn, handler http.Handler) { - t.Helper() - - ln, err := conn.Listen("tcp", fmt.Sprintf(":%d", workspacesdk.AgentHTTPAPIServerPort)) - require.NoError(t, err) - - server := &http.Server{Handler: handler} - t.Cleanup(func() { - assert.NoError(t, server.Close()) - assert.NoError(t, ln.Close()) - }) - - go func() { - err := server.Serve(ln) - if err != nil && !errors.Is(err, net.ErrClosed) && !errors.Is(err, http.ErrServerClosed) { - assert.NoError(t, err) - } - }() -} - -func coordinateRedirectTestTailnet(t *testing.T, conns map[uuid.UUID]*tailnet.Conn) { - t.Helper() - - sendNode := func(srcID uuid.UUID, node *tailnet.Node) { - protoNode, err := tailnet.NodeToProto(node) - if !assert.NoError(t, err) { - return - } - for dstID, dst := range conns { - if dstID == srcID { - continue - } - err = dst.UpdatePeers([]*proto.CoordinateResponse_PeerUpdate{{ - Id: srcID[:], - Node: protoNode, - Kind: proto.CoordinateResponse_PeerUpdate_NODE, - }}) - assert.NoError(t, err) - } - } - - for srcID, src := range conns { - src.SetNodeCallback(func(node *tailnet.Node) { - sendNode(srcID, node) - }) - if node := src.Node(); node != nil { - sendNode(srcID, node) - } - } - - t.Cleanup(func() { - for _, conn := range conns { - conn.SetNodeCallback(nil) - } - }) -} diff --git a/codersdk/workspacesdk/agentconn_test.go b/codersdk/workspacesdk/agentconn_test.go index 9a5e3a93bb7..2c5d9ed73b3 100644 --- a/codersdk/workspacesdk/agentconn_test.go +++ b/codersdk/workspacesdk/agentconn_test.go @@ -2,15 +2,25 @@ package workspacesdk_test import ( "context" + "errors" + "fmt" + "net" + "net/http" "net/netip" + "strings" + "sync/atomic" "testing" "github.com/google/uuid" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/goleak" + "tailscale.com/tailcfg" "github.com/coder/coder/v2/codersdk/workspacesdk" "github.com/coder/coder/v2/tailnet" + "github.com/coder/coder/v2/tailnet/proto" + "github.com/coder/coder/v2/tailnet/tailnettest" "github.com/coder/coder/v2/testutil" ) @@ -64,3 +74,162 @@ func TestAgentConn_DialBoundedByRequestContext(t *testing.T) { goleak.VerifyNone(t, ignoreCurrent) } + +func TestAgentConnRejectsCrossAgentRedirects(t *testing.T) { + t.Parallel() + + derpMap, _ := tailnettest.RunDERPAndSTUN(t) + cases := []struct { + name string + status int + invoke func(context.Context, workspacesdk.AgentConn) error + }{ + { + name: "get 302", + status: http.StatusFound, + invoke: func(ctx context.Context, conn workspacesdk.AgentConn) error { + _, err := conn.ListeningPorts(ctx) + return err + }, + }, + { + name: "post 307", + status: http.StatusTemporaryRedirect, + invoke: func(ctx context.Context, conn workspacesdk.AgentConn) error { + return conn.WriteFile(ctx, "/tmp/attacker", strings.NewReader("redirect-body")) + }, + }, + { + name: "post 308", + status: http.StatusPermanentRedirect, + invoke: func(ctx context.Context, conn workspacesdk.AgentConn) error { + return conn.WriteFile(ctx, "/tmp/attacker", strings.NewReader("redirect-body")) + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium) + t.Cleanup(cancel) + + clientID := uuid.New() + attackerID := uuid.New() + victimID := uuid.New() + clientConn, _ := newRedirectTestTailnetConn(t, derpMap, clientID, "client") + attackerConn, attackerIP := newRedirectTestTailnetConn(t, derpMap, attackerID, "attacker") + victimConn, victimIP := newRedirectTestTailnetConn(t, derpMap, victimID, "victim") + coordinateRedirectTestTailnet(t, map[uuid.UUID]*tailnet.Conn{ + clientID: clientConn, + attackerID: attackerConn, + victimID: victimConn, + }) + + var victimHit atomic.Bool + victimRouter := http.NewServeMux() + victimRouter.HandleFunc("/api/v0/listening-ports", func(rw http.ResponseWriter, _ *http.Request) { + victimHit.Store(true) + rw.Header().Set("Content-Type", "application/json") + _, _ = rw.Write([]byte(`{"ports":[]}`)) + }) + victimRouter.HandleFunc("/api/v0/write-file", func(rw http.ResponseWriter, _ *http.Request) { + victimHit.Store(true) + rw.WriteHeader(http.StatusOK) + }) + serveRedirectTestTailnetHTTP(t, victimConn, victimRouter) + + victimBaseURL := fmt.Sprintf("http://[%s]:%d", victimIP, workspacesdk.AgentHTTPAPIServerPort) + attackerRouter := http.NewServeMux() + attackerRouter.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) { + http.Redirect(rw, r, victimBaseURL+r.URL.RequestURI(), tc.status) + }) + serveRedirectTestTailnetHTTP(t, attackerConn, attackerRouter) + + require.True(t, clientConn.AwaitReachable(ctx, attackerIP)) + require.True(t, clientConn.AwaitReachable(ctx, victimIP)) + + conn := workspacesdk.NewAgentConn(clientConn, workspacesdk.AgentConnOptions{ + AgentID: attackerID, + }) + + err := tc.invoke(ctx, conn) + require.Error(t, err) + require.False(t, victimHit.Load()) + }) + } +} + +func newRedirectTestTailnetConn(t *testing.T, derpMap *tailcfg.DERPMap, id uuid.UUID, name string) (*tailnet.Conn, netip.Addr) { + t.Helper() + + addr := tailnet.TailscaleServicePrefix.AddrFromUUID(id) + conn, err := tailnet.NewConn(&tailnet.Options{ + ID: id, + Addresses: []netip.Prefix{netip.PrefixFrom(addr, 128)}, + Logger: testutil.Logger(t).Named(name), + DERPMap: derpMap, + }) + require.NoError(t, err) + t.Cleanup(func() { + assert.NoError(t, conn.Close()) + }) + + return conn, addr +} + +func serveRedirectTestTailnetHTTP(t *testing.T, conn *tailnet.Conn, handler http.Handler) { + t.Helper() + + ln, err := conn.Listen("tcp", fmt.Sprintf(":%d", workspacesdk.AgentHTTPAPIServerPort)) + require.NoError(t, err) + + server := &http.Server{Handler: handler} + t.Cleanup(func() { + assert.NoError(t, server.Close()) + assert.NoError(t, ln.Close()) + }) + + go func() { + err := server.Serve(ln) + if err != nil && !errors.Is(err, net.ErrClosed) && !errors.Is(err, http.ErrServerClosed) { + assert.NoError(t, err) + } + }() +} + +func coordinateRedirectTestTailnet(t *testing.T, conns map[uuid.UUID]*tailnet.Conn) { + t.Helper() + + sendNode := func(srcID uuid.UUID, node *tailnet.Node) { + protoNode, err := tailnet.NodeToProto(node) + if !assert.NoError(t, err) { + return + } + for dstID, dst := range conns { + if dstID == srcID { + continue + } + err = dst.UpdatePeers([]*proto.CoordinateResponse_PeerUpdate{{ + Id: srcID[:], + Node: protoNode, + Kind: proto.CoordinateResponse_PeerUpdate_NODE, + }}) + assert.NoError(t, err) + } + } + + for srcID, src := range conns { + src.SetNodeCallback(func(node *tailnet.Node) { + sendNode(srcID, node) + }) + if node := src.Node(); node != nil { + sendNode(srcID, node) + } + } + + t.Cleanup(func() { + for _, conn := range conns { + conn.SetNodeCallback(nil) + } + }) +} From 032cb84d7366188b306b494c8d8215485e7ae1e8 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 23 Jun 2026 04:27:22 +0000 Subject: [PATCH 3/6] test: address lint and align redirect tests with repo idioms --- coderd/aitasks_internal_test.go | 4 +++- codersdk/workspacesdk/agentconn_test.go | 28 ++++++++++++++----------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/coderd/aitasks_internal_test.go b/coderd/aitasks_internal_test.go index 08319c95f5e..706f4d25b68 100644 --- a/coderd/aitasks_internal_test.go +++ b/coderd/aitasks_internal_test.go @@ -254,7 +254,9 @@ func TestTaskAppHTTPClient_RejectsRedirect(t *testing.T) { return (&net.Dialer{}).DialContext(ctx, network, addr) }) - resp, err := client.Get(app.URL) + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, app.URL, nil) + require.NoError(t, err) + resp, err := client.Do(req) require.NoError(t, err) defer resp.Body.Close() diff --git a/codersdk/workspacesdk/agentconn_test.go b/codersdk/workspacesdk/agentconn_test.go index 2c5d9ed73b3..acbf0a0d2a1 100644 --- a/codersdk/workspacesdk/agentconn_test.go +++ b/codersdk/workspacesdk/agentconn_test.go @@ -110,16 +110,17 @@ func TestAgentConnRejectsCrossAgentRedirects(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium) - t.Cleanup(cancel) + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitMedium) clientID := uuid.New() attackerID := uuid.New() victimID := uuid.New() - clientConn, _ := newRedirectTestTailnetConn(t, derpMap, clientID, "client") - attackerConn, attackerIP := newRedirectTestTailnetConn(t, derpMap, attackerID, "attacker") - victimConn, victimIP := newRedirectTestTailnetConn(t, derpMap, victimID, "victim") - coordinateRedirectTestTailnet(t, map[uuid.UUID]*tailnet.Conn{ + clientConn, _ := newTailnetConn(t, derpMap, clientID, "client") + attackerConn, attackerIP := newTailnetConn(t, derpMap, attackerID, "attacker") + victimConn, victimIP := newTailnetConn(t, derpMap, victimID, "victim") + stitchTailnet(t, map[uuid.UUID]*tailnet.Conn{ clientID: clientConn, attackerID: attackerConn, victimID: victimConn, @@ -136,14 +137,14 @@ func TestAgentConnRejectsCrossAgentRedirects(t *testing.T) { victimHit.Store(true) rw.WriteHeader(http.StatusOK) }) - serveRedirectTestTailnetHTTP(t, victimConn, victimRouter) + serveTailnetHTTP(t, victimConn, victimRouter) victimBaseURL := fmt.Sprintf("http://[%s]:%d", victimIP, workspacesdk.AgentHTTPAPIServerPort) attackerRouter := http.NewServeMux() attackerRouter.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) { http.Redirect(rw, r, victimBaseURL+r.URL.RequestURI(), tc.status) }) - serveRedirectTestTailnetHTTP(t, attackerConn, attackerRouter) + serveTailnetHTTP(t, attackerConn, attackerRouter) require.True(t, clientConn.AwaitReachable(ctx, attackerIP)) require.True(t, clientConn.AwaitReachable(ctx, victimIP)) @@ -159,7 +160,7 @@ func TestAgentConnRejectsCrossAgentRedirects(t *testing.T) { } } -func newRedirectTestTailnetConn(t *testing.T, derpMap *tailcfg.DERPMap, id uuid.UUID, name string) (*tailnet.Conn, netip.Addr) { +func newTailnetConn(t *testing.T, derpMap *tailcfg.DERPMap, id uuid.UUID, name string) (*tailnet.Conn, netip.Addr) { t.Helper() addr := tailnet.TailscaleServicePrefix.AddrFromUUID(id) @@ -177,13 +178,13 @@ func newRedirectTestTailnetConn(t *testing.T, derpMap *tailcfg.DERPMap, id uuid. return conn, addr } -func serveRedirectTestTailnetHTTP(t *testing.T, conn *tailnet.Conn, handler http.Handler) { +func serveTailnetHTTP(t *testing.T, conn *tailnet.Conn, handler http.Handler) { t.Helper() ln, err := conn.Listen("tcp", fmt.Sprintf(":%d", workspacesdk.AgentHTTPAPIServerPort)) require.NoError(t, err) - server := &http.Server{Handler: handler} + server := &http.Server{Handler: handler, ReadHeaderTimeout: testutil.WaitShort} t.Cleanup(func() { assert.NoError(t, server.Close()) assert.NoError(t, ln.Close()) @@ -197,7 +198,10 @@ func serveRedirectTestTailnetHTTP(t *testing.T, conn *tailnet.Conn, handler http }() } -func coordinateRedirectTestTailnet(t *testing.T, conns map[uuid.UUID]*tailnet.Conn) { +// stitchTailnet cross-programs every conn's node into every other conn, the +// N-peer analog of tailnet's stitch test helper, so the peers can reach each +// other without a coordinator. +func stitchTailnet(t *testing.T, conns map[uuid.UUID]*tailnet.Conn) { t.Helper() sendNode := func(srcID uuid.UUID, node *tailnet.Node) { From b9fe68bcc13590ddaf0e6b52d841c5f7d62003be Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 23 Jun 2026 08:28:52 +0000 Subject: [PATCH 4/6] chore: log blocked cross-agent workspace agent API requests Plumb an optional logger into AgentConnOptions and emit a warning when the pinned-dial guard blocks a request whose host does not match the intended agent. This surfaces attempted cross-agent retargeting (e.g. via a malicious redirect) to admins. The zero-value logger is a no-op, so existing callers are unaffected. --- coderd/tailnet.go | 1 + codersdk/workspacesdk/agentconn.go | 6 ++++++ codersdk/workspacesdk/workspacesdk.go | 1 + 3 files changed, 8 insertions(+) diff --git a/coderd/tailnet.go b/coderd/tailnet.go index 4d73c89fd11..ec061f015b6 100644 --- a/coderd/tailnet.go +++ b/coderd/tailnet.go @@ -298,6 +298,7 @@ func (s *ServerTailnet) AgentConn(ctx context.Context, agentID uuid.UUID) (works conn = workspacesdk.NewAgentConn(s.conn, workspacesdk.AgentConnOptions{ AgentID: agentID, CloseFunc: func() error { return workspacesdk.ErrSkipClose }, + Logger: s.logger, }) // Since we now have an open conn, be careful to close it if we error diff --git a/codersdk/workspacesdk/agentconn.go b/codersdk/workspacesdk/agentconn.go index 7ff85655e4d..9fd71c5ba9e 100644 --- a/codersdk/workspacesdk/agentconn.go +++ b/codersdk/workspacesdk/agentconn.go @@ -158,6 +158,7 @@ func (c *agentConn) SetExtraHeaders(h http.Header) { type AgentConnOptions struct { AgentID uuid.UUID CloseFunc func() error + Logger slog.Logger } func (c *agentConn) agentAddress() netip.Addr { @@ -1419,6 +1420,11 @@ func (c *agentConn) apiClient(reqCtx context.Context) *http.Client { return nil, xerrors.Errorf("request %q does not appear to be for http api", addr) } if reqAddr, err := netip.ParseAddr(host); err != nil || reqAddr != agentAddr.Addr() { + c.opts.Logger.Warn(ctx, "blocked workspace agent API request to unintended host", + slog.F("agent_id", c.opts.AgentID), + slog.F("request_host", host), + slog.F("intended_agent_addr", agentAddr.Addr()), + ) return nil, xerrors.Errorf("request host %q does not match intended agent %q", host, agentAddr.Addr()) } diff --git a/codersdk/workspacesdk/workspacesdk.go b/codersdk/workspacesdk/workspacesdk.go index 67eab8b4bcb..f0311c7973f 100644 --- a/codersdk/workspacesdk/workspacesdk.go +++ b/codersdk/workspacesdk/workspacesdk.go @@ -300,6 +300,7 @@ func (c *Client) DialAgent(dialCtx context.Context, agentID uuid.UUID, options * <-controller.Closed() return conn.Close() }, + Logger: options.Logger, }) if !agentConn.AwaitReachable(dialCtx) { From 3fa9a04562bbad5dc902da610de43f8aca64c61c Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 23 Jun 2026 08:43:27 +0000 Subject: [PATCH 5/6] chore: trim adversarial comments on redirect handling --- coderd/aitasks.go | 7 +------ coderd/aitasks_internal_test.go | 8 +++----- codersdk/workspacesdk/agentconn.go | 12 +----------- 3 files changed, 5 insertions(+), 22 deletions(-) diff --git a/coderd/aitasks.go b/coderd/aitasks.go index 75dd2596ebc..5ce4869596b 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -1093,12 +1093,7 @@ func (api *API) authAndDoWithTaskAppClient( // taskAppHTTPClient builds the HTTP client used to reach a workspace's task app // over its agent connection, dialing via the provided dial function. // -// The client never follows redirects. The task app endpoint is served by an -// untrusted workspace agent, and dial pins every connection to this workspace's -// own agent (it takes only the port from the dial address), so a redirect can -// never reach a different agent. Redirects are refused anyway so a malicious -// agent cannot bounce the request to a different port on the same agent via a -// 3xx Location header. +// Redirects are blocked to prevent misuse. func taskAppHTTPClient(dial func(ctx context.Context, network, addr string) (net.Conn, error)) *http.Client { return &http.Client{ CheckRedirect: func(*http.Request, []*http.Request) error { diff --git a/coderd/aitasks_internal_test.go b/coderd/aitasks_internal_test.go index 706f4d25b68..985e1d4b788 100644 --- a/coderd/aitasks_internal_test.go +++ b/coderd/aitasks_internal_test.go @@ -227,14 +227,12 @@ func TestDeriveTaskCurrentState_Unit(t *testing.T) { } } -// TestTaskAppHTTPClient_RejectsRedirect verifies the client built for dialing a -// workspace task app never follows redirects. A malicious app must not be able -// to bounce coderd's request to a different address via a 3xx Location header. +// TestTaskAppHTTPClient_RejectsRedirect verifies the task app client does not +// follow redirects. func TestTaskAppHTTPClient_RejectsRedirect(t *testing.T) { t.Parallel() - // victim stands in for a different address (e.g. a different port) that a - // followed redirect would reach. It must never be contacted. + // victim is the redirect target; it must never be contacted. var victimHits atomic.Int64 victim := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { victimHits.Add(1) diff --git a/codersdk/workspacesdk/agentconn.go b/codersdk/workspacesdk/agentconn.go index 9fd71c5ba9e..16e44202467 100644 --- a/codersdk/workspacesdk/agentconn.go +++ b/codersdk/workspacesdk/agentconn.go @@ -1387,15 +1387,9 @@ func (c *agentConn) apiRequest(ctx context.Context, method, path string, body in // scoped to a single request: its transport cancels in-flight dials // once reqCtx ends. func (c *agentConn) apiClient(reqCtx context.Context) *http.Client { - // agentAddr is the only address this client is ever allowed to dial. It is - // derived from the intended AgentID rather than any request URL, so a - // redirect (or any other attacker-controlled host) can never retarget the - // request at a different agent on the shared tailnet. agentAddr := netip.AddrPortFrom(c.agentAddress(), AgentHTTPAPIServerPort) return &http.Client{ - // Never follow redirects. A malicious agent could otherwise return a - // 3xx that bounces the request to another agent. Combined with the - // pinned dial below, this keeps every request on the intended agent. + // Redirects are blocked to prevent misuse. CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, @@ -1408,10 +1402,6 @@ func (c *agentConn) apiClient(reqCtx context.Context) *http.Client { return nil, xerrors.Errorf("network must be tcp") } - // Reject any request whose host or port is not the intended - // agent's HTTP API. apiRequest always sets the host to the - // agent address, so a mismatch here only comes from an - // attacker-controlled URL or redirect. host, port, err := net.SplitHostPort(addr) if err != nil { return nil, xerrors.Errorf("split host port %q: %w", addr, err) From 7f2adb0d0ec5cb94981853bf7bddf4f287654fa8 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 23 Jun 2026 12:41:31 +0000 Subject: [PATCH 6/6] refactor: consolidate agent app HTTP client into workspacesdk.AgentConn Add AppHTTPClient() to AgentConn. It blocks redirects and pins dials to the agent's address, taking only the port from the request URL. Replace the duplicated clients in coderd/aitasks.go (taskAppHTTPClient) and scaletest/agentconn (agentHTTPClient) with this shared method. --- coderd/aitasks.go | 18 +-------- coderd/aitasks_internal_test.go | 40 ------------------- codersdk/workspacesdk/agentconn.go | 19 +++++++++ codersdk/workspacesdk/agentconn_test.go | 25 ++++++++++++ .../agentconnmock/agentconnmock.go | 14 +++++++ scaletest/agentconn/run.go | 29 ++------------ 6 files changed, 62 insertions(+), 83 deletions(-) diff --git a/coderd/aitasks.go b/coderd/aitasks.go index 5ce4869596b..3f88e8ede2b 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -6,7 +6,6 @@ import ( "encoding/json" "errors" "fmt" - "net" "net/http" "net/url" "slices" @@ -1086,25 +1085,10 @@ func (api *API) authAndDoWithTaskAppClient( } defer release() - client := taskAppHTTPClient(agentConn.DialContext) + client := agentConn.AppHTTPClient() return do(ctx, client, parsedURL) } -// taskAppHTTPClient builds the HTTP client used to reach a workspace's task app -// over its agent connection, dialing via the provided dial function. -// -// Redirects are blocked to prevent misuse. -func taskAppHTTPClient(dial func(ctx context.Context, network, addr string) (net.Conn, error)) *http.Client { - return &http.Client{ - CheckRedirect: func(*http.Request, []*http.Request) error { - return http.ErrUseLastResponse - }, - Transport: &http.Transport{ - DialContext: dial, - }, - } -} - const ( // taskSnapshotMaxSize is the maximum size for task log snapshots (64KB). // Protects against excessive memory usage and database payload sizes. diff --git a/coderd/aitasks_internal_test.go b/coderd/aitasks_internal_test.go index 985e1d4b788..0c087c653be 100644 --- a/coderd/aitasks_internal_test.go +++ b/coderd/aitasks_internal_test.go @@ -1,11 +1,6 @@ package coderd import ( - "context" - "net" - "net/http" - "net/http/httptest" - "sync/atomic" "testing" "time" @@ -226,38 +221,3 @@ func TestDeriveTaskCurrentState_Unit(t *testing.T) { }) } } - -// TestTaskAppHTTPClient_RejectsRedirect verifies the task app client does not -// follow redirects. -func TestTaskAppHTTPClient_RejectsRedirect(t *testing.T) { - t.Parallel() - - // victim is the redirect target; it must never be contacted. - var victimHits atomic.Int64 - victim := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - victimHits.Add(1) - w.WriteHeader(http.StatusOK) - })) - defer victim.Close() - - // The task app redirects every request to the victim address. - app := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, victim.URL+r.URL.Path, http.StatusTemporaryRedirect) - })) - defer app.Close() - - // The dial honors addr like the real agent transport, so following the - // redirect would actually reach the victim. - client := taskAppHTTPClient(func(ctx context.Context, network, addr string) (net.Conn, error) { - return (&net.Dialer{}).DialContext(ctx, network, addr) - }) - - req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, app.URL, nil) - require.NoError(t, err) - resp, err := client.Do(req) - require.NoError(t, err) - defer resp.Body.Close() - - require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode, "redirect must be surfaced, not followed") - require.Zero(t, victimHits.Load(), "redirect target must not be contacted") -} diff --git a/codersdk/workspacesdk/agentconn.go b/codersdk/workspacesdk/agentconn.go index 16e44202467..ab5f6a03d8a 100644 --- a/codersdk/workspacesdk/agentconn.go +++ b/codersdk/workspacesdk/agentconn.go @@ -102,6 +102,7 @@ type AgentConn interface { DebugMagicsock(ctx context.Context) ([]byte, error) DebugManifest(ctx context.Context) ([]byte, error) DialContext(ctx context.Context, network string, addr string) (net.Conn, error) + AppHTTPClient() *http.Client GetPeerDiagnostics() tailnet.PeerDiagnostics ListContainers(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) ListMCPTools(ctx context.Context) (ListMCPToolsResponse, error) @@ -371,6 +372,24 @@ func (c *agentConn) DialContext(ctx context.Context, network string, addr string } } +// AppHTTPClient returns an HTTP client for reaching HTTP apps served by this +// workspace agent. Redirects are blocked to prevent misuse. +func (c *agentConn) AppHTTPClient() *http.Client { + return &http.Client{ + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + Transport: &http.Transport{ + // Disable keep-alives so these short-lived clients don't leave + // idle connections (and their goroutines) lingering after they're + // discarded. + DisableKeepAlives: true, + // Host locked to agent, port from URL. + DialContext: c.DialContext, + }, + } +} + // ListeningPorts lists the ports that are currently in use by the workspace. func (c *agentConn) ListeningPorts(ctx context.Context) (codersdk.WorkspaceAgentListeningPortsResponse, error) { ctx, span := tracing.StartSpan(ctx) diff --git a/codersdk/workspacesdk/agentconn_test.go b/codersdk/workspacesdk/agentconn_test.go index acbf0a0d2a1..c77cbae3ce9 100644 --- a/codersdk/workspacesdk/agentconn_test.go +++ b/codersdk/workspacesdk/agentconn_test.go @@ -160,6 +160,31 @@ func TestAgentConnRejectsCrossAgentRedirects(t *testing.T) { } } +// TestAgentConnAppHTTPClientRefusesRedirects verifies the app HTTP client does +// not follow redirects. +func TestAgentConnAppHTTPClientRefusesRedirects(t *testing.T) { + t.Parallel() + + tailnetConn, err := tailnet.NewConn(&tailnet.Options{ + Addresses: []netip.Prefix{tailnet.TailscaleServicePrefix.RandomPrefix()}, + Logger: testutil.Logger(t), + }) + require.NoError(t, err) + t.Cleanup(func() { + _ = tailnetConn.Close() + }) + + conn := workspacesdk.NewAgentConn(tailnetConn, workspacesdk.AgentConnOptions{ + AgentID: uuid.New(), + }) + + client := conn.AppHTTPClient() + require.NotNil(t, client.CheckRedirect) + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://example.invalid/", nil) + require.NoError(t, err) + require.ErrorIs(t, client.CheckRedirect(req, nil), http.ErrUseLastResponse) +} + func newTailnetConn(t *testing.T, derpMap *tailcfg.DERPMap, id uuid.UUID, name string) (*tailnet.Conn, netip.Addr) { t.Helper() diff --git a/codersdk/workspacesdk/agentconnmock/agentconnmock.go b/codersdk/workspacesdk/agentconnmock/agentconnmock.go index 7e91f2e681a..4da420acdfc 100644 --- a/codersdk/workspacesdk/agentconnmock/agentconnmock.go +++ b/codersdk/workspacesdk/agentconnmock/agentconnmock.go @@ -56,6 +56,20 @@ func (m *MockAgentConn) EXPECT() *MockAgentConnMockRecorder { return m.recorder } +// AppHTTPClient mocks base method. +func (m *MockAgentConn) AppHTTPClient() *http.Client { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AppHTTPClient") + ret0, _ := ret[0].(*http.Client) + return ret0 +} + +// AppHTTPClient indicates an expected call of AppHTTPClient. +func (mr *MockAgentConnMockRecorder) AppHTTPClient() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AppHTTPClient", reflect.TypeOf((*MockAgentConn)(nil).AppHTTPClient)) +} + // AwaitReachable mocks base method. func (m *MockAgentConn) AwaitReachable(ctx context.Context) bool { m.ctrl.T.Helper() diff --git a/scaletest/agentconn/run.go b/scaletest/agentconn/run.go index 4a4587e478d..f26db4355a6 100644 --- a/scaletest/agentconn/run.go +++ b/scaletest/agentconn/run.go @@ -214,7 +214,7 @@ func verifyConnection(ctx context.Context, logs io.Writer, conn workspacesdk.Age ctx, span := tracing.StartSpan(ctx) defer span.End() - client := agentHTTPClient(conn) + client := conn.AppHTTPClient() for i := 0; i < verifyConnectionAttempts; i++ { _, _ = fmt.Fprintf(logs, "\tVerify connection attempt %d/%d...\n", i+1, verifyConnectionAttempts) verifyCtx, cancel := context.WithTimeout(ctx, defaultRequestTimeout) @@ -258,7 +258,7 @@ func performInitialConnections(ctx context.Context, logs io.Writer, conn workspa defer span.End() _, _ = fmt.Fprintln(logs, "Performing initial service connections...") - client := agentHTTPClient(conn) + client := conn.AppHTTPClient() for i, connSpec := range specs { _, _ = fmt.Fprintf(logs, "\t%d. %s\n", i, connSpec.URL) @@ -292,7 +292,7 @@ func holdConnection(ctx context.Context, logs io.Writer, conn workspacesdk.Agent defer span.End() eg, egCtx := errgroup.WithContext(ctx) - client := agentHTTPClient(conn) + client := conn.AppHTTPClient() if len(specs) > 0 { _, _ = fmt.Fprintln(logs, "\nStarting connection loops...") } @@ -362,26 +362,3 @@ func holdConnection(ctx context.Context, logs io.Writer, conn workspacesdk.Agent return nil } - -func agentHTTPClient(conn workspacesdk.AgentConn) *http.Client { - return &http.Client{ - Transport: &http.Transport{ - DisableKeepAlives: true, - DialContext: func(ctx context.Context, _ string, addr string) (net.Conn, error) { - _, port, err := net.SplitHostPort(addr) - if err != nil { - return nil, xerrors.Errorf("split host port %q: %w", addr, err) - } - - portUint, err := strconv.ParseUint(port, 10, 16) - if err != nil { - return nil, xerrors.Errorf("parse port %q: %w", port, err) - } - - // Addr doesn't matter here, besides the port. DialContext will - // automatically choose the right IP to dial. - return conn.DialContext(ctx, "tcp", fmt.Sprintf("127.0.0.1:%d", portUint)) - }, - }, - } -}