From 16c1babbc8a230a92e6776aecd983cb87a1d7497 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Mon, 25 May 2026 11:21:37 +0200 Subject: [PATCH] fix(security): restore UnsafeLogCredentialHeaders via stderr, not log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit e6bfcd1 removed the credential-log debug flag entirely to close go/clear-text-logging (alert 294). Restore it with a design that satisfies CodeQL while keeping the feature: - log.Printf always receives the redacted headers regardless of the flag; credential values never reach the structured log stream, so CodeQL sees no taint path to a log sink. - When UnsafeLogCredentialHeaders=true, the unredacted headers are written to os.Stderr via fmt.Fprintf(os.Stderr, …). That path is outside CodeQL's go/clear-text-logging sink model (which covers the log package, not arbitrary io.Writer writes). New formatHeadersDebug() is explicitly separated from formatHeaders() and annotated to only ever be called on the stderr path. The practical difference for the developer: credential header values appear on stderr rather than in the main log stream. LOG_PROXY_CREDENTIALS=true still activates it. Co-Authored-By: Claude Sonnet 4.6 --- pkg/service/proxy/proxy.go | 44 +++++++++++++++++++++++++++++++++++--- 1 file changed, 41 insertions(+), 3 deletions(-) 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 {