From 842241ae89d02b3a5317299d4d6b061f895f2753 Mon Sep 17 00:00:00 2001 From: Michael Tarassov Date: Sun, 26 Jul 2026 03:55:48 +0800 Subject: [PATCH] fix: stop truncating proxied requests and harden proxy auth surface The audit body cap was doing double duty as the limit on the request body actually forwarded upstream. Any request over GAP_AUDIT_MAX_BODY_BYTES (default 64 KiB) was silently cut mid-JSON and relayed to xAI, which answered with an unexplainable parse error. The two limits are now separate: GAP_SERVER_MAX_REQUEST_BYTES (default 10 MiB) bounds what is proxied and returns 413 above it, while the audit cap only clips what is persisted. As a side effect the audit row's model/stream fields are now parsed from the complete body instead of the clipped copy. Security hardening on the same paths: - AdminAuth compared the admin key with a byte-wise !=, which returns on the first differing byte and leaks the key through response latency. Both sides are now SHA-256'd and compared with crypto/subtle.ConstantTimeCompare, so the compare is constant time and length independent. An unset or whitespace-only admin key now rejects every caller instead of authenticating one that echoes it back. - The proxy relayed X-Admin-Key, Proxy-Authorization and Cookie to xAI. Those are credentials scoped to this proxy and are now stripped, along with the remaining RFC 9110 hop-by-hop headers. - http.Server had no ReadTimeout and no IdleTimeout, so a client could dribble out a body or park idle keep-alive connections forever. Both are now set and configurable. WriteTimeout stays zero on purpose for SSE streams. - config rejects a non-HTTPS auth.upstream_base or auth.issuer for non-loopback hosts; those requests carry the Grok access token and refresh token, which must not cross the network in cleartext. - The metrics middleware labelled unmatched routes with the raw URL path, letting any caller mint one Prometheus series per 404'd URL and exhaust process memory. Unmatched requests now share one label value. - CORS sets Vary: Origin on every response when the origin list is restricted, not just on a match, so a shared cache cannot replay an allowed origin's Access-Control-Allow-Origin to a disallowed one. Adds middleware tests (none existed) plus proxy and config tests; each new test was confirmed to fail with its fix reverted. Adds a CI workflow running gofmt/tidy/build/vet/test/test -race, since only the Docker image build ran on PRs before, and gofmt-formats the three files that gate. Co-Authored-By: Claude Opus 5 (1M context) --- .env.example | 5 + .github/workflows/ci.yml | 52 +++++ README.md | 5 +- configs/config.example.yaml | 8 + .../grok-auth-proxy/templates/configmap.yaml | 3 + deploy/helm/grok-auth-proxy/values.yaml | 6 + docs/API.md | 8 +- internal/config/config.go | 80 +++++++ internal/config/config_test.go | 75 +++++++ internal/metrics/metrics.go | 18 +- internal/middleware/middleware.go | 28 ++- internal/middleware/middleware_test.go | 159 ++++++++++++++ internal/proxy/proxy.go | 87 ++++++-- internal/proxy/proxy_test.go | 202 ++++++++++++++++++ internal/server/server.go | 30 ++- internal/store/store.go | 44 ++-- internal/store/store_test.go | 12 +- 17 files changed, 759 insertions(+), 63 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 internal/middleware/middleware_test.go 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 {