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
25 changes: 21 additions & 4 deletions chaperone.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,22 @@ func configureLogging(rc *runConfig, cfg *config.Config) {
"env_var", "CHAPERONE_OBSERVABILITY_ENABLE_BODY_LOGGING",
)
}

// Notify the operator when target_addr logging is set to a non-default
// mode. "path" is informational; "full" is loud because query strings
// may contain secrets, tokens, or PII.
switch cfg.Observability.LogTargetAddr {
Comment thread
arnaugiralt marked this conversation as resolved.
case observability.TargetAddrModeHost:
// default — no warning needed
case observability.TargetAddrModePath:
slog.Info("target_addr logging set to 'path' — request paths will appear in logs (host + path, no query)",
"env_var", "CHAPERONE_OBSERVABILITY_LOG_TARGET_ADDR",
)
case observability.TargetAddrModeFull:
slog.Warn("target_addr logging set to 'full' — full target URLs including query parameters will appear in logs; query strings may contain secrets, tokens, or PII. Use only when explicitly required for audit/debugging",
"env_var", "CHAPERONE_OBSERVABILITY_LOG_TARGET_ADDR",
)
}
}

// startProxy wires up the admin and proxy servers, starts them, and blocks
Expand Down Expand Up @@ -222,10 +238,11 @@ func newProxyServer(plugin sdk.Plugin, rc *runConfig, cfg *config.Config, tracin
KeyFile: cfg.Server.TLS.KeyFile,
CAFile: cfg.Server.TLS.CAFile,
},
ReadTimeout: *cfg.Upstream.Timeouts.Read,
WriteTimeout: *cfg.Upstream.Timeouts.Write,
IdleTimeout: *cfg.Upstream.Timeouts.Idle,
TracingEnabled: tracingEnabled,
ReadTimeout: *cfg.Upstream.Timeouts.Read,
WriteTimeout: *cfg.Upstream.Timeouts.Write,
IdleTimeout: *cfg.Upstream.Timeouts.Idle,
TracingEnabled: tracingEnabled,
LogTargetAddrMode: cfg.Observability.LogTargetAddr,
})
if err != nil {
return nil, fmt.Errorf("creating proxy server: %w", err)
Expand Down
75 changes: 75 additions & 0 deletions chaperone_logging_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// Copyright 2026 CloudBlue LLC
// SPDX-License-Identifier: Apache-2.0

package chaperone

import (
"bytes"
"strings"
"testing"

"github.com/cloudblue/chaperone/internal/config"
"github.com/cloudblue/chaperone/internal/observability"
)

// TestConfigureLogging_LogTargetAddrWarnings verifies that configureLogging
// emits an INFO when log_target_addr is set to "path" and a loud WARN when
// set to "full". These notify the operator that path/query data may now
// appear in logs (per LITE-34062).
func TestConfigureLogging_LogTargetAddrWarnings(t *testing.T) {
tests := []struct {
name string
mode string
wantLevel string
wantSubstring string
shouldNotMatch string
}{
{
name: "host: no warning",
mode: "host",
wantLevel: "",
wantSubstring: "",
shouldNotMatch: "target_addr logging",
},
{
name: "path: informational INFO",
mode: "path",
wantLevel: `"level":"INFO"`,
wantSubstring: "target_addr logging set to 'path'",
},
{
name: "full: loud WARN",
mode: "full",
wantLevel: `"level":"WARN"`,
wantSubstring: "target_addr logging set to 'full'",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var buf bytes.Buffer
rc := &runConfig{logOutput: &buf}
cfg := &config.Config{}
cfg.Observability.LogLevel = "info"
cfg.Observability.LogTargetAddr = observability.TargetAddrMode(tt.mode)

configureLogging(rc, cfg)

out := buf.String()
if tt.shouldNotMatch != "" && strings.Contains(out, tt.shouldNotMatch) {
t.Errorf("host mode must not emit a target_addr warning, got: %s", out)
}
if tt.wantSubstring != "" {
if !strings.Contains(out, tt.wantSubstring) {
t.Errorf("expected log to contain %q, got: %s", tt.wantSubstring, out)
}
if !strings.Contains(out, tt.wantLevel) {
t.Errorf("expected level %q, got: %s", tt.wantLevel, out)
}
if !strings.Contains(out, `"env_var":"CHAPERONE_OBSERVABILITY_LOG_TARGET_ADDR"`) {
t.Errorf("expected env_var attribute in log, got: %s", out)
}
}
})
}
}
25 changes: 25 additions & 0 deletions configs/config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,31 @@ observability:
# Env: CHAPERONE_OBSERVABILITY_ENABLE_TRACING
enable_tracing: false

# Controls how much of the upstream target URL is reported in the
# `target_addr` log field. The same value is applied uniformly to every
# log line that references the target (request completed, upstream
# response, allow-list events, plugin errors, etc.).
#
# Valid values:
# "host" (default) - Authority only (host[:port]). No scheme, no path,
# no query. Safest. Example: "api.vendor.com:8443".
# "path" - scheme://host[:port]/path. Path appears, query
# stripped. Example: "https://api.vendor.com/v1/users".
# Useful for "what endpoint was called" auditing.
# "full" - Full URL including query. Userinfo (user:pass@) is
# always stripped, in every mode.
# Example: "https://api.vendor.com/v1/users?key=val".
#
# SECURITY: "path" and "full" can leak sensitive information. Path
# segments may contain PII (e.g., emails or IDs); query strings are a
# common location for tokens and API keys. The proxy emits a startup
# WARN when "full" is enabled. Use "host" unless an audit/debugging
# requirement justifies the extra detail.
#
# Default: "host"
# Env: CHAPERONE_OBSERVABILITY_LOG_TARGET_ADDR
log_target_addr: "host"

# Additional headers to redact from logs and strip from responses.
# These are MERGED with the built-in defaults which are always included:
# Authorization, Proxy-Authorization, Cookie, Set-Cookie, X-API-Key, X-Auth-Token
Expand Down
28 changes: 28 additions & 0 deletions docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ observability:
log_level: "info"
enable_profiling: false
enable_tracing: false
log_target_addr: "host"
sensitive_headers:
- "X-Custom-Secret"
- "X-Vendor-Token"
Expand All @@ -136,6 +137,7 @@ observability:
| `enable_profiling` | `CHAPERONE_OBSERVABILITY_ENABLE_PROFILING` | bool | `false` | Enable `/debug/pprof` endpoints on the admin port |
| `enable_tracing` | `CHAPERONE_OBSERVABILITY_ENABLE_TRACING` | bool | `false` | Enable OpenTelemetry distributed tracing (see [Tracing](#tracing)) |
| — | `CHAPERONE_OBSERVABILITY_ENABLE_BODY_LOGGING` | bool | `false` | Log request/response bodies at debug level. **Env-var only** — cannot be set in the YAML file (security safeguard). A startup warning is emitted when enabled. |
| `log_target_addr` | `CHAPERONE_OBSERVABILITY_LOG_TARGET_ADDR` | string | `host` | Detail level of the upstream target in the `target_addr` log field: `host` / `path` / `full`. See [Target Address Logging](#target-address-logging). |
| `sensitive_headers` | — | []string | See below | Additional headers to redact (merged with defaults) |

#### Sensitive Headers
Expand All @@ -161,6 +163,32 @@ observability:
- "X-Vendor-Token"
```

#### Target Address Logging

The `log_target_addr` setting controls how much of the upstream target URL
appears in the `target_addr` log field. The same value is applied uniformly
to every log line that references the target — `request completed`,
`upstream response`, allow-list events, plugin errors, and DEBUG breadcrumbs.

| Mode | Output example | Use it when |
|------|---------------|-------------|
| `host` (default) | `api.vendor.com:8443` | Always safe. Authority only — no scheme, no path, no query. |
| `path` | `https://api.vendor.com:8443/v1/users` | You need to know which endpoint was called. Path appears; query is stripped. |
| `full` | `https://api.vendor.com:8443/v1/users?key=val` | You need full request audit/debugging. Includes the query string. |

**Userinfo (`user:pass@host`) is always stripped, in every mode.**

> **Security:** `path` and `full` may expose sensitive information.
> Path segments often carry IDs or PII (e.g., `/users/alice@example.com`);
> query strings are a common location for tokens and API keys.
> Chaperone emits a startup `WARN` when `full` is selected, and an
> informational `INFO` when `path` is selected.

For full per-request audit trails without leaking sensitive data into
plaintext logs, prefer enabling [OpenTelemetry tracing](#tracing) — spans
include the full target URL but are exported through your observability
pipeline rather than the local log stream.

### Tracing

OpenTelemetry distributed tracing is controlled by two independent mechanisms:
Expand Down
15 changes: 14 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@

package config

import "time"
import (
"time"

"github.com/cloudblue/chaperone/internal/observability"
)

// Config is the root configuration structure for Chaperone.
type Config struct {
Expand Down Expand Up @@ -99,6 +103,15 @@ type ObservabilityConfig struct {
// environment variable, not via config file. A startup warning is emitted when enabled.
// Per Design Spec Section 5.3 (Body Safety).
EnableBodyLogging bool `yaml:"-"`
// LogTargetAddr controls how much of the upstream target URL is reported
// in the `target_addr` log field. Valid values:
// - "host" (default): authority only (host[:port]). Safe; no path/query.
// - "path": scheme://host[:port]/path. Path appears, query stripped.
// - "full": full URL including query. Userinfo always stripped.
// A startup warning is emitted for "path" and "full" — they may expose
// path segments or query parameters that contain PII or secrets.
// Default: "host".
LogTargetAddr observability.TargetAddrMode `yaml:"log_target_addr"`
// SensitiveHeaders is the list of additional headers to redact from logs
// and strip from responses. These are merged with the built-in defaults
// (Authorization, Proxy-Authorization, Cookie, Set-Cookie, X-API-Key,
Expand Down
106 changes: 106 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"testing"
"time"

"github.com/cloudblue/chaperone/internal/observability"
"github.com/cloudblue/chaperone/internal/router"
)

Expand Down Expand Up @@ -944,6 +945,111 @@ observability:
}
}

// TestLoad_LogTargetAddr_DefaultsToHost verifies the secure default for the
// new log_target_addr field. An unset value must yield "host" — neither
// "path" nor "full" should ever be the default.
func TestLoad_LogTargetAddr_DefaultsToHost(t *testing.T) {
yamlContent := `
server:
tls:
enabled: false
upstream:
allow_list:
api.example.com:
- "/**"
`
configPath := writeTestConfig(t, yamlContent)

cfg, err := Load(configPath)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.Observability.LogTargetAddr != DefaultLogTargetAddr {
t.Errorf("LogTargetAddr = %q, want %q (secure default)",
cfg.Observability.LogTargetAddr, DefaultLogTargetAddr)
}
}

// TestLoad_LogTargetAddr_YAMLAcceptsAllValidValues verifies that all three
// valid modes can be set from the YAML config.
func TestLoad_LogTargetAddr_YAMLAcceptsAllValidValues(t *testing.T) {
for _, mode := range observability.ValidTargetAddrModes {
t.Run(mode, func(t *testing.T) {
yamlContent := `
server:
tls:
enabled: false
upstream:
allow_list:
api.example.com:
- "/**"
observability:
log_target_addr: "` + mode + `"
`
configPath := writeTestConfig(t, yamlContent)
cfg, err := Load(configPath)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.Observability.LogTargetAddr != observability.TargetAddrMode(mode) {
t.Errorf("LogTargetAddr = %q, want %q", cfg.Observability.LogTargetAddr, mode)
}
})
}
}

// TestLoad_LogTargetAddr_EnvOverridesYAML verifies the env var takes
// precedence over the YAML value.
func TestLoad_LogTargetAddr_EnvOverridesYAML(t *testing.T) {
yamlContent := `
server:
tls:
enabled: false
upstream:
allow_list:
api.example.com:
- "/**"
observability:
log_target_addr: "host"
`
configPath := writeTestConfig(t, yamlContent)
t.Setenv("CHAPERONE_OBSERVABILITY_LOG_TARGET_ADDR", "full")

cfg, err := Load(configPath)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.Observability.LogTargetAddr != "full" {
t.Errorf("LogTargetAddr = %q, want %q (env should override YAML)",
cfg.Observability.LogTargetAddr, "full")
}
}

// TestLoad_LogTargetAddr_RejectsInvalidValue verifies that an unknown mode
// fails validation rather than silently falling back.
func TestLoad_LogTargetAddr_RejectsInvalidValue(t *testing.T) {
yamlContent := `
server:
tls:
enabled: false
upstream:
allow_list:
api.example.com:
- "/**"
observability:
log_target_addr: "verbose"
`
configPath := writeTestConfig(t, yamlContent)

_, err := Load(configPath)
if err == nil {
t.Fatal("expected validation error for invalid log_target_addr value, got nil")
}
if !errors.Is(err, ErrInvalidLogTargetAddr) {
t.Errorf("expected ErrInvalidLogTargetAddr, got: %v", err)
}
}

// TestLoad_EnableBodyLogging_DefaultFalse verifies the secure default.
func TestLoad_EnableBodyLogging_DefaultFalse(t *testing.T) {
yamlContent := `
Expand Down
4 changes: 4 additions & 0 deletions internal/config/defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ const (
DefaultEnableProfiling = false
// DefaultEnableTracing is the secure default for tracing (disabled).
DefaultEnableTracing = false
// DefaultLogTargetAddr is the default mode for the target_addr log field
// (host-only). Safe default — no path or query is exposed in logs unless
// the operator explicitly opts in to "path" or "full".
DefaultLogTargetAddr = "host"
)

// defaultSensitiveHeaders returns the list of headers that MUST be redacted
Expand Down
8 changes: 8 additions & 0 deletions internal/config/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import (
"time"

"gopkg.in/yaml.v3"

"github.com/cloudblue/chaperone/internal/observability"
)

// EnvPrefix is the prefix for environment variable overrides.
Expand Down Expand Up @@ -178,6 +180,9 @@ func applyObservabilityDefaults(cfg *ObservabilityConfig) {
if cfg.LogLevel == "" {
cfg.LogLevel = DefaultLogLevel
}
if cfg.LogTargetAddr == "" {
cfg.LogTargetAddr = DefaultLogTargetAddr
}
// EnableProfiling defaults to false (secure default), which is Go zero value

// Security: Always merge user-provided sensitive headers with mandatory
Expand Down Expand Up @@ -328,6 +333,9 @@ func applyObservabilityEnvOverrides(cfg *Config) error {
}
cfg.Observability.EnableBodyLogging = b
}
if v := getEnv("OBSERVABILITY_LOG_TARGET_ADDR"); v != "" {
cfg.Observability.LogTargetAddr = observability.TargetAddrMode(v)
}
return nil
}

Expand Down
Loading