From 201617b893b3afb3c8d3257cf9939ff56dbd35fa Mon Sep 17 00:00:00 2001 From: tbphp Date: Wed, 29 Jul 2026 12:51:11 +0800 Subject: [PATCH] =?UTF-8?q?fix(security):=20=E7=A7=BB=E9=99=A4=E4=B8=8A?= =?UTF-8?q?=E6=B8=B8=E5=AF=86=E9=92=A5=E6=B4=BE=E7=94=9F=E7=89=87=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SECURITY.md | 6 ++ internal/control/request_logs.go | 2 - internal/control/request_logs_test.go | 24 ++++++- internal/gateway/dialects_integration_test.go | 5 +- internal/gateway/handler.go | 12 ++-- internal/gateway/handler_test.go | 65 +++++++++++++++---- internal/gateway/request_log.go | 5 -- internal/gateway/request_log_test.go | 10 +-- internal/gateway/security_integration_test.go | 17 ++++- internal/gateway/stream_integration_test.go | 3 +- internal/requestlog/mapper.go | 1 - internal/requestlog/mapper_test.go | 1 - internal/requestlog/query_test.go | 32 +++++++++ internal/requestlog/service_test.go | 2 - internal/requestlog/test_helpers_test.go | 1 - internal/requestlog/types.go | 1 - internal/telemetry/requestlog.go | 1 - internal/telemetry/requestlog_test.go | 1 - web/e2e/visual-api.ts | 1 - web/src/app/resources/request-logs.test.ts | 13 +++- web/src/app/resources/request-logs.ts | 10 --- .../features/monitor/LogDetailDrawer.test.ts | 8 +-- web/src/features/monitor/LogDetailDrawer.vue | 4 +- web/src/features/monitor/LogsTab.test.ts | 1 - 24 files changed, 153 insertions(+), 73 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 0901715c..0345affe 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -14,3 +14,9 @@ The repository's 2.0 candidate receives security fixes while its first public re Report suspected vulnerabilities through [GitHub Private Vulnerability Reporting](https://github.com/tbphp/gpt-load/security/advisories/new). Please do not open a public issue for an undisclosed vulnerability. Use the private report so the maintainers can investigate and coordinate a fix before public disclosure. + +## Provider credential exposure boundary + +Provider keys and any derived fragments, including prefix/suffix masks, must not appear in data-plane response headers or bodies, request logs, or the request-log management API. Request-log attempts identify an upstream key only by its internal `key_id`. + +The authenticated Group upstream-key management list is the sole approved exception: it may return the existing mask so an operator can identify a key in the provider console. It remains protected by management authentication and never exposes plaintext credentials. diff --git a/internal/control/request_logs.go b/internal/control/request_logs.go index c0d844fc..16ff0d27 100644 --- a/internal/control/request_logs.go +++ b/internal/control/request_logs.go @@ -53,7 +53,6 @@ type requestLogAttemptResponse struct { GroupID uint `json:"group_id"` GroupName string `json:"group_name"` KeyID uint `json:"key_id"` - KeyMask string `json:"key_mask"` UpstreamModel string `json:"upstream_model"` StatusCode int `json:"status_code"` DurationMs int64 `json:"duration_ms"` @@ -359,7 +358,6 @@ func mapRequestLogListResponse(page requestlog.Page) (requestLogListResponse, er GroupID: attempt.GroupID, GroupName: attempt.GroupName, KeyID: attempt.KeyID, - KeyMask: attempt.KeyMask, UpstreamModel: attempt.UpstreamModel, StatusCode: attempt.StatusCode, DurationMs: attempt.DurationMs, diff --git a/internal/control/request_logs_test.go b/internal/control/request_logs_test.go index a67b4893..5f7850bc 100644 --- a/internal/control/request_logs_test.go +++ b/internal/control/request_logs_test.go @@ -124,7 +124,18 @@ func TestRequestLogEndpointReturnsOpaqueCursorAndSafeDTO(t *testing.T) { AffinityHit: true, UsageState: usage.StateNotApplicable, CostState: pricing.CostStateNotApplicable, - Attempts: nil, + Attempts: []requestlog.Attempt{{ + Sequence: 1, + GroupID: 12, + GroupName: "Primary", + KeyID: 11, + UpstreamModel: "upstream-model", + StatusCode: 200, + DurationMs: 1234, + FailureCategory: telemetry.FailureCategoryOK, + Action: telemetry.ActionTerminate, + Committed: true, + }}, }}, NextCursor: nextCursor, }, @@ -176,8 +187,15 @@ func TestRequestLogEndpointReturnsOpaqueCursorAndSafeDTO(t *testing.T) { t.Fatalf("first response envelope = %#v", envelope) } attempts, ok := envelope.Data.Items[0]["attempts"].([]any) - if !ok || len(attempts) != 0 { - t.Fatalf("attempts = %#v, want []", envelope.Data.Items[0]["attempts"]) + if !ok || len(attempts) != 1 { + t.Fatalf("attempts = %#v, want one", envelope.Data.Items[0]["attempts"]) + } + attempt, ok := attempts[0].(map[string]any) + if !ok || attempt["key_id"] != float64(11) { + t.Fatalf("attempt = %#v, want key_id 11", attempts[0]) + } + if _, exists := attempt["key_mask"]; exists { + t.Fatalf("attempt exposes key_mask: %#v", attempt) } for _, forbidden := range []string{"headers", "body", "url"} { if strings.Contains(strings.ToLower(recorder.Body.String()), forbidden) { diff --git a/internal/gateway/dialects_integration_test.go b/internal/gateway/dialects_integration_test.go index d527eb36..9679e9c5 100644 --- a/internal/gateway/dialects_integration_test.go +++ b/internal/gateway/dialects_integration_test.go @@ -23,7 +23,6 @@ import ( "gpt-load/internal/platform/encryption" platformhttp "gpt-load/internal/platform/httpclient" "gpt-load/internal/platform/redact" - "gpt-load/internal/platform/utils" "gpt-load/internal/protocol" "gpt-load/internal/state" "gpt-load/internal/testutil/fakeupstream" @@ -613,8 +612,8 @@ func TestAnthropicGatewayStream(t *testing.T) { if err != nil || !strings.Contains(string(rest), "message_stop") || strings.Contains(string(rest), `"code":`) { t.Fatalf("remaining stream = %q, %v", rest, err) } - if response.Header.Get(debugHeaderKey) != utils.MaskAPIKey("sk-primary") { - t.Fatalf("debug key = %q", response.Header.Get(debugHeaderKey)) + if values := response.Header.Values(debugHeaderKey); len(values) != 0 { + t.Fatalf("debug key = %#v, want absent", values) } } diff --git a/internal/gateway/handler.go b/internal/gateway/handler.go index d2964852..69bf78d1 100644 --- a/internal/gateway/handler.go +++ b/internal/gateway/handler.go @@ -16,7 +16,6 @@ import ( "gpt-load/internal/dialect" "gpt-load/internal/health" "gpt-load/internal/platform/encryption" - "gpt-load/internal/platform/utils" "gpt-load/internal/ratelimit" "gpt-load/internal/scheduler" "gpt-load/internal/state" @@ -30,8 +29,9 @@ const ( fixedCooldown = time.Minute blacklistFailureThreshold = 3 debugHeaderGroup = "X-GPTLoad-Group" - debugHeaderKey = "X-GPTLoad-Key" - debugHeaderAttempts = "X-GPTLoad-Attempts" + // debugHeaderKey remains reserved so an upstream cannot inject it downstream. + debugHeaderKey = "X-GPTLoad-Key" + debugHeaderAttempts = "X-GPTLoad-Attempts" ) var debugHeaderNames = []string{ @@ -385,7 +385,7 @@ func (handler *Handler) executeAttempts( } attempts++ - updateDebugHeaders(ginContext.Writer.Header(), selection.Group.Name, apiKey, attempts) + updateDebugHeaders(ginContext.Writer.Header(), selection.Group.Name, attempts) selectedKeyID := selection.KeyID input := ForwardInput{ Dialect: selectedDialect, Group: selection.Group, APIKey: apiKey, Request: parsed, @@ -549,13 +549,11 @@ func (handler *Handler) executeAttempts( func initializeDebugHeaders(headers http.Header) { headers.Set(debugHeaderGroup, "") - headers.Set(debugHeaderKey, "") headers.Set(debugHeaderAttempts, "0") } -func updateDebugHeaders(headers http.Header, group, apiKey string, attempts int) { +func updateDebugHeaders(headers http.Header, group string, attempts int) { headers.Set(debugHeaderGroup, group) - headers.Set(debugHeaderKey, utils.MaskAPIKey(apiKey)) headers.Set(debugHeaderAttempts, strconv.Itoa(attempts)) } diff --git a/internal/gateway/handler_test.go b/internal/gateway/handler_test.go index b8b2870f..b78d0673 100644 --- a/internal/gateway/handler_test.go +++ b/internal/gateway/handler_test.go @@ -737,7 +737,7 @@ func TestHandlerInitializesDebugHeadersBeforeValidation(t *testing.T) { recorder := httptest.NewRecorder() engine.ServeHTTP(recorder, request) - assertDebugHeaders(t, recorder.Header(), "", "", "0") + assertDebugHeaders(t, recorder.Header(), "", "0") if len(forwarder.inputs)+len(forwarder.streamInputs) != 0 { t.Fatal("validation failure reached upstream forwarder") } @@ -882,7 +882,7 @@ func TestHandlerEnforcesModelUTF8ByteLimitBeforeAttempt(t *testing.T) { if response.Code != test.wantCode { t.Fatalf("response code = %q, want %q", response.Code, test.wantCode) } - assertDebugHeaders(t, recorder.Header(), "", "", "0") + assertDebugHeaders(t, recorder.Header(), "", "0") if event.ClientModel != "" || len(event.Attempts) != 0 || strings.Contains(event.ErrorSummary, test.model) { t.Fatalf("rejected model leaked into RequestLog event: %#v", event) @@ -943,7 +943,7 @@ func TestHandlerServesLocalModelEndpoints(t *testing.T) { t.Fatalf("response = %d %s", recorder.Code, recorder.Body.String()) } assertJSONEqual(t, recorder.Body.String(), test.expected) - assertDebugHeaders(t, recorder.Header(), "", "", "0") + assertDebugHeaders(t, recorder.Header(), "", "0") }) } } @@ -960,7 +960,7 @@ func TestHandlerModelEndpointsApplyFiltersAndKeepEmptyShape(t *testing.T) { recorder := httptest.NewRecorder() engine.ServeHTTP(recorder, request) assertJSONEqual(t, recorder.Body.String(), `{"object":"list","data":[{"id":"alpha","object":"model","created":1735689600,"owned_by":"gpt-load"}]}`) - assertDebugHeaders(t, recorder.Header(), "", "", "0") + assertDebugHeaders(t, recorder.Header(), "", "0") }) t.Run("protocol denied keeps official empty envelope", func(t *testing.T) { @@ -972,7 +972,7 @@ func TestHandlerModelEndpointsApplyFiltersAndKeepEmptyShape(t *testing.T) { recorder := httptest.NewRecorder() engine.ServeHTTP(recorder, request) assertJSONEqual(t, recorder.Body.String(), `{"object":"list","data":[]}`) - assertDebugHeaders(t, recorder.Header(), "", "", "0") + assertDebugHeaders(t, recorder.Header(), "", "0") }) } @@ -988,7 +988,7 @@ func TestHandlerModelEndpointsRequireValidAccessKey(t *testing.T) { if recorder.Code != http.StatusUnauthorized || !strings.Contains(recorder.Body.String(), reasonInvalidAccessKey.Code) { t.Fatalf("response = %d %s", recorder.Code, recorder.Body.String()) } - assertDebugHeaders(t, recorder.Header(), "", "", "0") + assertDebugHeaders(t, recorder.Header(), "", "0") } } @@ -1158,6 +1158,44 @@ func (panicReadCloser) Close() error { return nil } +func TestHandlerNeverExposesProviderKeyFragmentsInResponseHeaders(t *testing.T) { + const providerKey = "prov-fragment-test-abcdefghwxyz" + forwarder := &scriptedForwarder{results: []UpstreamResult{{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: []byte(`{"ok":true}`), + }}} + engine, _, _ := newHandlerTestRuntime(t, forwarder, providerKey) + request := httptest.NewRequest( + http.MethodPost, + "/v1/chat/completions", + bytes.NewBufferString(`{"model":"gpt-4o"}`), + ) + request.Header.Set("Authorization", "Bearer gl-client") + recorder := httptest.NewRecorder() + + engine.ServeHTTP(recorder, request) + + if values := recorder.Header().Values(debugHeaderKey); len(values) != 0 { + t.Fatalf("%s = %#v, want absent", debugHeaderKey, values) + } + if recorder.Header().Get(debugHeaderGroup) != "openai" || + recorder.Header().Get(debugHeaderAttempts) != "1" { + t.Fatalf("debug headers = %#v", recorder.Header()) + } + serialized := fmt.Sprint(recorder.Header()) + for _, forbidden := range []string{ + providerKey, + providerKey[:4], + providerKey[len(providerKey)-4:], + utils.MaskAPIKey(providerKey), + } { + if strings.Contains(serialized, forbidden) { + t.Fatalf("response headers expose provider key fragment %q: %s", forbidden, serialized) + } + } +} + func TestHandlerReportsFinalAttemptInDebugHeaders(t *testing.T) { tests := []struct { name string @@ -1197,11 +1235,7 @@ func TestHandlerReportsFinalAttemptInDebugHeaders(t *testing.T) { recorder := httptest.NewRecorder() engine.ServeHTTP(recorder, request) - last := forwarder.inputs[len(forwarder.inputs)-1] - assertDebugHeaders(t, recorder.Header(), "openai", utils.MaskAPIKey(last.APIKey), tt.wantAttempts) - if strings.Contains(strings.Join(recorder.Header().Values("X-GPTLoad-Key"), ","), last.APIKey) { - t.Fatal("debug header exposed plaintext key") - } + assertDebugHeaders(t, recorder.Header(), "openai", tt.wantAttempts) }) } } @@ -1327,13 +1361,13 @@ func TestHandlerRejectsSpoofedDebugHeaders(t *testing.T) { recorder := httptest.NewRecorder() engine.ServeHTTP(recorder, request) - assertDebugHeaders(t, recorder.Header(), "openai", utils.MaskAPIKey("sk-real-key"), "1") + assertDebugHeaders(t, recorder.Header(), "openai", "1") } -func assertDebugHeaders(t *testing.T, headers http.Header, group, key, attempts string) { +func assertDebugHeaders(t *testing.T, headers http.Header, group, attempts string) { t.Helper() want := map[string]string{ - "X-GPTLoad-Group": group, "X-GPTLoad-Key": key, "X-GPTLoad-Attempts": attempts, + debugHeaderGroup: group, debugHeaderAttempts: attempts, } for name, value := range want { values, exists := headers[http.CanonicalHeaderKey(name)] @@ -1341,6 +1375,9 @@ func assertDebugHeaders(t *testing.T, headers http.Header, group, key, attempts t.Errorf("%s = %#v (exists=%t), want exactly [%q]", name, values, exists, value) } } + if values := headers.Values(debugHeaderKey); len(values) != 0 { + t.Errorf("%s = %#v, want absent", debugHeaderKey, values) + } } func TestReadRequestBodyHonorsLimit(t *testing.T) { diff --git a/internal/gateway/request_log.go b/internal/gateway/request_log.go index bd98a687..1c6b906f 100644 --- a/internal/gateway/request_log.go +++ b/internal/gateway/request_log.go @@ -14,7 +14,6 @@ import ( "gpt-load/internal/health" "gpt-load/internal/platform/redact" - "gpt-load/internal/platform/utils" "gpt-load/internal/protocol" "gpt-load/internal/scheduler" "gpt-load/internal/state" @@ -136,7 +135,6 @@ func (recorder *requestRecorder) recordAttempt( ) return recorder.appendAttempt( selection, - apiKey, result, telemetryFailureCategory(decision.Category), telemetryAction(decision.Action), @@ -168,7 +166,6 @@ func (recorder *requestRecorder) recordStreamAttempt( ) return recorder.appendAttempt( selection, - apiKey, result, category, action, @@ -185,7 +182,6 @@ func (recorder *requestRecorder) recordStreamAttempt( func (recorder *requestRecorder) appendAttempt( selection scheduler.Selection, - apiKey string, result UpstreamResult, category telemetry.FailureCategory, action telemetry.Action, @@ -206,7 +202,6 @@ func (recorder *requestRecorder) appendAttempt( GroupID: selection.GroupID, GroupName: selection.Group.Name, KeyID: selection.KeyID, - KeyMask: utils.MaskAPIKey(apiKey), UpstreamModel: selection.UpstreamModelID, StatusCode: result.StatusCode, DurationMs: duration.Milliseconds(), diff --git a/internal/gateway/request_log_test.go b/internal/gateway/request_log_test.go index 8d12dab5..3b74ed10 100644 --- a/internal/gateway/request_log_test.go +++ b/internal/gateway/request_log_test.go @@ -160,8 +160,8 @@ func TestRequestRecorderBindsUsageToRecordedAttempt(t *testing.T) { t.Run(test.name, func(t *testing.T) { sink := &recordingRequestLogSink{} recorder := newRequestRecorder(sink, "req-usage-bind", time.Unix(100, 0), 9, protocol.OpenAI, func() time.Time { return time.Unix(101, 0) }) - recorder.appendAttempt(scheduler.Selection{GroupID: 11, Group: state.GroupView{Name: "first"}, KeyID: 21}, "sk-first", UpstreamResult{}, telemetry.FailureCategoryOK, telemetry.ActionTerminate, "", "", time.Unix(100, 0), time.Unix(100, 0)) - second := recorder.appendAttempt(scheduler.Selection{GroupID: 12, Group: state.GroupView{Name: "second"}, KeyID: 22}, "sk-second", UpstreamResult{StatusCode: http.StatusOK}, telemetry.FailureCategoryOK, telemetry.ActionTerminate, "", "", time.Unix(100, 0), time.Unix(100, 0)) + recorder.appendAttempt(scheduler.Selection{GroupID: 11, Group: state.GroupView{Name: "first"}, KeyID: 21}, UpstreamResult{}, telemetry.FailureCategoryOK, telemetry.ActionTerminate, "", "", time.Unix(100, 0), time.Unix(100, 0)) + second := recorder.appendAttempt(scheduler.Selection{GroupID: 12, Group: state.GroupView{Name: "second"}, KeyID: 22}, UpstreamResult{StatusCode: http.StatusOK}, telemetry.FailureCategoryOK, telemetry.ActionTerminate, "", "", time.Unix(100, 0), time.Unix(100, 0)) recorder.completeResponse(UpstreamResult{StatusCode: http.StatusOK, Usage: test.result}, health.Result{}, "provider", second) recorder.emit() @@ -180,7 +180,7 @@ func TestRequestRecorderBindsUsageToRecordedAttempt(t *testing.T) { func TestRequestRecorderNon2xxKeepsAttributionButNotApplicable(t *testing.T) { sink := &recordingRequestLogSink{} recorder := newRequestRecorder(sink, "req-usage-429", time.Unix(100, 0), 9, protocol.OpenAI, func() time.Time { return time.Unix(101, 0) }) - index := recorder.appendAttempt(scheduler.Selection{GroupID: 12, Group: state.GroupView{Name: "second"}, KeyID: 22}, "sk-second", UpstreamResult{StatusCode: http.StatusTooManyRequests}, telemetry.FailureCategoryRateLimited, telemetry.ActionRetry, "", "", time.Unix(100, 0), time.Unix(100, 0)) + index := recorder.appendAttempt(scheduler.Selection{GroupID: 12, Group: state.GroupView{Name: "second"}, KeyID: 22}, UpstreamResult{StatusCode: http.StatusTooManyRequests}, telemetry.FailureCategoryRateLimited, telemetry.ActionRetry, "", "", time.Unix(100, 0), time.Unix(100, 0)) recorder.completeResponse(UpstreamResult{StatusCode: http.StatusTooManyRequests, Usage: usage.Result{State: usage.StateComplete, Tokens: usage.Tokens{Output: 30}}}, health.Result{Category: health.FailureCategoryRateLimited}, "provider", index) recorder.emit() @@ -195,7 +195,7 @@ func TestRequestRecorderInvalidAttemptIndexDoesNotForgeUsageAttribution(t *testi t.Run(fmt.Sprintf("index_%d", index), func(t *testing.T) { sink := &recordingRequestLogSink{} recorder := newRequestRecorder(sink, "req-usage-invalid", time.Unix(100, 0), 9, protocol.OpenAI, func() time.Time { return time.Unix(101, 0) }) - recorder.appendAttempt(scheduler.Selection{GroupID: 12, Group: state.GroupView{Name: "second"}, KeyID: 22}, "sk-second", UpstreamResult{}, telemetry.FailureCategoryOK, telemetry.ActionTerminate, "", "", time.Unix(100, 0), time.Unix(100, 0)) + recorder.appendAttempt(scheduler.Selection{GroupID: 12, Group: state.GroupView{Name: "second"}, KeyID: 22}, UpstreamResult{}, telemetry.FailureCategoryOK, telemetry.ActionTerminate, "", "", time.Unix(100, 0), time.Unix(100, 0)) recorder.bindUsage(index, usage.Result{State: usage.StateComplete, Tokens: usage.Tokens{Output: 30}}, true) recorder.emit() @@ -209,7 +209,7 @@ func TestRequestRecorderInvalidAttemptIndexDoesNotForgeUsageAttribution(t *testi func TestRequestRecorderDownstreamFailureKeepsBoundUsage(t *testing.T) { sink := &recordingRequestLogSink{} recorder := newRequestRecorder(sink, "req-usage-write", time.Unix(100, 0), 9, protocol.OpenAI, func() time.Time { return time.Unix(101, 0) }) - index := recorder.appendAttempt(scheduler.Selection{GroupID: 12, Group: state.GroupView{Name: "second"}, KeyID: 22}, "sk-second", UpstreamResult{StatusCode: http.StatusOK}, telemetry.FailureCategoryOK, telemetry.ActionTerminate, "", "", time.Unix(100, 0), time.Unix(100, 0)) + index := recorder.appendAttempt(scheduler.Selection{GroupID: 12, Group: state.GroupView{Name: "second"}, KeyID: 22}, UpstreamResult{StatusCode: http.StatusOK}, telemetry.FailureCategoryOK, telemetry.ActionTerminate, "", "", time.Unix(100, 0), time.Unix(100, 0)) result := usage.Result{State: usage.StateComplete, Tokens: usage.Tokens{UncachedInput: 80, CacheRead: 20, Output: 30}} recorder.completeResponse(UpstreamResult{StatusCode: http.StatusOK, Usage: result}, health.Result{}, "provider", index) recorder.completeDownstreamWrite(http.StatusOK) diff --git a/internal/gateway/security_integration_test.go b/internal/gateway/security_integration_test.go index 05619add..495a730e 100644 --- a/internal/gateway/security_integration_test.go +++ b/internal/gateway/security_integration_test.go @@ -12,6 +12,7 @@ import ( "github.com/sirupsen/logrus" "gpt-load/internal/platform/redact" + "gpt-load/internal/platform/utils" ) func TestGatewayNeverExposesPlaintextKeys(t *testing.T) { @@ -154,9 +155,19 @@ func assertNoPlaintextSecrets(t *testing.T, recorder *httptest.ResponseRecorder, t.Helper() surfaces := []string{recorder.Body.String(), fmt.Sprint(recorder.Header()), logs} for _, secret := range secrets { - for _, surface := range surfaces { - if strings.Contains(surface, secret) { - t.Fatalf("gateway exposed plaintext secret %q in %q", secret, surface) + if len(secret) <= 16 { + t.Fatalf("test secret %q is too short to verify masked fragments", secret) + } + for _, forbidden := range []string{ + secret, + secret[:4], + secret[len(secret)-4:], + utils.MaskAPIKey(secret), + } { + for _, surface := range surfaces { + if strings.Contains(surface, forbidden) { + t.Fatalf("gateway exposed provider key fragment %q in %q", forbidden, surface) + } } } } diff --git a/internal/gateway/stream_integration_test.go b/internal/gateway/stream_integration_test.go index 2edaff77..5efc1904 100644 --- a/internal/gateway/stream_integration_test.go +++ b/internal/gateway/stream_integration_test.go @@ -30,7 +30,6 @@ import ( "gpt-load/internal/platform/encryption" platformhttp "gpt-load/internal/platform/httpclient" "gpt-load/internal/platform/redact" - "gpt-load/internal/platform/utils" "gpt-load/internal/protocol" "gpt-load/internal/state" "gpt-load/internal/testutil/fakeupstream" @@ -294,7 +293,7 @@ func TestHandlerStreamingDebugHeadersRejectUpstreamSpoofing(t *testing.T) { }) recorder := performStreamingRequest(engine) - assertDebugHeaders(t, recorder.Header(), "stream-group", utils.MaskAPIKey("sk-real-stream-key"), "1") + assertDebugHeaders(t, recorder.Header(), "stream-group", "1") if strings.Contains(recorder.Body.String(), "sk-real-stream-key") || strings.Contains(recorder.Body.String(), "sk-spoofed-plaintext") { t.Fatalf("stream response leaked a plaintext key: %s", recorder.Body.String()) } diff --git a/internal/requestlog/mapper.go b/internal/requestlog/mapper.go index e64ed94c..157e072a 100644 --- a/internal/requestlog/mapper.go +++ b/internal/requestlog/mapper.go @@ -30,7 +30,6 @@ func mapEvent( GroupID: attempt.GroupID, GroupName: attempt.GroupName, KeyID: attempt.KeyID, - KeyMask: attempt.KeyMask, UpstreamModel: projectModel(attempt.UpstreamModel), StatusCode: attempt.StatusCode, DurationMs: attempt.DurationMs, diff --git a/internal/requestlog/mapper_test.go b/internal/requestlog/mapper_test.go index 1aee49cd..cd49089d 100644 --- a/internal/requestlog/mapper_test.go +++ b/internal/requestlog/mapper_test.go @@ -35,7 +35,6 @@ func TestMapEventPersistsCompletedAtZeroUsageAndJSONArray(t *testing.T) { GroupID: 12, GroupName: "Anthropic Primary", KeyID: 34, - KeyMask: "sk-ant-...wxyz", UpstreamModel: "claude-sonnet-5", StatusCode: 429, DurationMs: 800, diff --git a/internal/requestlog/query_test.go b/internal/requestlog/query_test.go index b6f07059..fae90a23 100644 --- a/internal/requestlog/query_test.go +++ b/internal/requestlog/query_test.go @@ -1,6 +1,7 @@ package requestlog import ( + "bytes" "context" "encoding/json" "math" @@ -61,6 +62,37 @@ func TestDecodeRequestLogRowsPreservesUsageCostAttribution(t *testing.T) { } } +func TestDecodeRequestLogRowsIgnoresHistoricalKeyMask(t *testing.T) { + rows := []models.RequestLog{{ + ID: "00000000-0000-4000-8000-000000000605", + CreatedAt: time.Date(2026, time.July, 27, 0, 0, 0, 0, time.UTC), + Protocol: string(protocol.OpenAI), + Status: string(telemetry.RequestStatusSuccess), + UsageState: string(usage.StateNotApplicable), + CostState: string(pricing.CostStateNotApplicable), + Attempts: models.JSON( + `[{"sequence":1,"group_id":7,"group_name":"Primary","key_id":11,"key_mask":"prov****safe","upstream_model":"model","status_code":200,"duration_ms":10,"failure_category":"ok","action":"terminate","will_retry":false,"error_code":"","error_summary":"","committed":true}]`, + ), + }} + + records, err := decodeRequestLogRows(rows) + if err != nil { + t.Fatalf("decodeRequestLogRows() error = %v", err) + } + if len(records) != 1 || len(records[0].Attempts) != 1 { + t.Fatalf("records = %#v, want one historical attempt", records) + } + encoded, err := json.Marshal(records[0].Attempts[0]) + if err != nil { + t.Fatalf("marshal decoded attempt: %v", err) + } + for _, forbidden := range [][]byte{[]byte(`"key_mask"`), []byte("prov"), []byte("safe")} { + if bytes.Contains(encoded, forbidden) { + t.Fatalf("decoded attempt retains historical key material %q: %s", forbidden, encoded) + } + } +} + func TestDecodeRequestLogRowsRejectsInvalidUsageCostValues(t *testing.T) { base := models.RequestLog{ ID: "00000000-0000-4000-8000-000000000602", diff --git a/internal/requestlog/service_test.go b/internal/requestlog/service_test.go index fe83e003..b2e802df 100644 --- a/internal/requestlog/service_test.go +++ b/internal/requestlog/service_test.go @@ -280,7 +280,6 @@ func TestServiceWarningsExcludeEventContentAndThrottle(t *testing.T) { } sensitive := testEvent("request-id-must-not-appear") sensitive.ErrorSummary = "authorization: Bearer sk-sensitive-warning-secret" - sensitive.Attempts[0].KeyMask = "sensitive-key-mask" service.Emit(sensitive) service.Emit(sensitive) @@ -291,7 +290,6 @@ func TestServiceWarningsExcludeEventContentAndThrottle(t *testing.T) { for _, forbidden := range []string{ sensitive.RequestID, sensitive.ErrorSummary, - sensitive.Attempts[0].KeyMask, "sk-sensitive-warning-secret", } { if bytes.Contains(output.Bytes(), []byte(forbidden)) { diff --git a/internal/requestlog/test_helpers_test.go b/internal/requestlog/test_helpers_test.go index b1a91a56..7ade8089 100644 --- a/internal/requestlog/test_helpers_test.go +++ b/internal/requestlog/test_helpers_test.go @@ -272,7 +272,6 @@ func testEvent(id string) telemetry.RequestEvent { GroupID: 7, GroupName: "primary", KeyID: 8, - KeyMask: "sk-...mask", UpstreamModel: "upstream-model", StatusCode: 200, DurationMs: 20, diff --git a/internal/requestlog/types.go b/internal/requestlog/types.go index 1fd59875..5466691c 100644 --- a/internal/requestlog/types.go +++ b/internal/requestlog/types.go @@ -29,7 +29,6 @@ type Attempt struct { GroupID uint `json:"group_id"` GroupName string `json:"group_name"` KeyID uint `json:"key_id"` - KeyMask string `json:"key_mask"` UpstreamModel string `json:"upstream_model"` StatusCode int `json:"status_code"` DurationMs int64 `json:"duration_ms"` diff --git a/internal/telemetry/requestlog.go b/internal/telemetry/requestlog.go index 23d8224c..fb9687da 100644 --- a/internal/telemetry/requestlog.go +++ b/internal/telemetry/requestlog.go @@ -44,7 +44,6 @@ type Attempt struct { GroupID uint GroupName string KeyID uint - KeyMask string UpstreamModel string StatusCode int DurationMs int64 diff --git a/internal/telemetry/requestlog_test.go b/internal/telemetry/requestlog_test.go index 6b2224cd..5deb3749 100644 --- a/internal/telemetry/requestlog_test.go +++ b/internal/telemetry/requestlog_test.go @@ -37,7 +37,6 @@ func TestRequestTelemetryContractUsesExactFieldAllowlist(t *testing.T) { "GroupID", "GroupName", "KeyID", - "KeyMask", "UpstreamModel", "StatusCode", "DurationMs", diff --git a/web/e2e/visual-api.ts b/web/e2e/visual-api.ts index 9e1a0f96..23775471 100644 --- a/web/e2e/visual-api.ts +++ b/web/e2e/visual-api.ts @@ -236,7 +236,6 @@ const requestLogPage = { group_id: group.id, group_name: group.name, key_id: 91, - key_mask: 'sk-v****1234', upstream_model: visualFixtureData.modelName, status_code: 429, duration_ms: 842, diff --git a/web/src/app/resources/request-logs.test.ts b/web/src/app/resources/request-logs.test.ts index 622db581..fdf34c12 100644 --- a/web/src/app/resources/request-logs.test.ts +++ b/web/src/app/resources/request-logs.test.ts @@ -29,7 +29,6 @@ const item = { group_id: 7, group_name: 'Primary', key_id: 11, - key_mask: 'sk-u****safe', upstream_model: 'gpt-upstream', status_code: 200, duration_ms: 40, @@ -67,7 +66,6 @@ describe('RequestLog resource', () => { { ...item, status: 'unknown' }, { ...item, duration_ms: Number.POSITIVE_INFINITY }, { ...item, access_key: { ...item.access_key, key: 'plaintext' } }, - { ...item, attempts: [{ ...item.attempts[0], key_mask: 'plaintext' }] }, { ...item, attempts: [{ ...item.attempts[0], action: 'future_action' }] }, { ...item, attempts: [{ ...item.attempts[0], secret_token: 'plaintext' }] }, ])('rejects an unsafe rendered log field %#j', (unsafe) => { @@ -76,6 +74,17 @@ describe('RequestLog resource', () => { ) }) + it('rejects a legacy upstream key mask instead of rendering it', () => { + const unsafe = { + ...item, + attempts: [{ ...item.attempts[0], key_mask: 'sk-u****safe' }], + } + + expect(() => projectRequestLogPage({ items: [unsafe], next_cursor: null })).toThrow( + InvalidResponseError, + ) + }) + it('keeps drawer selection outside both list identity and transport query', async () => { const filters = { status: 'error', diff --git a/web/src/app/resources/request-logs.ts b/web/src/app/resources/request-logs.ts index 0dbe3b68..bc5423a8 100644 --- a/web/src/app/resources/request-logs.ts +++ b/web/src/app/resources/request-logs.ts @@ -48,7 +48,6 @@ export interface RequestLogAttemptDto { group_id: number group_name: string key_id: number - key_mask: string upstream_model: string status_code: number duration_ms: number @@ -95,7 +94,6 @@ export interface RequestLogPageDto { } const requestIDPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ -const canonicalMask = /^(?:\*{4}|.{4}\*{4}.{4})$/u const protocols = ['openai', 'anthropic', 'gemini'] as const const statuses = ['success', 'error', 'incomplete', 'canceled'] as const const failureCategories = [ @@ -116,7 +114,6 @@ const attemptFields = [ 'group_id', 'group_name', 'key_id', - 'key_mask', 'upstream_model', 'status_code', 'duration_ms', @@ -168,12 +165,6 @@ function projectRequestID(value: unknown): string { return result } -function projectMask(value: unknown): string { - const result = projectString(value) - if (!canonicalMask.test(result)) invalidResponse() - return result -} - function projectStatusCode(value: unknown): number { return projectSafeInteger(value, { minimum: 0, maximum: 999 }) } @@ -196,7 +187,6 @@ function projectAttempt(value: unknown): RequestLogAttemptDto { group_id: projectSafeInteger(record.group_id, { minimum: 1 }), group_name: projectNonBlankString(record.group_name), key_id: projectSafeInteger(record.key_id, { minimum: 1 }), - key_mask: projectMask(record.key_mask), upstream_model: projectNonBlankString(record.upstream_model), status_code: projectStatusCode(record.status_code), duration_ms: projectSafeInteger(record.duration_ms, { minimum: 0 }), diff --git a/web/src/features/monitor/LogDetailDrawer.test.ts b/web/src/features/monitor/LogDetailDrawer.test.ts index d9ac1601..df2ac849 100644 --- a/web/src/features/monitor/LogDetailDrawer.test.ts +++ b/web/src/features/monitor/LogDetailDrawer.test.ts @@ -23,7 +23,6 @@ function attempt( group_id: 7, group_name: 'Historical Group', key_id: 21, - key_mask: 'sk-u****safe', upstream_model: 'gpt-upstream', status_code: 429, duration_ms: 40, @@ -52,9 +51,9 @@ function logFixture(overrides: Partial = {}): RequestLogItemD error_summary: '', affinity_hit: false, attempts: [ - attempt(3, { group_name: 'Third Group', key_mask: 'sk-u****thrd' }), + attempt(3, { group_name: 'Third Group' }), attempt(1), - attempt(2, { group_name: 'Second Group', key_mask: 'sk-u****scnd' }), + attempt(2, { group_name: 'Second Group' }), ], group_id: 7, usage_state: 'complete', @@ -106,7 +105,8 @@ describe('LogDetailDrawer', () => { ), ).toEqual(['log-attempt-1', 'log-attempt-2', 'log-attempt-3']) expect(bodyElement('[data-test="log-attempt-1"]').textContent).toContain('Historical Group') - expect(bodyElement('[data-test="log-attempt-1"]').textContent).toContain('sk-u****safe') + expect(bodyElement('[data-test="log-attempt-1"]').textContent).toContain('#21') + expect(document.body.textContent).not.toContain('sk-u') expect(document.body.textContent).toContain( '', ) diff --git a/web/src/features/monitor/LogDetailDrawer.vue b/web/src/features/monitor/LogDetailDrawer.vue index bf99dc76..8c3b2bca 100644 --- a/web/src/features/monitor/LogDetailDrawer.vue +++ b/web/src/features/monitor/LogDetailDrawer.vue @@ -331,9 +331,7 @@ function estimatedCost(log: RequestLogItemDto): string {
{{ t('monitor.logs.drawer.upstreamKey') }}
-
- {{ attempt.key_mask }} ยท #{{ attempt.key_id }} -
+
#{{ attempt.key_id }}
{{ t('monitor.logs.drawer.upstreamModel') }}
diff --git a/web/src/features/monitor/LogsTab.test.ts b/web/src/features/monitor/LogsTab.test.ts index 07d2fe46..98987896 100644 --- a/web/src/features/monitor/LogsTab.test.ts +++ b/web/src/features/monitor/LogsTab.test.ts @@ -26,7 +26,6 @@ function attemptFixture(sequence: number): RequestLogAttemptDto { group_id: 7, group_name: 'Historical Group', key_id: 21, - key_mask: 'sk-u****safe', upstream_model: 'gpt-upstream', status_code: 200, duration_ms: 100,