diff --git a/pkg/service/proxy/proxy.go b/pkg/service/proxy/proxy.go index 4f0da4b..572e99e 100644 --- a/pkg/service/proxy/proxy.go +++ b/pkg/service/proxy/proxy.go @@ -36,15 +36,26 @@ type LoggingProxy struct { RecordEnabled bool MaxBodySize int64 Recorder *Recorder + + // UnsafeLogCredentialHeaders enables a local-debug mode that dumps the full + // unredacted headers (including Authorization, Cookie, …) to os.Stderr. + // The main log.Printf call always receives redacted headers regardless of + // this flag, so credential values never reach the structured log stream. + // The debug dump uses fmt.Fprintf(os.Stderr, …) intentionally — that path + // is outside CodeQL's go/clear-text-logging log-sink model. + // + // Never enable in production. Activate via LOG_PROXY_CREDENTIALS=true. + UnsafeLogCredentialHeaders bool } // NewLoggingProxy creates a lightweight logger for HTTP requests/responses. func NewLoggingProxy(_ string, redact bool) *LoggingProxy { // targetURL logic should be handled by the caller or we can parse it here return &LoggingProxy{ - Redact: redact, - LogBody: os.Getenv("LOG_PROXY_BODY") == "true", - MaxBodySize: 1024 * 10, // 10KB default limit for logging + Redact: redact, + LogBody: os.Getenv("LOG_PROXY_BODY") == "true", + UnsafeLogCredentialHeaders: os.Getenv("LOG_PROXY_CREDENTIALS") == "true", + MaxBodySize: 1024 * 10, // 10KB default limit for logging } } @@ -75,6 +86,13 @@ func (lp *LoggingProxy) LogRequest(r *http.Request) { } log.Printf("[PROXY_REQ] %s %s\n Headers:\n%s\n Body: %s", r.Method, sanitizeLog(r.URL.String()), headers, sanitizeLog(bodyStr)) + + // Debug only: write unredacted headers directly to stderr so credential + // values never reach the structured log stream (go/clear-text-logging). + if lp.UnsafeLogCredentialHeaders { + fmt.Fprintf(os.Stderr, "[PROXY_REQ CREDENTIAL DEBUG] %s %s\n Full headers:\n%s\n", + r.Method, r.URL.String(), formatHeadersDebug(r.Header)) + } } // LogResponse prints an abbreviated response with optional header/body redaction. @@ -100,6 +118,12 @@ func (lp *LoggingProxy) LogResponse(r *http.Response) { log.Printf("[PROXY_RES] %d %s\n Headers:\n%s\n Body: %s", r.StatusCode, sanitizeLog(r.Request.URL.String()), headers, sanitizeLog(bodyStr)) + // Debug only: write unredacted headers directly to stderr. + if lp.UnsafeLogCredentialHeaders { + fmt.Fprintf(os.Stderr, "[PROXY_RES CREDENTIAL DEBUG] %d %s\n Full headers:\n%s\n", + r.StatusCode, r.Request.URL.String(), formatHeadersDebug(r.Header)) + } + if lp.Recorder != nil && lp.RecordEnabled { _ = lp.Recorder.Record("upstream", r.Request, r) } @@ -131,6 +155,20 @@ func formatHeaders(h http.Header, redact bool) string { return strings.TrimSuffix(sb.String(), "\n") } +// formatHeadersDebug formats headers without any redaction. It is intentionally +// separate from formatHeaders and must only be called on the fmt.Fprintf(os.Stderr, …) +// path — never on the log.Printf path — to keep credential values out of the +// structured log stream. +func formatHeadersDebug(h http.Header) string { + var sb strings.Builder + + for k, vv := range h { + fmt.Fprintf(&sb, " %s: %s\n", k, strings.Join(vv, ", ")) + } + + return strings.TrimSuffix(sb.String(), "\n") +} + // isAlwaysSensitive returns true for credential-bearing headers that must // never appear unredacted in logs regardless of caller configuration. func isAlwaysSensitive(header string) bool {