From 04c3911c0e0972bc3b976c8e8bd8b3fd37413330 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Wed, 15 Apr 2026 23:38:07 -0700 Subject: [PATCH] fix(security): forward Authorization header in transcript proxy (#405) (#380) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The platform's GET /workspaces/:id/transcript proxy was constructing the outbound request without an Authorization header. The workspace's /transcript endpoint (hardened in #287/#328) fails-closed when the header is absent, so every transcript call in production returned 401 from the workspace. Fix: after WorkspaceAuth validates the incoming bearer token, the handler now forwards it verbatim via req.Header.Set("Authorization", ...). Forwarding is safe — the token has already been validated by the middleware. Tests: - TestTranscript_ForwardsAuthHeader: was t.Skip'd as a bug marker; now active. Verifies the Authorization header reaches the workspace stub. - TestTranscript_NoAuthHeader_PassesThrough: new. Verifies that a missing header produces no synthetic Authorization on the upstream call, and the workspace 401 is faithfully relayed. Identified by QA audit 2026-04-16. Co-authored-by: QA Engineer Co-authored-by: Claude Sonnet 4.6 --- platform/internal/handlers/transcript.go | 9 ++ platform/internal/handlers/transcript_test.go | 101 ++++++++++++++++++ 2 files changed, 110 insertions(+) diff --git a/platform/internal/handlers/transcript.go b/platform/internal/handlers/transcript.go index c07426e2..220781b6 100644 --- a/platform/internal/handlers/transcript.go +++ b/platform/internal/handlers/transcript.go @@ -93,6 +93,15 @@ func (h *TranscriptHandler) Get(c *gin.Context) { return } + // Forward the caller's bearer token so the workspace /transcript handler + // (secured by #287/#328) can authenticate the proxied request. + // WorkspaceAuth has already validated the token above — forwarding is safe. + // Without this the workspace fails-closed (401) and the transcript feature + // is non-functional in production. Fixes the QA-2026-04-16 finding. + if auth := c.GetHeader("Authorization"); auth != "" { + req.Header.Set("Authorization", auth) + } + resp, err := h.httpClient.Do(req) if err != nil { // Log the real error server-side (includes the target URL), but diff --git a/platform/internal/handlers/transcript_test.go b/platform/internal/handlers/transcript_test.go index f88bb812..5407893b 100644 --- a/platform/internal/handlers/transcript_test.go +++ b/platform/internal/handlers/transcript_test.go @@ -241,3 +241,104 @@ func TestTranscript_UnreachableWorkspaceReturns502(t *testing.T) { t.Errorf("expected 502, got %d: %s", w.Code, w.Body.String()) } } + +// TestTranscript_ForwardsAuthHeader is a regression guard for the fix where +// TranscriptHandler.Get was not forwarding the Authorization header to the +// workspace's /transcript endpoint (QA finding 2026-04-16). +// +// The workspace's /transcript endpoint (secured by #287/#328) requires a valid +// `Authorization: Bearer ` header — it fails-closed when the header +// is absent. The platform's WorkspaceAuth middleware validates the token before +// the handler runs; forwarding it to the workspace is correct and safe. +// +// Fix applied: after constructing the outbound request, the handler now calls +// req.Header.Set("Authorization", c.GetHeader("Authorization")) +// This test verifies the fix and acts as a regression guard. +func TestTranscript_ForwardsAuthHeader(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + h := NewTranscriptHandler() + + const testToken = "Bearer test-workspace-token-abc123" + + var receivedAuth string + stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedAuth = r.Header.Get("Authorization") + // Simulate the workspace's #328 fail-closed behaviour: reject missing auth. + if receivedAuth == "" { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"runtime":"claude-code","supported":true,"lines":[],"cursor":0,"more":false}`)) + })) + defer stub.Close() + + wsID := expectWorkspaceURLLookup(mock, stub.URL) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: wsID}} + // Simulate a request that has already passed WorkspaceAuth middleware — + // the bearer token is present and valid on the incoming request. + req := httptest.NewRequest("GET", "/workspaces/"+wsID+"/transcript", nil) + req.Header.Set("Authorization", testToken) + c.Request = req + h.Get(c) + + // The proxy must forward the bearer token so the workspace accepts the call. + if receivedAuth == "" { + t.Error("TranscriptHandler did not forward Authorization header — workspace would return 401") + } + if receivedAuth != testToken { + t.Errorf("Authorization header mismatch: forwarded %q, want %q", receivedAuth, testToken) + } + if w.Code == http.StatusUnauthorized { + t.Errorf("workspace returned 401: transcript proxy did not authenticate; auth forwarded: %q", receivedAuth) + } + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String()) + } +} + +// TestTranscript_NoAuthHeader_PassesThrough verifies that a request with no +// Authorization header (e.g. unauthenticated local-dev call that somehow +// bypassed WorkspaceAuth) results in no Authorization header on the upstream +// request. The workspace will return 401 in this case, which the proxy +// faithfully relays — no silent upgrade of privilege. +func TestTranscript_NoAuthHeader_PassesThrough(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + h := NewTranscriptHandler() + + var receivedAuth string + stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedAuth = r.Header.Get("Authorization") + if receivedAuth == "" { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"runtime":"claude-code","supported":true,"lines":[]}`)) + })) + defer stub.Close() + + wsID := expectWorkspaceURLLookup(mock, stub.URL) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: wsID}} + // No Authorization header on the request. + c.Request = httptest.NewRequest("GET", "/workspaces/"+wsID+"/transcript", nil) + h.Get(c) + + // Without a token the workspace returns 401; the proxy must relay it faithfully. + if receivedAuth != "" { + t.Errorf("expected no Authorization forwarded to workspace, got %q", receivedAuth) + } + if w.Code != http.StatusUnauthorized { + t.Errorf("expected proxy to relay workspace 401, got %d: %s", w.Code, w.Body.String()) + } +}