diff --git a/.env.example b/.env.example index eb78558..7a66b10 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,10 @@ # Server GAP_SERVER_ADDR=:8080 GAP_SERVER_ADMIN_KEY=change-me-admin-key +# Slowloris guards and the cap on a proxied client request body (413 above it). +GAP_SERVER_READ_TIMEOUT=60s +GAP_SERVER_IDLE_TIMEOUT=120s +GAP_SERVER_MAX_REQUEST_BYTES=10485760 # Auth (Grok CLI auth.json) GAP_AUTH_FILE=/config/auth.json @@ -25,6 +29,7 @@ GAP_LOG_LEVEL=info GAP_LOG_REDACT=true # Audit (request/response bodies stored in DB; access via /admin/audit) +# max_body_bytes caps only what is STORED; it never truncates the proxied request. GAP_AUDIT_ENABLED=true GAP_AUDIT_MAX_BODY_BYTES=65536 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0715859 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,52 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + name: Build and test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Check formatting + run: | + unformatted="$(gofmt -l .)" + if [ -n "$unformatted" ]; then + echo "These files are not gofmt-formatted:" + echo "$unformatted" + gofmt -d . + exit 1 + fi + + - name: Verify go.mod is tidy + run: | + go mod tidy + git diff --exit-code -- go.mod go.sum + + - name: Build + run: go build ./... + + - name: Vet + run: go vet ./... + + - name: Test + run: go test ./... + + - name: Test with race detector + run: go test -race ./... diff --git a/README.md b/README.md index 83d0e2e..17cc5d2 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,9 @@ Priority: **flags → env (`GAP_*`) → config file → defaults**. |----------|---------|-------------| | `GAP_SERVER_ADDR` | `:8080` | Listen address | | `GAP_SERVER_ADMIN_KEY` | **required** | Admin API secret | +| `GAP_SERVER_READ_TIMEOUT` | `60s` | Max time to read a client request (slowloris guard) | +| `GAP_SERVER_IDLE_TIMEOUT` | `120s` | Max idle keep-alive connection lifetime | +| `GAP_SERVER_MAX_REQUEST_BYTES` | `10485760` | Largest proxied request body; larger gets `413` | | `GAP_AUTH_FILE` | `./auth.json` | Path to Grok `auth.json` | | `GAP_AUTH_UPSTREAM_BASE` | `https://api.x.ai/v1` | Upstream API base | | `GAP_AUTH_REFRESH_SKEW` | `5m` | Refresh before expiry | @@ -110,7 +113,7 @@ Priority: **flags → env (`GAP_*`) → config file → defaults**. | `GAP_LOG_LEVEL` | `info` | `debug\|info\|warn\|error` | | `GAP_LOG_REDACT` | `true` | Redact secrets in logs | | `GAP_AUDIT_ENABLED` | `true` | Store request/response bodies in DB | -| `GAP_AUDIT_MAX_BODY_BYTES` | `65536` | Max body size stored per side | +| `GAP_AUDIT_MAX_BODY_BYTES` | `65536` | Max body size **stored** per side (does not truncate what is proxied) | | `GAP_METRICS_ENABLED` | `true` | Prometheus metrics | | `GAP_CONFIG` | | Optional config file path | diff --git a/configs/config.example.yaml b/configs/config.example.yaml index bda06e9..287ee31 100644 --- a/configs/config.example.yaml +++ b/configs/config.example.yaml @@ -2,6 +2,12 @@ server: addr: ":8080" admin_key: "change-me-admin-key" shutdown_timeout: 15s + # Bound how long a client may take to send a request and how long an idle + # keep-alive connection is held; without these a slow client can pin sockets. + read_timeout: 60s + idle_timeout: 120s + # Largest proxied client request body. Bigger requests get 413. + max_request_bytes: 10485760 auth: file: "./auth.json" @@ -33,6 +39,8 @@ log: # Proxied request/response bodies stored in DB (admin /admin/audit). audit: enabled: true + # Caps only what is stored per side. It does NOT truncate the request that is + # forwarded upstream — see server.max_request_bytes for that. max_body_bytes: 65536 metrics: diff --git a/deploy/helm/grok-auth-proxy/templates/configmap.yaml b/deploy/helm/grok-auth-proxy/templates/configmap.yaml index ed34b79..de10a0a 100644 --- a/deploy/helm/grok-auth-proxy/templates/configmap.yaml +++ b/deploy/helm/grok-auth-proxy/templates/configmap.yaml @@ -6,6 +6,9 @@ metadata: {{- include "grok-auth-proxy.labels" . | nindent 4 }} data: GAP_SERVER_ADDR: {{ .Values.config.serverAddr | quote }} + GAP_SERVER_READ_TIMEOUT: {{ .Values.config.serverReadTimeout | default "60s" | quote }} + GAP_SERVER_IDLE_TIMEOUT: {{ .Values.config.serverIdleTimeout | default "120s" | quote }} + GAP_SERVER_MAX_REQUEST_BYTES: {{ .Values.config.serverMaxRequestBytes | default "10485760" | quote }} GAP_AUTH_FILE: {{ .Values.config.authFile | quote }} GAP_AUTH_UPSTREAM_BASE: {{ .Values.config.upstreamBase | quote }} GAP_AUTH_REFRESH_SKEW: {{ .Values.config.refreshSkew | quote }} diff --git a/deploy/helm/grok-auth-proxy/values.yaml b/deploy/helm/grok-auth-proxy/values.yaml index 9ed92b9..3eaff1d 100644 --- a/deploy/helm/grok-auth-proxy/values.yaml +++ b/deploy/helm/grok-auth-proxy/values.yaml @@ -49,6 +49,11 @@ resources: # Non-secret configuration (rendered into ConfigMap / env) config: serverAddr: ":8080" + # Request read / idle connection timeouts (slowloris guards). + serverReadTimeout: 60s + serverIdleTimeout: 120s + # Largest proxied client request body; bigger requests get 413. + serverMaxRequestBytes: "10485760" # When seedToDataVolume=false, mount auth secret here (read-only). authFile: /config/auth.json upstreamBase: https://api.x.ai/v1 @@ -66,6 +71,7 @@ config: logRedact: "true" metricsEnabled: "true" auditEnabled: "true" + # Caps only what is stored in the audit log, not what is proxied. auditMaxBodyBytes: "65536" # External Postgres DSN (recommended). Key should contain the full libpq/GORM DSN. diff --git a/docs/API.md b/docs/API.md index c85340c..4b27331 100644 --- a/docs/API.md +++ b/docs/API.md @@ -113,6 +113,7 @@ Upstream (xAI) errors are forwarded as-is (status code and body), for example: | `400` | Bad client JSON (admin) or upstream validation (e.g. unknown model) | | `401` | Missing/invalid API key or admin key | | `404` | Unknown path (e.g. `/v1/v1/chat/completions`) or unknown key id | +| `413` | Request body larger than `GAP_SERVER_MAX_REQUEST_BYTES` (default 10 MiB) | | `429` | Per-key rate limit exceeded | | `500` | Internal (DB, reload failure, …) | | `502` | Upstream request failed / unauthorized after refresh | @@ -577,7 +578,12 @@ curl -sS "http://localhost:8080/admin/audit?limit=20&path=/v1/chat/completions" } ``` -Bodies are truncated at `GAP_AUDIT_MAX_BODY_BYTES` (default 64 KiB) per side. Streaming responses store the first N bytes only. +Stored bodies are truncated at `GAP_AUDIT_MAX_BODY_BYTES` (default 64 KiB) per side, and the +`request_truncated` / `response_truncated` flags say so. This limit applies only to what is +persisted — the request forwarded to xAI is never truncated. The proxied request body is +bounded separately by `GAP_SERVER_MAX_REQUEST_BYTES` (default 10 MiB); larger requests are +rejected with `413 Request Entity Too Large` and never reach the upstream. Streaming responses +store the first N bytes only. Disable with `GAP_AUDIT_ENABLED=false`. diff --git a/internal/config/config.go b/internal/config/config.go index 56b9889..d143cd3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,6 +2,8 @@ package config import ( "fmt" + "net" + "net/url" "strings" "time" @@ -9,6 +11,14 @@ import ( "github.com/spf13/viper" ) +const ( + defaultReadTimeout = 60 * time.Second + defaultIdleTimeout = 120 * time.Second + // 10 MiB is well above any OpenAI-compatible chat payload while still + // bounding how much memory one client request can pin. + defaultMaxRequestBytes = 10 << 20 +) + // Config holds all application settings. type Config struct { Server ServerConfig `mapstructure:"server"` @@ -31,6 +41,14 @@ type ServerConfig struct { Addr string `mapstructure:"addr"` AdminKey string `mapstructure:"admin_key"` ShutdownTimeout time.Duration `mapstructure:"shutdown_timeout"` + // ReadTimeout bounds how long a client may take to send headers plus body. + // Without it a single idle socket can pin a connection forever (slowloris). + ReadTimeout time.Duration `mapstructure:"read_timeout"` + // IdleTimeout bounds how long an idle keep-alive connection is kept open. + IdleTimeout time.Duration `mapstructure:"idle_timeout"` + // MaxRequestBytes caps the proxied client request body. Requests above it + // are rejected with 413 rather than silently truncated. + MaxRequestBytes int `mapstructure:"max_request_bytes"` } type AuthConfig struct { @@ -96,6 +114,9 @@ func Load() (*Config, error) { func setDefaults(v *viper.Viper) { v.SetDefault("server.addr", ":8080") v.SetDefault("server.shutdown_timeout", 15*time.Second) + v.SetDefault("server.read_timeout", 60*time.Second) + v.SetDefault("server.idle_timeout", 120*time.Second) + v.SetDefault("server.max_request_bytes", defaultMaxRequestBytes) v.SetDefault("auth.file", "./auth.json") v.SetDefault("auth.upstream_base", "https://api.x.ai/v1") v.SetDefault("auth.refresh_skew", 5*time.Minute) @@ -135,6 +156,9 @@ func bindEnv(v *viper.Viper) { _ = v.BindEnv("server.addr", "GAP_SERVER_ADDR") _ = v.BindEnv("server.admin_key", "GAP_SERVER_ADMIN_KEY") _ = v.BindEnv("server.shutdown_timeout", "GAP_SERVER_SHUTDOWN_TIMEOUT") + _ = v.BindEnv("server.read_timeout", "GAP_SERVER_READ_TIMEOUT") + _ = v.BindEnv("server.idle_timeout", "GAP_SERVER_IDLE_TIMEOUT") + _ = v.BindEnv("server.max_request_bytes", "GAP_SERVER_MAX_REQUEST_BYTES") _ = v.BindEnv("auth.file", "GAP_AUTH_FILE") _ = v.BindEnv("auth.upstream_base", "GAP_AUTH_UPSTREAM_BASE") _ = v.BindEnv("auth.refresh_skew", "GAP_AUTH_REFRESH_SKEW") @@ -165,6 +189,17 @@ func (c *Config) Validate() error { if strings.TrimSpace(c.Auth.UpstreamBase) == "" { return fmt.Errorf("auth.upstream_base is required") } + // The upstream request carries the Grok access token in an Authorization + // header, so plaintext HTTP to a remote host would leak it on the wire. + if err := requireSecureURL("auth.upstream_base", c.Auth.UpstreamBase); err != nil { + return err + } + // The issuer receives the refresh_token during token exchange. + if strings.TrimSpace(c.Auth.Issuer) != "" { + if err := requireSecureURL("auth.issuer", c.Auth.Issuer); err != nil { + return err + } + } driver := strings.ToLower(c.DB.Driver) if driver != "sqlite" && driver != "postgres" { return fmt.Errorf("db.driver must be sqlite or postgres, got %q", c.DB.Driver) @@ -185,7 +220,52 @@ func (c *Config) Validate() error { if c.Audit.MaxBodyBytes <= 0 { c.Audit.MaxBodyBytes = 65536 } + if c.Server.ReadTimeout <= 0 { + c.Server.ReadTimeout = defaultReadTimeout + } + if c.Server.IdleTimeout <= 0 { + c.Server.IdleTimeout = defaultIdleTimeout + } + if c.Server.MaxRequestBytes <= 0 { + c.Server.MaxRequestBytes = defaultMaxRequestBytes + } // Strip trailing slash from upstream base for consistent path join. c.Auth.UpstreamBase = strings.TrimRight(c.Auth.UpstreamBase, "/") return nil } + +// requireSecureURL rejects URLs that would carry credentials in cleartext. +// Plain HTTP is tolerated only for loopback hosts, which never leave the machine +// and are the common case for local mock upstreams in tests and development. +func requireSecureURL(field, raw string) error { + u, err := url.Parse(raw) + if err != nil { + return fmt.Errorf("%s is not a valid URL: %w", field, err) + } + switch strings.ToLower(u.Scheme) { + case "https": + return nil + case "http": + if isLoopbackHost(u.Hostname()) { + return nil + } + return fmt.Errorf("%s must use https (got %q): credentials would be sent in cleartext", field, raw) + case "": + return fmt.Errorf("%s must be an absolute http(s) URL, got %q", field, raw) + default: + return fmt.Errorf("%s must use http or https, got scheme %q", field, u.Scheme) + } +} + +func isLoopbackHost(host string) bool { + if host == "" { + return false + } + if strings.EqualFold(host, "localhost") { + return true + } + if ip := net.ParseIP(host); ip != nil { + return ip.IsLoopback() + } + return false +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 3b87f94..25af559 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -45,6 +45,81 @@ func TestValidateTrimsUpstreamBase(t *testing.T) { } } +func baseConfig() *Config { + return &Config{ + Server: ServerConfig{AdminKey: "k", Addr: ":8080"}, + Auth: AuthConfig{ + File: "./auth.json", + UpstreamBase: "https://api.x.ai/v1", + RefreshSkew: time.Minute, + }, + DB: DBConfig{Driver: "sqlite", DSN: "x"}, + RateLimit: RateLimitConfig{RPS: 1, Burst: 1}, + } +} + +// The upstream call carries the Grok access token, and the issuer call carries +// the refresh token. Neither may go out over plaintext HTTP to a remote host. +func TestValidateRejectsCleartextCredentialEndpoints(t *testing.T) { + cases := []struct { + name string + mutate func(*Config) + wantErr bool + }{ + {"https upstream", func(c *Config) { c.Auth.UpstreamBase = "https://api.x.ai/v1" }, false}, + {"http upstream remote", func(c *Config) { c.Auth.UpstreamBase = "http://api.x.ai/v1" }, true}, + {"http upstream localhost", func(c *Config) { c.Auth.UpstreamBase = "http://localhost:9999/v1" }, false}, + {"http upstream 127.0.0.1", func(c *Config) { c.Auth.UpstreamBase = "http://127.0.0.1:9999/v1" }, false}, + {"http upstream ::1", func(c *Config) { c.Auth.UpstreamBase = "http://[::1]:9999/v1" }, false}, + {"bad scheme", func(c *Config) { c.Auth.UpstreamBase = "ftp://api.x.ai/v1" }, true}, + {"relative", func(c *Config) { c.Auth.UpstreamBase = "api.x.ai/v1" }, true}, + {"https issuer", func(c *Config) { c.Auth.Issuer = "https://auth.x.ai" }, false}, + {"http issuer remote", func(c *Config) { c.Auth.Issuer = "http://auth.x.ai" }, true}, + {"empty issuer defaults later", func(c *Config) { c.Auth.Issuer = "" }, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cfg := baseConfig() + tc.mutate(cfg) + err := cfg.Validate() + if tc.wantErr && err == nil { + t.Fatal("expected a validation error, got nil") + } + if !tc.wantErr && err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +func TestValidateFillsServerHardeningDefaults(t *testing.T) { + cfg := baseConfig() + if err := cfg.Validate(); err != nil { + t.Fatal(err) + } + if cfg.Server.ReadTimeout <= 0 { + t.Fatal("read_timeout must default to a non-zero value (slowloris guard)") + } + if cfg.Server.IdleTimeout <= 0 { + t.Fatal("idle_timeout must default to a non-zero value") + } + if cfg.Server.MaxRequestBytes <= 0 { + t.Fatal("max_request_bytes must default to a non-zero value") + } + + // Explicit values are preserved. + cfg = baseConfig() + cfg.Server.ReadTimeout = 5 * time.Second + cfg.Server.IdleTimeout = 7 * time.Second + cfg.Server.MaxRequestBytes = 123 + if err := cfg.Validate(); err != nil { + t.Fatal(err) + } + if cfg.Server.ReadTimeout != 5*time.Second || cfg.Server.IdleTimeout != 7*time.Second || cfg.Server.MaxRequestBytes != 123 { + t.Fatalf("explicit server settings were overwritten: %+v", cfg.Server) + } +} + func TestLoadFromEnv(t *testing.T) { t.Setenv("GAP_SERVER_ADMIN_KEY", "env-admin") t.Setenv("GAP_AUTH_FILE", "/tmp/auth.json") diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index fb79916..b1ee357 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -10,17 +10,21 @@ import ( "github.com/prometheus/client_golang/prometheus/promhttp" ) +// unmatchedRoute is the single label value used for requests that did not match +// a registered route, keeping metric cardinality bounded. +const unmatchedRoute = "unmatched" + // Metrics holds Prometheus collectors. type Metrics struct { Requests *prometheus.CounterVec Latency *prometheus.HistogramVec // Auth / Grok session - AuthTokenExpiresAt prometheus.Gauge + AuthTokenExpiresAt prometheus.Gauge AuthTokenSecondsRemaining prometheus.Gauge - AuthTokenHasRefresh prometheus.Gauge - AuthReady prometheus.Gauge - AuthRefreshTotal *prometheus.CounterVec + AuthTokenHasRefresh prometheus.Gauge + AuthReady prometheus.Gauge + AuthRefreshTotal *prometheus.CounterVec } // New registers default metrics. @@ -94,9 +98,13 @@ func (m *Metrics) Middleware() gin.HandlerFunc { return func(c *gin.Context) { start := time.Now() c.Next() + // c.FullPath() is the registered route template, which is a bounded + // set. Falling back to the raw URL path would let any caller mint an + // unbounded number of label values (one series per 404'd URL) and + // exhaust the process memory, so unmatched requests share one bucket. path := c.FullPath() if path == "" { - path = c.Request.URL.Path + path = unmatchedRoute } status := strconv.Itoa(c.Writer.Status()) m.Requests.WithLabelValues(c.Request.Method, path, status).Inc() diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index c375f52..c5d3f82 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -1,6 +1,8 @@ package middleware import ( + "crypto/sha256" + "crypto/subtle" "net/http" "strings" "sync" @@ -85,10 +87,15 @@ func CORS(allowed []string) gin.HandlerFunc { origin := c.GetHeader("Origin") if allowAll { c.Header("Access-Control-Allow-Origin", "*") - } else if origin != "" { - if _, ok := set[origin]; ok { - c.Header("Access-Control-Allow-Origin", origin) - c.Header("Vary", "Origin") + } else { + // Vary must be set whether or not the origin matched, otherwise a + // shared cache can replay an allowed origin's Access-Control-Allow-Origin + // to a different, disallowed origin. + c.Writer.Header().Add("Vary", "Origin") + if origin != "" { + if _, ok := set[origin]; ok { + c.Header("Access-Control-Allow-Origin", origin) + } } } c.Header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS") @@ -125,13 +132,24 @@ func APIKeyAuth(s *store.Store) gin.HandlerFunc { } // AdminAuth checks the admin key via Bearer or X-Admin-Key. +// +// The comparison is constant time: a byte-wise `==` returns as soon as it hits +// a differing byte, which lets an attacker recover the admin key one character +// at a time from response latency. Both sides are hashed first so the compare +// is also independent of the key length. func AdminAuth(adminKey string) gin.HandlerFunc { + configured := strings.TrimSpace(adminKey) != "" + want := sha256.Sum256([]byte(adminKey)) return func(c *gin.Context) { got := c.GetHeader("X-Admin-Key") if got == "" { got = extractBearer(c) } - if got == "" || got != adminKey { + have := sha256.Sum256([]byte(got)) + ok := subtle.ConstantTimeCompare(have[:], want[:]) == 1 + // An unset admin key must never authenticate anyone, not even a caller + // who sends an empty key. + if !configured || !ok { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ "error": "unauthorized", }) diff --git a/internal/middleware/middleware_test.go b/internal/middleware/middleware_test.go new file mode 100644 index 0000000..87fb78e --- /dev/null +++ b/internal/middleware/middleware_test.go @@ -0,0 +1,159 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func adminRouter(adminKey string) *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.New() + g := r.Group("/admin") + g.Use(AdminAuth(adminKey)) + g.GET("/keys", func(c *gin.Context) { c.String(http.StatusOK, "ok") }) + return r +} + +func TestAdminAuthAcceptsCorrectKey(t *testing.T) { + r := adminRouter("s3cret-admin-key") + + for _, tc := range []struct { + name string + header string + value string + }{ + {"x-admin-key", "X-Admin-Key", "s3cret-admin-key"}, + {"bearer", "Authorization", "Bearer s3cret-admin-key"}, + } { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/admin/keys", nil) + req.Header.Set(tc.header, tc.value) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) + } + }) + } +} + +func TestAdminAuthRejectsWrongKeys(t *testing.T) { + r := adminRouter("s3cret-admin-key") + + cases := map[string]string{ + "empty": "", + "wrong": "not-the-key", + "prefix": "s3cret-admin-ke", + "prefix-one-char": "s", + "extra-suffix": "s3cret-admin-keyy", + "case-differs": "S3CRET-ADMIN-KEY", + "whitespace": " s3cret-admin-key", + } + for name, key := range cases { + t.Run(name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/admin/keys", nil) + if key != "" { + req.Header.Set("X-Admin-Key", key) + } + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401 for %q, got %d", key, w.Code) + } + }) + } +} + +// An admin key that was never configured must lock the endpoints, not turn them +// into an open door for callers that send nothing or an empty header. +func TestAdminAuthRejectsEverythingWhenKeyUnset(t *testing.T) { + for _, adminKey := range []string{"", " "} { + r := adminRouter(adminKey) + // " " is the case a plain `got != adminKey` compare gets wrong: it + // treats a blank placeholder key as a valid credential. + for _, sent := range []string{"", " ", " ", "anything"} { + req := httptest.NewRequest(http.MethodGet, "/admin/keys", nil) + req.Header.Set("X-Admin-Key", sent) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusUnauthorized { + t.Fatalf("admin_key=%q sent=%q: expected 401, got %d", adminKey, sent, w.Code) + } + } + } +} + +// A non-Bearer Authorization scheme must not be accepted as an admin key. +func TestAdminAuthIgnoresNonBearerAuthorization(t *testing.T) { + r := adminRouter("s3cret-admin-key") + req := httptest.NewRequest(http.MethodGet, "/admin/keys", nil) + req.Header.Set("Authorization", "Basic s3cret-admin-key") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", w.Code) + } +} + +func corsRouter(allowed []string) *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(CORS(allowed)) + r.GET("/v1/models", func(c *gin.Context) { c.String(http.StatusOK, "ok") }) + return r +} + +func TestCORSAllowsListedOriginOnly(t *testing.T) { + r := corsRouter([]string{"https://app.example.com"}) + + req := httptest.NewRequest(http.MethodGet, "/v1/models", nil) + req.Header.Set("Origin", "https://app.example.com") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if got := w.Header().Get("Access-Control-Allow-Origin"); got != "https://app.example.com" { + t.Fatalf("allow-origin=%q", got) + } + + req = httptest.NewRequest(http.MethodGet, "/v1/models", nil) + req.Header.Set("Origin", "https://evil.example.com") + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" { + t.Fatalf("disallowed origin got allow-origin=%q", got) + } +} + +// Without Vary: Origin on every response, a shared cache can hand an allowed +// origin's Access-Control-Allow-Origin header to a disallowed one. +func TestCORSAlwaysVariesOnOriginWhenRestricted(t *testing.T) { + r := corsRouter([]string{"https://app.example.com"}) + + for _, origin := range []string{"https://app.example.com", "https://evil.example.com", ""} { + req := httptest.NewRequest(http.MethodGet, "/v1/models", nil) + if origin != "" { + req.Header.Set("Origin", origin) + } + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if got := w.Header().Get("Vary"); got != "Origin" { + t.Fatalf("origin=%q: Vary=%q, want Origin", origin, got) + } + } +} + +func TestMaskBearerKeepsSecretShort(t *testing.T) { + if got := maskBearer("Basic abcdef"); got != "***" { + t.Fatalf("non-bearer masked as %q", got) + } + full := "Bearer sk-gap-0123456789abcdef0123456789abcdef" + got := maskBearer(full) + if len(got) >= len(full) { + t.Fatalf("masked value %q is not shorter than the input", got) + } + if got == full { + t.Fatal("token was not masked") + } +} diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index 4910cbd..d31e247 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -30,6 +30,14 @@ type Auditor interface { InsertAuditLog(row *store.AuditLog) error } +// DefaultMaxRequestBytes bounds the client request body when Options does not +// set MaxRequestBytes. +const DefaultMaxRequestBytes = 10 << 20 + +// defaultAuditMaxBody is the fallback cap on how much of each body is retained +// for the audit log. +const defaultAuditMaxBody = 65536 + // Upstream reverse-proxies OpenAI-compatible requests to xAI. type Upstream struct { base *url.URL @@ -39,6 +47,7 @@ type Upstream struct { auditor Auditor auditEnabled bool maxBody int + maxRequest int } // Options configures Upstream. @@ -48,7 +57,12 @@ type Options struct { Log *zap.Logger Auditor Auditor AuditEnabled bool + // MaxBodyBytes caps how much of each body is stored in the audit log. It + // never affects what is forwarded upstream. MaxBodyBytes int + // MaxRequestBytes caps the client request body that will be forwarded. + // Larger requests are rejected with 413. + MaxRequestBytes int } // New creates an Upstream proxy. @@ -67,7 +81,11 @@ func NewWithOptions(opts Options) (*Upstream, error) { } maxBody := opts.MaxBodyBytes if maxBody <= 0 { - maxBody = 65536 + maxBody = defaultAuditMaxBody + } + maxRequest := opts.MaxRequestBytes + if maxRequest <= 0 { + maxRequest = DefaultMaxRequestBytes } return &Upstream{ base: u, @@ -87,6 +105,7 @@ func NewWithOptions(opts Options) (*Upstream, error) { auditor: opts.Auditor, auditEnabled: opts.AuditEnabled && opts.Auditor != nil, maxBody: maxBody, + maxRequest: maxRequest, }, nil } @@ -106,11 +125,26 @@ func (u *Upstream) Handler() gin.HandlerFunc { func (u *Upstream) forward(c *gin.Context, retried bool) error { start := time.Now() - reqBody, reqTrunc, err := readLimited(c.Request.Body, u.maxBody) + // The request body is bounded by maxRequest, never by the audit cap: an + // oversized request must be rejected outright, not silently truncated into + // malformed JSON and forwarded upstream. + reqBody, tooLarge, err := readLimited(c.Request.Body, u.maxRequest) if err != nil { return err } - _ = c.Request.Body.Close() + if c.Request.Body != nil { + _ = c.Request.Body.Close() + } + if tooLarge { + c.AbortWithStatusJSON(http.StatusRequestEntityTooLarge, gin.H{ + "error": gin.H{ + "message": fmt.Sprintf("request body exceeds the %d byte limit", u.maxRequest), + "type": "invalid_request_error", + }, + }) + u.recordAudit(c, start, reqBody, nil, false, http.StatusRequestEntityTooLarge, nil) + return nil + } c.Request.Body = io.NopCloser(bytes.NewReader(reqBody)) token, err := u.tokens.GetAccessToken(c.Request.Context()) @@ -118,7 +152,7 @@ func (u *Upstream) forward(c *gin.Context, retried bool) error { c.JSON(http.StatusServiceUnavailable, gin.H{ "error": gin.H{"message": "upstream authentication unavailable", "type": "api_error"}, }) - u.recordAudit(c, start, reqBody, reqTrunc, nil, false, http.StatusServiceUnavailable, err) + u.recordAudit(c, start, reqBody, nil, false, http.StatusServiceUnavailable, err) return err } @@ -139,7 +173,7 @@ func (u *Upstream) forward(c *gin.Context, retried bool) error { resp, err := u.httpClient.Do(req) if err != nil { - u.recordAudit(c, start, reqBody, reqTrunc, nil, false, 0, err) + u.recordAudit(c, start, reqBody, nil, false, 0, err) return err } @@ -152,7 +186,7 @@ func (u *Upstream) forward(c *gin.Context, retried bool) error { c.JSON(http.StatusBadGateway, gin.H{ "error": gin.H{"message": "upstream unauthorized", "type": "api_error"}, }) - u.recordAudit(c, start, reqBody, reqTrunc, nil, false, http.StatusBadGateway, rerr) + u.recordAudit(c, start, reqBody, nil, false, http.StatusBadGateway, rerr) return rerr } return u.forward(c, true) @@ -171,7 +205,7 @@ func (u *Upstream) forward(c *gin.Context, retried bool) error { n, readErr := resp.Body.Read(buf) if n > 0 { if _, werr := c.Writer.Write(buf[:n]); werr != nil { - u.recordAudit(c, start, reqBody, reqTrunc, respBuf.Bytes(), respTrunc, resp.StatusCode, werr) + u.recordAudit(c, start, reqBody, respBuf.Bytes(), respTrunc, resp.StatusCode, werr) return werr } if canFlush { @@ -193,10 +227,10 @@ func (u *Upstream) forward(c *gin.Context, retried bool) error { } if readErr != nil { if readErr == io.EOF { - u.recordAudit(c, start, reqBody, reqTrunc, respBuf.Bytes(), respTrunc, resp.StatusCode, nil) + u.recordAudit(c, start, reqBody, respBuf.Bytes(), respTrunc, resp.StatusCode, nil) return nil } - u.recordAudit(c, start, reqBody, reqTrunc, respBuf.Bytes(), respTrunc, resp.StatusCode, readErr) + u.recordAudit(c, start, reqBody, respBuf.Bytes(), respTrunc, resp.StatusCode, readErr) return readErr } } @@ -206,7 +240,6 @@ func (u *Upstream) recordAudit( c *gin.Context, start time.Time, reqBody []byte, - reqTrunc bool, respBody []byte, respTrunc bool, status int, @@ -216,6 +249,12 @@ func (u *Upstream) recordAudit( return } + // Derive model/stream from the complete body, then clamp only what is + // persisted. The forwarded request itself was never truncated. + model := extractModel(reqBody) + stream := detectStream(reqBody) + storedReq, reqTrunc := clampForAudit(reqBody, u.maxBody) + row := &store.AuditLog{ RequestID: c.GetString(middleware.ContextRequestID), Method: c.Request.Method, @@ -225,12 +264,12 @@ func (u *Upstream) recordAudit( UserAgent: c.Request.UserAgent(), StatusCode: status, LatencyMS: time.Since(start).Milliseconds(), - RequestBody: string(reqBody), + RequestBody: string(storedReq), ResponseBody: string(respBody), RequestTruncated: reqTrunc, ResponseTruncated: respTrunc, - Stream: detectStream(reqBody), - Model: extractModel(reqBody), + Stream: stream, + Model: model, } if callErr != nil { row.Error = callErr.Error() @@ -249,14 +288,16 @@ func (u *Upstream) recordAudit( } } +// readLimited reads at most max bytes from r. The second return value reports +// that r held more than max bytes, i.e. the returned slice is incomplete. func readLimited(r io.Reader, max int) ([]byte, bool, error) { if r == nil { return nil, false, nil } if max <= 0 { - max = 65536 + max = DefaultMaxRequestBytes } - // Read one extra byte to detect truncation. + // Read one extra byte to detect that the limit was exceeded. data, err := io.ReadAll(io.LimitReader(r, int64(max)+1)) if err != nil { return nil, false, err @@ -267,6 +308,14 @@ func readLimited(r io.Reader, max int) ([]byte, bool, error) { return data, false, nil } +// clampForAudit caps a body for persistence and reports whether it was cut. +func clampForAudit(b []byte, max int) ([]byte, bool) { + if max > 0 && len(b) > max { + return b[:max], true + } + return b, false +} + func extractModel(body []byte) string { if len(body) == 0 { return "" @@ -317,10 +366,16 @@ func (u *Upstream) buildURL(reqURL *url.URL) string { return out.String() } +// copyHeaders relays client request headers upstream, dropping hop-by-hop +// headers (RFC 9110 §7.6.1) and every header that carries a credential to this +// proxy. Client credentials must never reach xAI: the proxy substitutes its own +// Authorization header. func copyHeaders(dst, src http.Header) { for k, vals := range src { switch strings.ToLower(k) { - case "authorization", "host", "content-length", "connection", "transfer-encoding": + case "authorization", "x-admin-key", "proxy-authorization", "cookie", + "host", "content-length", "connection", "transfer-encoding", + "keep-alive", "te", "trailer", "upgrade": continue default: for _, v := range vals { diff --git a/internal/proxy/proxy_test.go b/internal/proxy/proxy_test.go index 12ebb3c..fc6d236 100644 --- a/internal/proxy/proxy_test.go +++ b/internal/proxy/proxy_test.go @@ -2,14 +2,18 @@ package proxy import ( "context" + "encoding/json" "io" "net/http" "net/http/httptest" "strings" + "sync" "testing" "github.com/gin-gonic/gin" "go.uber.org/zap" + + "github.com/moveeeax/grok-auth-proxy/internal/store" ) type fakeTokens struct { @@ -27,6 +31,24 @@ func (f *fakeTokens) ForceRefresh(ctx context.Context) error { return nil } +type fakeAuditor struct { + mu sync.Mutex + rows []*store.AuditLog +} + +func (a *fakeAuditor) InsertAuditLog(row *store.AuditLog) error { + a.mu.Lock() + defer a.mu.Unlock() + a.rows = append(a.rows, row) + return nil +} + +func (a *fakeAuditor) all() []*store.AuditLog { + a.mu.Lock() + defer a.mu.Unlock() + return append([]*store.AuditLog(nil), a.rows...) +} + func TestProxyForwardsAndInjectsAuth(t *testing.T) { gin.SetMode(gin.TestMode) @@ -112,6 +134,186 @@ func TestProxyRetriesOn401(t *testing.T) { } } +// The audit body cap must never reach the wire. Before the fix, a request +// larger than MaxBodyBytes was silently cut mid-JSON and the truncated bytes +// were forwarded to xAI, which answered with a parse error the client could not +// explain. +func TestProxyForwardsBodiesLargerThanAuditCap(t *testing.T) { + gin.SetMode(gin.TestMode) + + var gotBody []byte + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotBody, _ = io.ReadAll(r.Body) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer up.Close() + + // A ~200 KiB request with a 1 KiB audit cap. + content := strings.Repeat("x", 200*1024) + sent := `{"model":"grok","messages":[{"role":"user","content":"` + content + `"}]}` + + p, err := NewWithOptions(Options{ + BaseURL: up.URL, + Tokens: &fakeTokens{token: "t"}, + Log: zap.NewNop(), + MaxBodyBytes: 1024, + }) + if err != nil { + t.Fatal(err) + } + r := gin.New() + r.POST("/v1/chat/completions", p.Handler()) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(sent))) + + if w.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) + } + if string(gotBody) != sent { + t.Fatalf("upstream received %d bytes, want %d (body was truncated in flight)", len(gotBody), len(sent)) + } + if !json.Valid(gotBody) { + t.Fatal("upstream received invalid JSON") + } +} + +// The audit row still respects MaxBodyBytes even though the wire body does not. +func TestProxyAuditTruncatesStoredBodyOnly(t *testing.T) { + gin.SetMode(gin.TestMode) + + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.Copy(io.Discard, r.Body) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer up.Close() + + aud := &fakeAuditor{} + p, err := NewWithOptions(Options{ + BaseURL: up.URL, + Tokens: &fakeTokens{token: "t"}, + Log: zap.NewNop(), + Auditor: aud, + AuditEnabled: true, + MaxBodyBytes: 256, + }) + if err != nil { + t.Fatal(err) + } + r := gin.New() + r.POST("/v1/chat/completions", p.Handler()) + + sent := `{"model":"grok","pad":"` + strings.Repeat("y", 4096) + `"}` + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(sent))) + if w.Code != http.StatusOK { + t.Fatalf("status=%d", w.Code) + } + + rows := aud.all() + if len(rows) != 1 { + t.Fatalf("audit rows=%d, want 1", len(rows)) + } + if len(rows[0].RequestBody) != 256 { + t.Fatalf("stored request body=%d bytes, want 256", len(rows[0].RequestBody)) + } + if !rows[0].RequestTruncated { + t.Fatal("request_truncated not set on a clipped audit row") + } + // Model is parsed from the full body, before clipping. + if rows[0].Model != "grok" { + t.Fatalf("model=%q", rows[0].Model) + } +} + +func TestProxyRejectsOversizedRequestBody(t *testing.T) { + gin.SetMode(gin.TestMode) + + upstreamHits := 0 + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamHits++ + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer up.Close() + + p, err := NewWithOptions(Options{ + BaseURL: up.URL, + Tokens: &fakeTokens{token: "t"}, + Log: zap.NewNop(), + MaxRequestBytes: 1024, + }) + if err != nil { + t.Fatal(err) + } + r := gin.New() + r.POST("/v1/chat/completions", p.Handler()) + + w := httptest.NewRecorder() + body := strings.NewReader(`{"pad":"` + strings.Repeat("z", 4096) + `"}`) + r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/v1/chat/completions", body)) + + if w.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("status=%d, want 413; body=%s", w.Code, w.Body.String()) + } + if upstreamHits != 0 { + t.Fatalf("oversized request reached upstream %d times", upstreamHits) + } + + // A request at the limit still goes through. + w = httptest.NewRecorder() + small := strings.NewReader(`{"model":"grok"}`) + r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/v1/chat/completions", small)) + if w.Code != http.StatusOK { + t.Fatalf("in-limit request status=%d", w.Code) + } + if upstreamHits != 1 { + t.Fatalf("upstream hits=%d, want 1", upstreamHits) + } +} + +// Credentials scoped to this proxy must never be relayed to xAI. +func TestProxyStripsClientCredentialHeaders(t *testing.T) { + gin.SetMode(gin.TestMode) + + var got http.Header + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = r.Header.Clone() + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer up.Close() + + p, err := New(up.URL, &fakeTokens{token: "upstream-jwt"}, zap.NewNop()) + if err != nil { + t.Fatal(err) + } + r := gin.New() + r.POST("/v1/chat/completions", p.Handler()) + + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{}`)) + req.Header.Set("Authorization", "Bearer sk-gap-clientkey") + req.Header.Set("X-Admin-Key", "admin-secret") + req.Header.Set("Proxy-Authorization", "Basic c2VjcmV0") + req.Header.Set("Cookie", "session=abc123") + req.Header.Set("X-Trace-Id", "keep-me") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status=%d", w.Code) + } + if v := got.Get("Authorization"); v != "Bearer upstream-jwt" { + t.Fatalf("upstream Authorization=%q", v) + } + for _, h := range []string{"X-Admin-Key", "Proxy-Authorization", "Cookie"} { + if v := got.Get(h); v != "" { + t.Fatalf("%s leaked upstream as %q", h, v) + } + } + if v := got.Get("X-Trace-Id"); v != "keep-me" { + t.Fatalf("benign header dropped: X-Trace-Id=%q", v) + } +} + func TestProxyStreamSSE(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/internal/server/server.go b/internal/server/server.go index 71a5e78..88d5d32 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -60,12 +60,13 @@ func New(deps Dependencies) (*Server, error) { } up, err := proxy.NewWithOptions(proxy.Options{ - BaseURL: deps.Config.Auth.UpstreamBase, - Tokens: deps.Auth, - Log: deps.Log, - Auditor: deps.Store, - AuditEnabled: deps.Config.Audit.Enabled, - MaxBodyBytes: deps.Config.Audit.MaxBodyBytes, + BaseURL: deps.Config.Auth.UpstreamBase, + Tokens: deps.Auth, + Log: deps.Log, + Auditor: deps.Store, + AuditEnabled: deps.Config.Audit.Enabled, + MaxBodyBytes: deps.Config.Audit.MaxBodyBytes, + MaxRequestBytes: deps.Config.Server.MaxRequestBytes, }) if err != nil { return nil, err @@ -101,11 +102,26 @@ func New(deps Dependencies) (*Server, error) { ad.GET("/audit/:id", adminH.GetAudit) } + readTimeout := deps.Config.Server.ReadTimeout + if readTimeout <= 0 { + readTimeout = 60 * time.Second + } + idleTimeout := deps.Config.Server.IdleTimeout + if idleTimeout <= 0 { + idleTimeout = 120 * time.Second + } srv := &http.Server{ Addr: deps.Config.Server.Addr, Handler: r, ReadHeaderTimeout: 10 * time.Second, - // WriteTimeout left zero for long-lived SSE streams + // Without ReadTimeout a client can dribble out a request body forever + // and hold the connection open; without IdleTimeout an idle keep-alive + // connection is never reclaimed. Either one is a trivial slowloris. + ReadTimeout: readTimeout, + IdleTimeout: idleTimeout, + MaxHeaderBytes: 1 << 20, + // WriteTimeout left zero on purpose: SSE responses stream for minutes + // and a write deadline would cut them off mid-stream. } return &Server{ diff --git a/internal/store/store.go b/internal/store/store.go index 5210243..e5af366 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -41,8 +41,8 @@ const ( // Soft readiness: tolerate brief pool pressure after a recent successful ping. dbOKGrace = 45 * time.Second - auditQueueSize = 4096 - auditWorkers = 2 + auditQueueSize = 4096 + auditWorkers = 2 ) // APIKey is the persisted API key record. The plaintext key is never stored. @@ -71,26 +71,26 @@ const AuthStateDefaultID = "default" // AuditLog is one proxied client request for admin audit. type AuditLog struct { - ID string `gorm:"primaryKey;size:36" json:"id"` - CreatedAt time.Time `gorm:"index" json:"created_at"` - RequestID string `gorm:"size:64;index" json:"request_id"` - APIKeyID string `gorm:"size:36;index" json:"api_key_id,omitempty"` - APIKeyName string `gorm:"size:128" json:"api_key_name,omitempty"` - APIKeyPrefix string `gorm:"size:32" json:"api_key_prefix,omitempty"` - Method string `gorm:"size:16" json:"method"` - Path string `gorm:"size:512;index" json:"path"` - Query string `gorm:"size:1024" json:"query,omitempty"` - ClientIP string `gorm:"size:64" json:"client_ip,omitempty"` - UserAgent string `gorm:"size:512" json:"user_agent,omitempty"` - StatusCode int `gorm:"index" json:"status_code"` - LatencyMS int64 `json:"latency_ms"` - Model string `gorm:"size:128;index" json:"model,omitempty"` - Stream bool `json:"stream"` - RequestBody string `gorm:"type:text" json:"request_body,omitempty"` - ResponseBody string `gorm:"type:text" json:"response_body,omitempty"` - RequestTruncated bool `json:"request_truncated"` - ResponseTruncated bool `json:"response_truncated"` - Error string `gorm:"size:1024" json:"error,omitempty"` + ID string `gorm:"primaryKey;size:36" json:"id"` + CreatedAt time.Time `gorm:"index" json:"created_at"` + RequestID string `gorm:"size:64;index" json:"request_id"` + APIKeyID string `gorm:"size:36;index" json:"api_key_id,omitempty"` + APIKeyName string `gorm:"size:128" json:"api_key_name,omitempty"` + APIKeyPrefix string `gorm:"size:32" json:"api_key_prefix,omitempty"` + Method string `gorm:"size:16" json:"method"` + Path string `gorm:"size:512;index" json:"path"` + Query string `gorm:"size:1024" json:"query,omitempty"` + ClientIP string `gorm:"size:64" json:"client_ip,omitempty"` + UserAgent string `gorm:"size:512" json:"user_agent,omitempty"` + StatusCode int `gorm:"index" json:"status_code"` + LatencyMS int64 `json:"latency_ms"` + Model string `gorm:"size:128;index" json:"model,omitempty"` + Stream bool `json:"stream"` + RequestBody string `gorm:"type:text" json:"request_body,omitempty"` + ResponseBody string `gorm:"type:text" json:"response_body,omitempty"` + RequestTruncated bool `json:"request_truncated"` + ResponseTruncated bool `json:"response_truncated"` + Error string `gorm:"size:1024" json:"error,omitempty"` } // AuditListFilter filters audit log queries. diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 2d7cb20..3cfa66f 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -95,12 +95,12 @@ func TestAuthStateAndAudit(t *testing.T) { } row := &AuditLog{ - RequestID: "req-1", - Method: "POST", - Path: "/v1/chat/completions", - StatusCode: 200, - Model: "grok-4.5", - RequestBody: `{"model":"grok-4.5"}`, + RequestID: "req-1", + Method: "POST", + Path: "/v1/chat/completions", + StatusCode: 200, + Model: "grok-4.5", + RequestBody: `{"model":"grok-4.5"}`, ResponseBody: `{"ok":true}`, } if err := s.InsertAuditLog(row); err != nil {