// Copyright 2024-2025 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package requestlogger import ( "bufio" "net" "net/http" "net/url" "slices" "time" utilnet "k8s.io/apimachinery/pkg/util/net" "k8s.io/apiserver/pkg/endpoints/responsewriter" "k8s.io/utils/clock" "go.pinniped.dev/internal/auditevent" "go.pinniped.dev/internal/config/supervisor" "go.pinniped.dev/internal/httputil/requestutil" "go.pinniped.dev/internal/plog" ) func WithHTTPRequestAuditLogging(handler http.Handler, auditLogger plog.AuditLogger, auditInternalPathsCfg supervisor.AuditInternalPaths) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { rl := newRequestLogger(req, w, auditLogger, time.Now(), auditInternalPathsCfg) rl.logRequestReceived() defer rl.logRequestComplete() statusCodeCapturingResponseWriter := responsewriter.WrapForHTTP1Or2(rl) handler.ServeHTTP(statusCodeCapturingResponseWriter, req) }) } type requestLogger struct { startTime time.Time clock clock.Clock // clock is used to calculate the response latency, and useful for unit tests. hijacked bool statusRecorded bool status int req *http.Request userAgent string w http.ResponseWriter auditLogger plog.AuditLogger auditInternalPaths bool } func newRequestLogger( req *http.Request, w http.ResponseWriter, auditLogger plog.AuditLogger, startTime time.Time, auditInternalPathsCfg supervisor.AuditInternalPaths, ) *requestLogger { return &requestLogger{ req: req, w: w, startTime: startTime, clock: clock.RealClock{}, userAgent: req.UserAgent(), // cache this from the req to avoid any possibility of concurrent read/write problems with headers map auditLogger: auditLogger, auditInternalPaths: auditInternalPathsCfg.Enabled(), } } func internalPaths() []string { return []string{ "/healthz", } } func (rl *requestLogger) logRequestReceived() { r := rl.req if !rl.auditInternalPaths && slices.Contains(internalPaths(), r.URL.Path) { return } // Always log all other requests, including 404's caused by bad paths, for debugging purposes. rl.auditLogger.Audit(auditevent.HTTPRequestReceived, &plog.AuditParams{ ReqCtx: r.Context(), KeysAndValues: []any{ "proto", r.Proto, "method", r.Method, "host", r.Host, // The "Host" header is promoted to this field. "serverName", requestutil.SNIServerName(r), "path", r.URL.Path, "userAgent", rl.userAgent, "sourceIPs", sourceIPs(r), }, }) } func sourceIPs(r *http.Request) []string { // Use the same utility function that calculate the sourceIPs field in the Kubernetes audit logs. // See "sourceIPs" in https://kubernetes.io/docs/reference/config-api/apiserver-audit.v1 for docs. // This can log useful information when the request was made through a proxy. ips := utilnet.SourceIPs(r) ipsAsStr := make([]string, len(ips)) for i := range ips { ipsAsStr[i] = ips[i].String() } return ipsAsStr } func getLocationForAuditLogs(location string) string { if location == "" { return "no location header" } parsedLocation, err := url.Parse(location) if err != nil { return "unparsable location header" } // We don't know what this `Location` header is used for, so redact nearly all query parameters redactedParams := parsedLocation.Query() for k, v := range redactedParams { // Due to https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1, // authorize errors can have an 'error' and an 'error_description' parameter // which should never contain PII and is safe to log. // The 'err' parameter may be populated by the post_login_handler to indicate issues // when using Supervisor's built-in login page. if k == "error" || k == "error_description" || k == "err" { continue } for i := range v { redactedParams[k][i] = "redacted" } } parsedLocation.RawQuery = redactedParams.Encode() return parsedLocation.String() } func (rl *requestLogger) logRequestComplete() { r := rl.req if !rl.auditInternalPaths && slices.Contains(internalPaths(), r.URL.Path) { return } rl.auditLogger.Audit(auditevent.HTTPRequestCompleted, &plog.AuditParams{ ReqCtx: r.Context(), KeysAndValues: []any{ "path", r.URL.Path, "latency", rl.clock.Since(rl.startTime), "responseStatus", rl.status, "location", getLocationForAuditLogs(rl.Header().Get("Location")), }, }) } // Unwrap implements responsewriter.UserProvidedDecorator. func (rl *requestLogger) Unwrap() http.ResponseWriter { return rl.w } // Header implements http.ResponseWriter. func (rl *requestLogger) Header() http.Header { return rl.w.Header() } // Write implements http.ResponseWriter. func (rl *requestLogger) Write(b []byte) (int, error) { if !rl.statusRecorded { rl.recordStatus(http.StatusOK) // Default if WriteHeader hasn't been called } return rl.w.Write(b) } // WriteHeader implements http.ResponseWriter. func (rl *requestLogger) WriteHeader(status int) { rl.recordStatus(status) rl.w.WriteHeader(status) } // Hijack implements http.Hijacker. func (rl *requestLogger) Hijack() (net.Conn, *bufio.ReadWriter, error) { rl.hijacked = true // the outer ResponseWriter object returned by WrapForHTTP1Or2 implements // http.Hijacker if the inner object (rl.w) implements http.Hijacker. return rl.w.(http.Hijacker).Hijack() } func (rl *requestLogger) recordStatus(status int) { rl.status = status rl.statusRecorded = true }