diff --git a/internal/credentials/chatgpt.go b/internal/credentials/chatgpt.go index a2a89ed..481e197 100644 --- a/internal/credentials/chatgpt.go +++ b/internal/credentials/chatgpt.go @@ -87,6 +87,7 @@ func NewChatGPTInjectorWithConfig(config ChatGPTOAuthConfig) *ChatGPTInjector { config.HTTPClient = &http.Client{ Timeout: timeouts.ResponseHeader, Transport: &http.Transport{ + Proxy: nil, // token refresh must go directly to auth.openai.com, never through HTTP_PROXY TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}, TLSHandshakeTimeout: timeouts.TLSHandshake, ResponseHeaderTimeout: timeouts.ResponseHeader, diff --git a/internal/credentials/gcloud.go b/internal/credentials/gcloud.go index dd811d8..49fca23 100644 --- a/internal/credentials/gcloud.go +++ b/internal/credentials/gcloud.go @@ -8,6 +8,7 @@ import ( "net/http" "os" "sync" + "time" "github.com/bbrowning/paude-proxy/internal/timeouts" "golang.org/x/oauth2" @@ -64,6 +65,7 @@ func (g *GCloudInjector) init() error { httpClient := &http.Client{ Timeout: timeouts.ResponseHeader, Transport: &http.Transport{ + Proxy: nil, // token refresh must go directly to Google, never through HTTP_PROXY DisableKeepAlives: true, TLSClientConfig: &tls.Config{ MinVersion: tls.VersionTLS12, @@ -108,10 +110,17 @@ func (g *GCloudInjector) Inject(req *http.Request) InjectResult { } if !token.Valid() { - log.Printf("WARN gcloud token is invalid after refresh") + log.Printf("WARN gcloud token is invalid after refresh (length=%d)", len(token.AccessToken)) return InjectFailed } + if token.AccessToken == SyntheticToken || len(token.AccessToken) < 20 { + log.Printf("ERROR gcloud token looks like a dummy/synthetic token (length=%d) — will cause upstream 401. Check if proxy's own HTTP traffic is routing through itself (HTTP_PROXY env var set on proxy container?)", len(token.AccessToken)) + return InjectFailed + } + + log.Printf("GCLOUD_TOKEN_DEBUG length=%d type=%q expiry=%s", len(token.AccessToken), token.TokenType, token.Expiry.UTC().Format(time.RFC3339)) + req.Header.Set("Authorization", "Bearer "+token.AccessToken) return InjectOK } diff --git a/internal/credentials/gcloud_test.go b/internal/credentials/gcloud_test.go index bf328ba..6c7657d 100644 --- a/internal/credentials/gcloud_test.go +++ b/internal/credentials/gcloud_test.go @@ -60,3 +60,44 @@ func TestGCloudInjectorFromJSON_NilHeader(t *testing.T) { t.Error("request with nil Header should not succeed") } } + +func TestGCloudInjector_NeverReturnsInjectAuthRequired(t *testing.T) { + tests := []struct { + name string + injector *GCloudInjector + req *http.Request + }{ + { + name: "nil request", + injector: NewGCloudInjector("/nonexistent/path"), + req: nil, + }, + { + name: "init failure from bad path", + injector: NewGCloudInjector("/nonexistent/path"), + req: &http.Request{Header: make(http.Header)}, + }, + { + name: "init failure from invalid JSON", + injector: NewGCloudInjectorFromJSON([]byte("not valid json")), + req: &http.Request{Header: make(http.Header)}, + }, + { + name: "init failure from empty JSON", + injector: NewGCloudInjectorFromJSON([]byte("{}")), + req: &http.Request{Header: make(http.Header)}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.injector.Inject(tt.req) + if result == InjectAuthRequired { + t.Error("GCloudInjector must never return InjectAuthRequired — only ChatGPTInjector should") + } + if result != InjectFailed { + t.Errorf("expected InjectFailed, got %d", result) + } + }) + } +} diff --git a/internal/credentials/store.go b/internal/credentials/store.go index efe01e8..6924437 100644 --- a/internal/credentials/store.go +++ b/internal/credentials/store.go @@ -7,6 +7,11 @@ import ( "sync" ) +// SyntheticToken is the dummy access token returned by the token vendor to +// agents. The GCloudInjector checks for this value to detect accidental +// self-proxying (token refresh routing through the proxy's own token vendor). +const SyntheticToken = "paude-proxy-managed" + // InjectResult describes the outcome of a credential injection attempt. type InjectResult int diff --git a/internal/credentials/token_vending.go b/internal/credentials/token_vending.go index 02895ac..f8099b2 100644 --- a/internal/credentials/token_vending.go +++ b/internal/credentials/token_vending.go @@ -150,7 +150,7 @@ func (tv *TokenVendor) HandleTokenExchange(req *http.Request) *http.Response { var resp *tokenResponse if tv.googleEnabled && IsTokenExchange(req) { resp = &tokenResponse{ - AccessToken: "paude-proxy-managed", + AccessToken: SyntheticToken, ExpiresIn: 3600, TokenType: "Bearer", } diff --git a/internal/proxy/integration_test.go b/internal/proxy/integration_test.go index 493534e..a264466 100644 --- a/internal/proxy/integration_test.go +++ b/internal/proxy/integration_test.go @@ -868,6 +868,9 @@ func TestIntegration_CredentialInjectionFailure_Returns502(t *testing.T) { if resp.StatusCode != http.StatusBadGateway { t.Errorf("expected 502 Bad Gateway, got %d", resp.StatusCode) } + if resp.StatusCode == http.StatusUnauthorized { + t.Errorf("credential injection failure must never produce 401 Unauthorized") + } body, _ := io.ReadAll(resp.Body) if got := string(body); got != "Proxy credential injection failed" { @@ -1070,3 +1073,71 @@ func TestIntegration_ChatGPTLoginSanitizeReject_HTTPS(t *testing.T) { t.Errorf("status = %d, want 400", resp.StatusCode) } } + +func TestIntegration_UpstreamErrorPassesThrough(t *testing.T) { + skipIntegration(t) + + ca, err := GenerateCA() + if err != nil { + t.Fatalf("generate CA: %v", err) + } + + tests := []struct { + name string + statusCode int + body string + }{ + {"429 rate limit", http.StatusTooManyRequests, `{"error":{"code":429,"message":"Rate limit exceeded"}}`}, + {"500 internal error", http.StatusInternalServerError, `{"error":{"code":500,"message":"Internal server error"}}`}, + {"503 service unavailable", http.StatusServiceUnavailable, `{"error":{"code":503,"message":"Service unavailable"}}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + upstream := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.statusCode) + _, _ = w.Write([]byte(tt.body)) + })) + defer upstream.Close() + + upstreamURL, _ := url.Parse(upstream.URL) + upstreamHostname := upstreamURL.Hostname() + + df := filter.NewDomainFilter(upstreamHostname) + + store := credentials.NewStore() + store.AddRoute(credentials.Route{ + ExactDomain: upstreamHostname, + Injector: &credentials.BearerInjector{Token: "test-secret-key"}, + }) + + upstreamCAs := upstreamCertPool(t, upstream) + upstreamCert := upstream.TLS.Certificates[0] + upstreamCA, _ := x509.ParseCertificate(upstreamCert.Certificate[0]) + + proxyAddr, cleanup := startTestProxy(t, ca, df, store, nil, upstreamCAs) + defer cleanup() + + client := httpClientViaProxy(t, proxyAddr, ca.Certificate, upstreamCA) + + resp, err := client.Get(upstream.URL + "/test") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != tt.statusCode { + t.Errorf("expected upstream status %d to pass through, got %d", tt.statusCode, resp.StatusCode) + } + if resp.StatusCode == http.StatusUnauthorized { + t.Errorf("upstream %d must never be converted to 401", tt.statusCode) + } + + body, _ := io.ReadAll(resp.Body) + if string(body) != tt.body { + t.Errorf("response body not passed through unchanged\ngot: %s\nwant: %s", body, tt.body) + } + }) + } +} diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index ccf49cd..252f66f 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -296,6 +296,10 @@ func (cf *ClientFilter) String() string { // Intentionally vague to avoid revealing why the request was blocked. const rejectMsg = "Request blocked by proxy policy" +// credInjectedFlag is stored in ctx.UserData after successful credential +// injection so the OnResponse handler can log upstream errors for those requests. +type credInjectedFlag struct{} + // Config holds proxy configuration. type Config struct { ListenAddr string @@ -482,6 +486,8 @@ func New(cfg Config) *http.Server { // Inject credentials for API requests if cfg.CredStore != nil { switch cfg.CredStore.InjectCredentials(req) { + case credentials.InjectOK: + ctx.UserData = credInjectedFlag{} case credentials.InjectAuthRequired: return req, goproxy.NewResponse(req, goproxy.ContentTypeText, @@ -505,6 +511,25 @@ func New(cfg Config) *http.Server { }, ) + // Log upstream error responses for credential-injected requests. + // This distinguishes proxy-generated errors from upstream errors. + proxy.OnResponse().DoFunc( + func(resp *http.Response, ctx *goproxy.ProxyCtx) *http.Response { + if resp != nil && ctx.UserData != nil && resp.StatusCode >= 400 { + var host, method, path string + if ctx.Req != nil { + method = ctx.Req.Method + if ctx.Req.URL != nil { + host = ctx.Req.URL.Host + path = ctx.Req.URL.Path + } + } + log.Printf("UPSTREAM_ERROR host=%s status=%d method=%s path=%s", host, resp.StatusCode, method, path) + } + return resp + }, + ) + return &http.Server{ Addr: cfg.ListenAddr, Handler: proxy,