fix(security): forward Authorization header in transcript proxy (#405) (#380)

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 <qa-engineer@molecule-ai.internal>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-04-15 23:38:07 -07:00 committed by GitHub
parent 1ff544eba8
commit 04c3911c0e
2 changed files with 110 additions and 0 deletions

View File

@ -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

View File

@ -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 <token>` 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())
}
}