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
8 changes: 8 additions & 0 deletions .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,14 @@
# Log only model interactions, skip /health, /metrics, /admin endpoints (default: true)
# LOGGING_ONLY_MODEL_INTERACTIONS=true

# Networks your own proxies sit on, comma-separated (default: empty, headers ignored)
# With this set, an audit entry records the nearest X-Forwarded-For hop that is not
# one of these networks (the last address your infrastructure wrote, which a client
# cannot overwrite). Requests that do not arrive from a listed network keep their
# socket address. Loopback and private ranges are NOT trusted implicitly: list every
# proxy hop between clients and the gateway. Bare addresses are single-host networks.
# LOGGING_TRUSTED_PROXY_CIDRS=10.0.0.0/8,127.0.0.1

# In-memory audit log queue capacity in entries/rows, not bytes (default: 1000)
# If the queue is full, new audit log entries are dropped with a warning
# LOGGING_BUFFER_SIZE=1000
Expand Down
7 changes: 7 additions & 0 deletions config/config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,13 @@ logging:
flush_interval: 5 # seconds
retention_days: 30 # 0 = keep forever
only_model_interactions: true
# Networks your own proxies sit on. With this set, an audit entry records the
# nearest X-Forwarded-For hop that is not one of these networks (the last
# address your infrastructure wrote, which a client cannot overwrite).
# Requests that do not arrive from a listed network keep their socket address.
# Loopback and private ranges are not trusted implicitly. Leave empty to
# ignore forwarding headers entirely (default).
# trusted_proxy_cidrs: ["10.0.0.0/8", "127.0.0.1"]

usage:
# Usage actions require USAGE_ENABLED=true (or usage.enabled: true) and a supported
Expand Down
3 changes: 3 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,9 @@ func Load() (*LoadResult, error) {
if !cfg.Logging.LogImageBodiesScope.Valid() {
return nil, fmt.Errorf("logging.log_image_bodies_scope must be one of: all, input, output; got %q", cfg.Logging.LogImageBodiesScope)
}
if err := NormalizeTrustedProxyCIDRs(&cfg.Logging); err != nil {
return nil, err
}

return &LoadResult{
Config: cfg,
Expand Down
2 changes: 1 addition & 1 deletion config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ func clearAllConfigEnvVars(t *testing.T) {
"METRICS_ENABLED", "METRICS_ENDPOINT",
"LOGGING_ENABLED", "LOGGING_LOG_BODIES", "LOGGING_LOG_REVISION_BODIES", "LOGGING_LOG_GUARDRAIL_STEPS", "LOGGING_LOG_HEADERS",
"LOGGING_LOG_AUDIO_BODIES", "LOGGING_LOG_IMAGE_BODIES", "LOGGING_LOG_IMAGE_BODIES_SCOPE",
"LOGGING_ONLY_MODEL_INTERACTIONS", "LOGGING_BUFFER_SIZE",
"LOGGING_ONLY_MODEL_INTERACTIONS", "LOGGING_TRUSTED_PROXY_CIDRS", "LOGGING_BUFFER_SIZE",
"LOGGING_FLUSH_INTERVAL", "LOGGING_RETENTION_DAYS",
"USAGE_ENABLED", "ENFORCE_RETURNING_USAGE_DATA",
"USAGE_PRICING_RECALCULATION_ENABLED",
Expand Down
88 changes: 87 additions & 1 deletion config/logging.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package config

import "strings"
import (
"fmt"
"net"
"strings"
)

// LogConfig holds audit logging configuration
type LogConfig struct {
Expand Down Expand Up @@ -84,6 +88,88 @@ type LogConfig struct {
// Endpoints like /health, /metrics, /admin, /v1/models are skipped
// Default: true
OnlyModelInteractions bool `yaml:"only_model_interactions" env:"LOGGING_ONLY_MODEL_INTERACTIONS"`

// TrustedProxyCIDRs lists the networks your own proxies sit on, enabling
// X-Forwarded-For based client IPs in audit entries. A bare address is
// treated as a single host.
//
// When empty (default), audit entries record the address of the socket
// peer that connected to the gateway, and forwarding headers are ignored.
// When set, an entry records the nearest hop in the X-Forwarded-For chain
// that is not one of these networks (the last address your own proxy
// wrote, which a client cannot overwrite). Requests that do not arrive from
// a listed network keep their socket peer address.
//
// Loopback and private ranges are not trusted implicitly: list every proxy
// hop between clients and the gateway, or the header chain is ignored.
// Example: ["10.0.0.0/8", "127.0.0.1"].
// Default: empty (forwarding headers ignored)
TrustedProxyCIDRs []string `yaml:"trusted_proxy_cidrs" env:"LOGGING_TRUSTED_PROXY_CIDRS"`
}

// NormalizeTrustedProxyCIDRs trims the configured networks, accepts bare
// addresses as single-host CIDRs, drops duplicates and blanks, and rejects
// anything that is neither a valid address nor a valid network.
func NormalizeTrustedProxyCIDRs(cfg *LogConfig) error {
if cfg == nil {
return nil
}
if len(cfg.TrustedProxyCIDRs) == 0 {
cfg.TrustedProxyCIDRs = nil
return nil
}

normalized := make([]string, 0, len(cfg.TrustedProxyCIDRs))
seen := make(map[string]struct{}, len(cfg.TrustedProxyCIDRs))
for _, raw := range cfg.TrustedProxyCIDRs {
value := strings.TrimSpace(raw)
if value == "" {
continue
}
ip, ipnet, err := net.ParseCIDR(value)
if err == nil {
value, err = canonicalProxyCIDR(ip, ipnet)
if err != nil {
return err
}
} else if addr := net.ParseIP(value); addr != nil {
value = singleHostCIDR(addr)
} else {
return fmt.Errorf("logging.trusted_proxy_cidrs: %q is not a valid IP address or CIDR network", raw)
}
if _, duplicate := seen[value]; duplicate {
continue
}
seen[value] = struct{}{}
normalized = append(normalized, value)
}
cfg.TrustedProxyCIDRs = normalized
return nil
}

// canonicalProxyCIDR renders a parsed network in the form that actually matches
// requests, rejecting the ones that cannot. An IPv4-mapped network whose prefix
// is shorter than /96 ("::ffff:10.0.0.0/8") reaches past the embedded address:
// Go masks it to a network such as "::/8" that no IPv4 request belongs to, so
// the operator would believe a proxy network was trusted while nothing matched
// it. Narrower mapped networks ("::ffff:10.0.0.0/120") are rendered by net.IPNet
// as the IPv4 network they stand for ("10.0.0.0/24") and are kept.
func canonicalProxyCIDR(addr net.IP, ipnet *net.IPNet) (string, error) {
ones, bits := ipnet.Mask.Size()
if bits == 128 && addr.To4() != nil && ones < 96 {
return "", fmt.Errorf("logging.trusted_proxy_cidrs: %q is an IPv4-mapped network with a prefix shorter than /96, which matches no IPv4 address; write the IPv4 form (for example 10.0.0.0/8)", ipnet.String())
}
return ipnet.String(), nil
}

// singleHostCIDR renders an address as the network containing only it, so an
// IPv4-mapped address such as "::ffff:10.0.0.1" normalizes to "10.0.0.1/32"
// instead of a /128 no IPv4 request will ever match.
func singleHostCIDR(ip net.IP) string {
if v4 := ip.To4(); v4 != nil {
return v4.String() + "/32"
}
return ip.String() + "/128"
}

// ImageBodyScope selects which image bytes the audit log embeds when
Expand Down
87 changes: 87 additions & 0 deletions config/logging_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,90 @@ func TestLoadImageBodyLoggingEnv(t *testing.T) {
require.Contains(t, err.Error(), "log_image_bodies_scope")
})
}

// TestLoadTrustedProxyCIDRs covers the operator-facing shape of the setting:
// off unless configured, normalized when set, and a startup error on a value
// that cannot describe a network.
func TestLoadTrustedProxyCIDRs(t *testing.T) {
clearAllConfigEnvVars(t)

withTempDir(t, func(string) {
result, err := Load()
require.NoError(t, err)
require.Empty(t, result.Config.Logging.TrustedProxyCIDRs, "forwarding headers must be ignored unless the operator lists proxy networks")

t.Setenv("LOGGING_TRUSTED_PROXY_CIDRS", " 10.0.0.0/8 , 127.0.0.1, 10.0.0.0/8 ,")
result, err = Load()
require.NoError(t, err)
require.Equal(t, []string{"10.0.0.0/8", "127.0.0.1/32"}, result.Config.Logging.TrustedProxyCIDRs)

t.Setenv("LOGGING_TRUSTED_PROXY_CIDRS", "2001:db8::5")
result, err = Load()
require.NoError(t, err)
require.Equal(t, []string{"2001:db8::5/128"}, result.Config.Logging.TrustedProxyCIDRs)

t.Setenv("LOGGING_TRUSTED_PROXY_CIDRS", "10.0.0.0/33")
_, err = Load()
require.Error(t, err)
require.Contains(t, err.Error(), "trusted_proxy_cidrs")
})
}

// TestNormalizeTrustedProxyCIDRsMappedIPv6 pins the handling of IPv4-mapped
// networks, which are the ones an operator can write believing a proxy network
// is trusted while it silently matches nothing.
func TestNormalizeTrustedProxyCIDRsMappedIPv6(t *testing.T) {
tests := []struct {
name string
in []string
want []string
wantErr string
}{
{
name: "mapped prefix inside the embedded address becomes the IPv4 network",
in: []string{"::ffff:10.0.0.0/120"},
want: []string{"10.0.0.0/24"},
},
{
name: "mapped /96 is the whole IPv4 space",
in: []string{"::ffff:0:0/96"},
want: []string{"0.0.0.0/0"},
},
{
name: "mapped prefix below /96 reaches past the embedded address",
in: []string{"::ffff:10.0.0.0/8"},
wantErr: "shorter than /96",
},
{
name: "plain IPv4 and IPv6 networks are untouched",
in: []string{"10.0.0.0/8", "2001:db8::/32", "::1"},
want: []string{"10.0.0.0/8", "2001:db8::/32", "::1/128"},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &LogConfig{TrustedProxyCIDRs: tt.in}
err := NormalizeTrustedProxyCIDRs(cfg)

if tt.wantErr != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tt.wantErr)
return
}
require.NoError(t, err)
require.Equal(t, tt.want, cfg.TrustedProxyCIDRs)
})
}
}

// TestNormalizeTrustedProxyCIDRsDegenerateInput covers the shapes a config file
// can hold that environment parsing would have filtered out already: no
// configuration at all, and entries that carry no value.
func TestNormalizeTrustedProxyCIDRsDegenerateInput(t *testing.T) {
require.NoError(t, NormalizeTrustedProxyCIDRs(nil))

cfg := &LogConfig{TrustedProxyCIDRs: []string{" ", ""}}
require.NoError(t, NormalizeTrustedProxyCIDRs(cfg))
require.Empty(t, cfg.TrustedProxyCIDRs)
}
18 changes: 18 additions & 0 deletions docs/advanced/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ Storage is shared by audit logging, usage tracking, and future features like IAM
| `LOGGING_BUFFER_SIZE` | In-memory buffer before flush | `1000` |
| `LOGGING_FLUSH_INTERVAL` | Flush interval in seconds | `5` |
| `LOGGING_RETENTION_DAYS` | Auto-delete after N days (0 = forever) | `30` |
| `LOGGING_TRUSTED_PROXY_CIDRS` | Networks your proxies sit on; enables X-Forwarded-For client IPs in audit entries | _(empty)_ |

Realtime dashboard previews and persisted audit logs are separate features.
With `DASHBOARD_LIVE_LOGS_ENABLED=true` and `LOGGING_ENABLED=false`, requests
Expand All @@ -179,6 +180,23 @@ to `LOGGING_FLUSH_INTERVAL` seconds to appear through the stored-log API.
restart GoModel.
</Note>

<Note>
Audit entries record the address of the connection that reached the gateway,
so behind a proxy they all show the proxy. Set
`LOGGING_TRUSTED_PROXY_CIDRS` (or `logging.trusted_proxy_cidrs`) to the
networks your own proxies sit on to record the forwarded client instead: each
entry then carries the nearest `X-Forwarded-For` hop that is not one of those
networks, which is the last address your infrastructure wrote and the one a
client cannot overwrite. Requests arriving from outside the listed networks,
requests whose header cannot be parsed, and requests whose whole chain is made
of listed networks keep their connection address — in that last case no hop
was written by anything the gateway does not already trust, so the chain is a
claim an internal client could invent rather than evidence. Loopback and
private ranges are not trusted implicitly, so list every hop between clients
and the gateway, for example `10.0.0.0/8,127.0.0.1`. Changing it requires a
restart.
</Note>

<Warning>
When `LOGGING_LOG_BODIES` is enabled, request and response bodies are stored
in full. These may contain sensitive data such as PII or API keys embedded in
Expand Down
1 change: 1 addition & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,7 @@ func (a *App) logStartupInfo() {
"log_image_bodies_scope", cfg.Logging.LogImageBodiesScope,
"log_headers", cfg.Logging.LogHeaders,
"retention_days", cfg.Logging.RetentionDays,
"trusted_proxy_cidrs", cfg.Logging.TrustedProxyCIDRs,
)
} else {
slog.Info("audit logging disabled")
Expand Down
5 changes: 5 additions & 0 deletions internal/auditlog/auditlog.go
Original file line number Diff line number Diff line change
Expand Up @@ -543,4 +543,9 @@ type Config struct {
// OnlyModelInteractions limits logging to AI model endpoints only
// When true, only /v1/chat/completions, /v1/responses, /v1/embeddings, /v1/files, and /v1/batches are logged
OnlyModelInteractions bool

// TrustedProxies resolves audit client IPs from X-Forwarded-For when a
// request arrives from one of the operator's proxy networks. Nil (the
// default) records the socket peer address and ignores forwarding headers.
TrustedProxies *TrustedProxies
}
Loading
Loading