Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions internal/credentials/chatgpt.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 10 additions & 1 deletion internal/credentials/gcloud.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"net/http"
"os"
"sync"
"time"

"github.com/bbrowning/paude-proxy/internal/timeouts"
"golang.org/x/oauth2"
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
Expand Down
41 changes: 41 additions & 0 deletions internal/credentials/gcloud_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
}
5 changes: 5 additions & 0 deletions internal/credentials/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion internal/credentials/token_vending.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
Expand Down
71 changes: 71 additions & 0 deletions internal/proxy/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down Expand Up @@ -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)
}
})
}
}
25 changes: 25 additions & 0 deletions internal/proxy/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down