mirror of
https://github.com/lukaszraczylo/kportal.git
synced 2026-06-08 23:39:46 +00:00
95bda3ee3b
P0 #1 — HTTP traffic logger captured Authorization, Cookie, Set-Cookie, X-Api-Key, X-Auth-Token, X-Csrf-Token, Proxy-Authorization, X-Access-Token verbatim into log entries (file 0600 + UI subscribers). Bearer tokens and session cookies were ending up on disk whenever httpLog.includeHeaders was enabled. flattenHeaders now redacts: - the explicit list above (case-insensitive via http.CanonicalHeaderKey) - any header name containing 'token', 'secret', 'password', 'apikey' Header names remain visible; values become [REDACTED]. Redaction is unconditional and on-by-default — no opt-out flag. Users who want raw headers can use tcpdump. P0 #6 — Headless mode without -v silently routed both structured and stdlib logs to io.Discard. A daemon under launchd/systemd had no way to report errors. Headless now defaults log destination to os.Stderr; -v controls only the level (debug vs info). TUI-quiet path is preserved. Tests in internal/httplog/redact_test.go cover all explicit names, substring patterns, and case variants.
420 lines
11 KiB
Go
420 lines
11 KiB
Go
package httplog
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httputil"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/lukaszraczylo/kportal/internal/config"
|
|
"github.com/lukaszraczylo/kportal/internal/logger"
|
|
)
|
|
|
|
// bufferPool is used to reuse byte buffers for body reading.
|
|
// This significantly reduces GC pressure under high load.
|
|
// Using *([]byte) to avoid allocations when storing/retrieving from pool (SA6002).
|
|
var bufferPool = sync.Pool{
|
|
New: func() interface{} {
|
|
buf := make([]byte, 0, 8192) // Start with 8KB capacity
|
|
return &buf
|
|
},
|
|
}
|
|
|
|
// readBufferPool provides fixed-size buffers for io.Reader operations.
|
|
// Using a pool eliminates per-read allocations of temporary buffers.
|
|
var readBufferPool = sync.Pool{
|
|
New: func() interface{} {
|
|
buf := make([]byte, 4096) // 4KB fixed-size read buffer
|
|
return &buf
|
|
},
|
|
}
|
|
|
|
// Proxy is an HTTP reverse proxy with logging capabilities
|
|
type Proxy struct {
|
|
listener net.Listener
|
|
logger *Logger
|
|
server *http.Server
|
|
forwardID string
|
|
filterPath string
|
|
localPort int
|
|
targetPort int
|
|
requestCount uint64
|
|
mu sync.Mutex
|
|
includeHdrs bool
|
|
running bool
|
|
}
|
|
|
|
// NewProxy creates a new HTTP logging proxy
|
|
func NewProxy(fwd *config.Forward, targetPort int) (*Proxy, error) {
|
|
httpCfg := fwd.HTTPLog
|
|
if httpCfg == nil {
|
|
return nil, fmt.Errorf("HTTP log config is nil")
|
|
}
|
|
|
|
logger, err := NewLogger(fwd.ID(), httpCfg.LogFile, fwd.GetHTTPLogMaxBodySize())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create logger: %w", err)
|
|
}
|
|
|
|
return &Proxy{
|
|
localPort: fwd.LocalPort,
|
|
targetPort: targetPort,
|
|
logger: logger,
|
|
forwardID: fwd.ID(),
|
|
filterPath: httpCfg.FilterPath,
|
|
includeHdrs: httpCfg.IncludeHeaders,
|
|
}, nil
|
|
}
|
|
|
|
// Start starts the HTTP proxy server
|
|
func (p *Proxy) Start() error {
|
|
p.mu.Lock()
|
|
if p.running {
|
|
p.mu.Unlock()
|
|
return fmt.Errorf("proxy already running")
|
|
}
|
|
|
|
// Create listener
|
|
ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", p.localPort))
|
|
if err != nil {
|
|
p.mu.Unlock()
|
|
return fmt.Errorf("failed to listen on port %d: %w", p.localPort, err)
|
|
}
|
|
p.listener = ln
|
|
|
|
// Create reverse proxy
|
|
director := func(req *http.Request) {
|
|
req.URL.Scheme = "http"
|
|
req.URL.Host = fmt.Sprintf("127.0.0.1:%d", p.targetPort)
|
|
}
|
|
|
|
proxy := &httputil.ReverseProxy{
|
|
Director: director,
|
|
Transport: &loggingTransport{
|
|
proxy: p,
|
|
transport: http.DefaultTransport,
|
|
},
|
|
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
|
|
p.logError(r, err)
|
|
w.WriteHeader(http.StatusBadGateway)
|
|
_, _ = w.Write([]byte("Proxy error: " + err.Error()))
|
|
},
|
|
}
|
|
|
|
p.server = &http.Server{
|
|
Handler: proxy,
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
}
|
|
|
|
p.running = true
|
|
p.mu.Unlock()
|
|
|
|
// Start serving (blocking)
|
|
go func() {
|
|
if err := p.server.Serve(ln); err != nil && err != http.ErrServerClosed {
|
|
logger.Debug("HTTP proxy serve error (will be replaced on reconnect)", map[string]any{"error": err.Error()})
|
|
}
|
|
}()
|
|
|
|
return nil
|
|
}
|
|
|
|
// Stop stops the HTTP proxy server
|
|
func (p *Proxy) Stop() error {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
if !p.running {
|
|
return nil
|
|
}
|
|
|
|
p.running = false
|
|
|
|
// Shutdown with timeout
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
if err := p.server.Shutdown(ctx); err != nil {
|
|
// Force close - error ignored as we're already shutting down
|
|
_ = p.server.Close()
|
|
}
|
|
|
|
if err := p.logger.Close(); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// loggingTransport wraps http.RoundTripper to log requests and responses
|
|
type loggingTransport struct {
|
|
proxy *Proxy
|
|
transport http.RoundTripper
|
|
}
|
|
|
|
func (t *loggingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
// Generate request ID
|
|
reqID := fmt.Sprintf("%d", atomic.AddUint64(&t.proxy.requestCount, 1))
|
|
|
|
// Check if we should log this request based on path filter
|
|
if !t.proxy.shouldLog(req.URL.Path) {
|
|
return t.transport.RoundTrip(req)
|
|
}
|
|
|
|
startTime := time.Now()
|
|
maxBodySize := t.proxy.logger.GetMaxBodyLen()
|
|
|
|
// Read request body with size limit to prevent memory exhaustion
|
|
var reqBody []byte
|
|
var reqBodySize int
|
|
if req.Body != nil {
|
|
reqBody, reqBodySize = t.readBodyLimited(req.Body, maxBodySize)
|
|
req.Body = io.NopCloser(bytes.NewBuffer(reqBody))
|
|
}
|
|
|
|
// Log request
|
|
reqEntry := Entry{
|
|
RequestID: reqID,
|
|
Direction: "request",
|
|
Method: req.Method,
|
|
Path: req.URL.Path,
|
|
BodySize: reqBodySize,
|
|
Body: string(reqBody),
|
|
}
|
|
|
|
if t.proxy.includeHdrs {
|
|
reqEntry.Headers = flattenHeaders(req.Header)
|
|
}
|
|
|
|
_ = t.proxy.logger.Log(reqEntry)
|
|
|
|
// Make the request
|
|
resp, err := t.transport.RoundTrip(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Read response body with size limit to prevent memory exhaustion
|
|
var respBody []byte
|
|
var respBodySize int
|
|
if resp.Body != nil {
|
|
respBody, respBodySize = t.readBodyLimited(resp.Body, maxBodySize)
|
|
resp.Body = io.NopCloser(bytes.NewBuffer(respBody))
|
|
}
|
|
|
|
latency := time.Since(startTime)
|
|
|
|
// Log response
|
|
respEntry := Entry{
|
|
RequestID: reqID,
|
|
Direction: "response",
|
|
Method: req.Method,
|
|
Path: req.URL.Path,
|
|
StatusCode: resp.StatusCode,
|
|
BodySize: respBodySize,
|
|
Body: string(respBody),
|
|
LatencyMs: latency.Milliseconds(),
|
|
}
|
|
|
|
if t.proxy.includeHdrs {
|
|
respEntry.Headers = flattenHeaders(resp.Header)
|
|
}
|
|
|
|
_ = t.proxy.logger.Log(respEntry)
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
// readBodyLimited reads a body with a size limit to prevent memory exhaustion.
|
|
// Returns the body content (up to maxSize bytes) and the actual content length.
|
|
// If the body exceeds maxSize, it reads only maxSize bytes for logging but
|
|
// consumes the entire body to get the true size for BodySize reporting.
|
|
// Uses sync.Pool to reuse buffers and reduce allocations.
|
|
func (t *loggingTransport) readBodyLimited(body io.ReadCloser, maxSize int) ([]byte, int) {
|
|
// Get a buffer from the pool for accumulating body content
|
|
bufPtr := bufferPool.Get().(*[]byte)
|
|
buf := *bufPtr
|
|
buf = buf[:0] // Reset length but keep capacity
|
|
defer bufferPool.Put(bufPtr)
|
|
|
|
// Get a pooled read buffer to eliminate per-read allocation
|
|
tmpPtr := readBufferPool.Get().(*[]byte)
|
|
tmp := *tmpPtr
|
|
defer readBufferPool.Put(tmpPtr)
|
|
|
|
// Read up to maxSize+1 to detect if there's more
|
|
limitedReader := io.LimitReader(body, int64(maxSize+1))
|
|
|
|
// Read into the pooled buffer
|
|
var totalRead int
|
|
for {
|
|
n, err := limitedReader.Read(tmp)
|
|
if n > 0 {
|
|
buf = append(buf, tmp[:n]...)
|
|
totalRead += n
|
|
}
|
|
if err != nil {
|
|
break
|
|
}
|
|
}
|
|
|
|
actualSize := len(buf)
|
|
wasTruncated := actualSize > maxSize
|
|
|
|
// If we read exactly maxSize+1, there might be more data
|
|
// Discard the rest but count the bytes for accurate BodySize
|
|
if wasTruncated {
|
|
// Count remaining bytes without storing them
|
|
remaining, _ := io.Copy(io.Discard, body)
|
|
actualSize = maxSize + int(remaining)
|
|
// Return a copy of just the maxSize bytes for logging
|
|
resultPtr := bufferPool.Get().(*[]byte)
|
|
result := *resultPtr
|
|
result = result[:maxSize]
|
|
copy(result, buf)
|
|
return result, actualSize
|
|
}
|
|
|
|
// For small results, allocate minimally. For larger results, use pooled buffer.
|
|
resultLen := len(buf)
|
|
var result []byte
|
|
if resultLen <= 4096 {
|
|
// Small body: allocate exact size to avoid holding large buffers
|
|
result = make([]byte, resultLen)
|
|
copy(result, buf)
|
|
} else {
|
|
// Larger body: try to use pooled buffer
|
|
resultPtr := bufferPool.Get().(*[]byte)
|
|
result = *resultPtr
|
|
if cap(result) >= resultLen {
|
|
result = result[:resultLen]
|
|
copy(result, buf)
|
|
} else {
|
|
// Pooled buffer too small, allocate new and don't return to pool
|
|
result = make([]byte, resultLen)
|
|
copy(result, buf)
|
|
}
|
|
}
|
|
return result, actualSize
|
|
}
|
|
|
|
// shouldLog checks if the request path matches the filter
|
|
func (p *Proxy) shouldLog(path string) bool {
|
|
if p.filterPath == "" {
|
|
return true
|
|
}
|
|
|
|
matched, err := filepath.Match(p.filterPath, path)
|
|
if err != nil {
|
|
// Invalid pattern, log everything
|
|
return true
|
|
}
|
|
|
|
// Also try matching with ** for prefix patterns like /api/*
|
|
if !matched && strings.HasSuffix(p.filterPath, "/*") {
|
|
prefix := strings.TrimSuffix(p.filterPath, "/*")
|
|
matched = strings.HasPrefix(path, prefix)
|
|
}
|
|
|
|
return matched
|
|
}
|
|
|
|
// logError logs an error entry
|
|
func (p *Proxy) logError(req *http.Request, err error) {
|
|
entry := Entry{
|
|
RequestID: fmt.Sprintf("%d", atomic.AddUint64(&p.requestCount, 1)),
|
|
Direction: "error",
|
|
Method: req.Method,
|
|
Path: req.URL.Path,
|
|
Error: err.Error(),
|
|
}
|
|
_ = p.logger.Log(entry)
|
|
}
|
|
|
|
// redactedHeaderNames is the set of header names whose values are always
|
|
// redacted before being captured into log entries. Comparison is
|
|
// case-insensitive (canonical MIME header form is used as the key).
|
|
//
|
|
// Redaction is unconditional and on-by-default as a defense-in-depth measure:
|
|
// these headers commonly carry bearer tokens, session cookies, API keys, or
|
|
// other credentials that must never be persisted to disk or surfaced to the
|
|
// UI. Users who genuinely need raw header capture should use a dedicated
|
|
// packet-capture tool (e.g. tcpdump) instead.
|
|
var redactedHeaderNames = map[string]struct{}{
|
|
"Authorization": {},
|
|
"Proxy-Authorization": {},
|
|
"Cookie": {},
|
|
"Set-Cookie": {},
|
|
"X-Api-Key": {},
|
|
"X-Auth-Token": {},
|
|
"X-Csrf-Token": {},
|
|
"X-Access-Token": {},
|
|
}
|
|
|
|
// redactedHeaderSubstrings is a list of lowercase substrings that, when
|
|
// found anywhere in a header name (case-insensitive), trigger redaction.
|
|
// This catches custom or vendor-specific sensitive headers without needing
|
|
// to enumerate every variant.
|
|
var redactedHeaderSubstrings = []string{
|
|
"token",
|
|
"secret",
|
|
"password",
|
|
"apikey",
|
|
}
|
|
|
|
// redactedValue is the placeholder written in place of any sensitive header
|
|
// value. The header name itself is preserved so operators can see which
|
|
// sensitive headers were present without leaking their contents.
|
|
const redactedValue = "[REDACTED]"
|
|
|
|
// shouldRedactHeader reports whether the given header name should have its
|
|
// value redacted before being recorded. The check is case-insensitive and
|
|
// covers both the explicit name list and the substring patterns.
|
|
func shouldRedactHeader(name string) bool {
|
|
if _, ok := redactedHeaderNames[http.CanonicalHeaderKey(name)]; ok {
|
|
return true
|
|
}
|
|
lower := strings.ToLower(name)
|
|
for _, sub := range redactedHeaderSubstrings {
|
|
if strings.Contains(lower, sub) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// flattenHeaders converts http.Header to map[string]string, redacting the
|
|
// values of any sensitive headers (see redactedHeaderNames /
|
|
// redactedHeaderSubstrings) so that credentials never reach the log file or
|
|
// UI subscribers. Pre-allocates the map with the exact size needed to avoid
|
|
// reallocations.
|
|
func flattenHeaders(h http.Header) map[string]string {
|
|
result := make(map[string]string, len(h))
|
|
for k, v := range h {
|
|
if shouldRedactHeader(k) {
|
|
result[k] = redactedValue
|
|
continue
|
|
}
|
|
result[k] = strings.Join(v, ", ")
|
|
}
|
|
return result
|
|
}
|
|
|
|
// GetTargetPort returns the target port for the k8s tunnel
|
|
func (p *Proxy) GetTargetPort() int {
|
|
return p.targetPort
|
|
}
|
|
|
|
// GetLogger returns the HTTP logger for subscribing to log entries
|
|
func (p *Proxy) GetLogger() *Logger {
|
|
return p.logger
|
|
}
|