From 552f342a5b345ae39c18678b754835db85f6ab0b Mon Sep 17 00:00:00 2001 From: Ethan <39577870+ethanndickson@users.noreply.github.com> Date: Mon, 2 Mar 2026 19:32:36 +1100 Subject: [PATCH] fix(codersdk): use header auth for non-browser websocket dials (#22461) ## Context This commit is part of the fix for a downstream provider outage observed during `coderd_template` updates. Observed downstream symptoms (terraform-provider-coderd): - Template-version websocket log stream requests returned `401`: `GET /api/v2/templateversions//logs`. - In older provider code (`waitForJob`), stream-init errors could produce `(nil, nil, err)` and then trigger a nil dereference when `closer.Close()` was deferred before checking `err`. - Net effect: template update path crashed instead of returning a controlled provisioning error. That provider panic is being hardened in the provider repo separately (https://github.com/coder/terraform-provider-coderd/pull/308). This commit addresses the upstream SDK auth mismatch that caused the websocket `401` side of the chain. ## Root cause On deployments with host-prefixed cookie handling (dev.coder.com) enabled (`--host-prefix-cookie` / `EnableHostPrefix=true`), middleware rewrites cookie state to enforce prefixed auth cookies. For non-browser websocket clients that still sent unprefixed `coder_session_token` via cookie jars, this created an auth mismatch: - cookie-based credential expected by the client path, - but cookie normalization/stripping applied server-side, - resulting in no usable token at auth extraction time. ## Fix in this commit Apply the #22226 non-browser auth principle to remaining websocket callsites in `codersdk` by replacing cookie-jar session auth with header-token auth. _Generated with mux but reviewed by a human_ --- codersdk/provisionerdaemons.go | 27 ++++++------------------ codersdk/workspaceagents.go | 29 +++++++------------------- codersdk/workspacesdk/workspacesdk.go | 30 ++++++++++++--------------- 3 files changed, 26 insertions(+), 60 deletions(-) diff --git a/codersdk/provisionerdaemons.go b/codersdk/provisionerdaemons.go index be51efe013..dde6ec7dea 100644 --- a/codersdk/provisionerdaemons.go +++ b/codersdk/provisionerdaemons.go @@ -6,7 +6,6 @@ import ( "fmt" "io" "net/http" - "net/http/cookiejar" "slices" "strings" "time" @@ -239,20 +238,14 @@ func (c *Client) provisionerJobLogsAfter(ctx context.Context, path string, after if err != nil { return nil, nil, err } - jar, err := cookiejar.New(nil) - if err != nil { - return nil, nil, xerrors.Errorf("create cookie jar: %w", err) - } - jar.SetCookies(followURL, []*http.Cookie{{ - Name: SessionTokenCookie, - Value: c.SessionToken(), - }}) httpClient := &http.Client{ - Jar: jar, Transport: c.HTTPClient.Transport, } conn, res, err := websocket.Dial(ctx, followURL.String(), &websocket.DialOptions{ - HTTPClient: httpClient, + HTTPClient: httpClient, + HTTPHeader: http.Header{ + SessionTokenHeader: []string{c.SessionToken()}, + }, CompressionMode: websocket.CompressionDisabled, }) if err != nil { @@ -325,16 +318,8 @@ func (c *Client) ServeProvisionerDaemon(ctx context.Context, req ServeProvisione headers.Set(ProvisionerDaemonPSK, req.PreSharedKey) } if req.ProvisionerKey == "" && req.PreSharedKey == "" { - // use session token if we don't have a PSK or provisioner key. - jar, err := cookiejar.New(nil) - if err != nil { - return nil, xerrors.Errorf("create cookie jar: %w", err) - } - jar.SetCookies(serverURL, []*http.Cookie{{ - Name: SessionTokenCookie, - Value: c.SessionToken(), - }}) - httpClient.Jar = jar + // Use session token if we don't have a PSK or provisioner key. + headers.Set(SessionTokenHeader, c.SessionToken()) } conn, res, err := websocket.Dial(ctx, serverURL.String(), &websocket.DialOptions{ diff --git a/codersdk/workspaceagents.go b/codersdk/workspaceagents.go index 3e2fafa7c4..94a5a0a9d8 100644 --- a/codersdk/workspaceagents.go +++ b/codersdk/workspaceagents.go @@ -6,7 +6,6 @@ import ( "fmt" "io" "net/http" - "net/http/cookiejar" "strings" "time" @@ -580,24 +579,16 @@ func (c *Client) WatchWorkspaceAgentContainers(ctx context.Context, agentID uuid return nil, nil, err } - jar, err := cookiejar.New(nil) - if err != nil { - return nil, nil, xerrors.Errorf("create cookie jar: %w", err) - } - - jar.SetCookies(reqURL, []*http.Cookie{{ - Name: SessionTokenCookie, - Value: c.SessionToken(), - }}) - conn, res, err := websocket.Dial(ctx, reqURL.String(), &websocket.DialOptions{ // We want `NoContextTakeover` compression to balance improving // bandwidth cost/latency with minimal memory usage overhead. CompressionMode: websocket.CompressionNoContextTakeover, HTTPClient: &http.Client{ - Jar: jar, Transport: c.HTTPClient.Transport, }, + HTTPHeader: http.Header{ + SessionTokenHeader: []string{c.SessionToken()}, + }, }) if err != nil { if res == nil { @@ -687,20 +678,14 @@ func (c *Client) WorkspaceAgentLogsAfter(ctx context.Context, agentID uuid.UUID, return ch, closeFunc(func() error { return nil }), nil } - jar, err := cookiejar.New(nil) - if err != nil { - return nil, nil, xerrors.Errorf("create cookie jar: %w", err) - } - jar.SetCookies(reqURL, []*http.Cookie{{ - Name: SessionTokenCookie, - Value: c.SessionToken(), - }}) httpClient := &http.Client{ - Jar: jar, Transport: c.HTTPClient.Transport, } conn, res, err := websocket.Dial(ctx, reqURL.String(), &websocket.DialOptions{ - HTTPClient: httpClient, + HTTPClient: httpClient, + HTTPHeader: http.Header{ + SessionTokenHeader: []string{c.SessionToken()}, + }, CompressionMode: websocket.CompressionDisabled, }) if err != nil { diff --git a/codersdk/workspacesdk/workspacesdk.go b/codersdk/workspacesdk/workspacesdk.go index 1d383257c8..018759f25b 100644 --- a/codersdk/workspacesdk/workspacesdk.go +++ b/codersdk/workspacesdk/workspacesdk.go @@ -6,7 +6,6 @@ import ( "fmt" "net" "net/http" - "net/http/cookiejar" "net/netip" "os" "strconv" @@ -363,26 +362,23 @@ func (c *Client) AgentReconnectingPTY(ctx context.Context, opts WorkspaceAgentRe } serverURL.RawQuery = q.Encode() - // If we're not using a signed token, we need to set the session token as a - // cookie. - httpClient := c.client.HTTPClient + // Shallow-clone the HTTP client so we never inherit a caller-provided + // cookie jar. Non-browser websocket auth uses the Coder-Session-Token + // header or a signed-token query param — never cookies. A stale jar + // cookie would take precedence on the server (cookies are checked + // before headers) and cause spurious 401s. + wsHTTPClient := *c.client.HTTPClient + wsHTTPClient.Jar = nil + + headers := http.Header{} + // If we're not using a signed token, set the session token header. if opts.SignedToken == "" { - jar, err := cookiejar.New(nil) - if err != nil { - return nil, xerrors.Errorf("create cookie jar: %w", err) - } - jar.SetCookies(serverURL, []*http.Cookie{{ - Name: codersdk.SessionTokenCookie, - Value: c.client.SessionToken(), - }}) - httpClient = &http.Client{ - Jar: jar, - Transport: c.client.HTTPClient.Transport, - } + headers.Set(codersdk.SessionTokenHeader, c.client.SessionToken()) } //nolint:bodyclose conn, res, err := websocket.Dial(ctx, serverURL.String(), &websocket.DialOptions{ - HTTPClient: httpClient, + HTTPClient: &wsHTTPClient, + HTTPHeader: headers, }) if err != nil { if res == nil {