fix(security): 移除上游密钥派生片段
This commit is contained in:
24 files changed
+153
-73
No files matched your search
@@ -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.
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -44,7 +44,6 @@ type Attempt struct {
|
||||
GroupID uint
|
||||
GroupName string
|
||||
KeyID uint
|
||||
KeyMask string
|
||||
UpstreamModel string
|
||||
StatusCode int
|
||||
DurationMs int64
|
||||
|
||||
@@ -37,7 +37,6 @@ func TestRequestTelemetryContractUsesExactFieldAllowlist(t *testing.T) {
|
||||
"GroupID",
|
||||
"GroupName",
|
||||
"KeyID",
|
||||
"KeyMask",
|
||||
"UpstreamModel",
|
||||
"StatusCode",
|
||||
"DurationMs",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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<RequestLogItemDto> = {}): RequestLogItemD
|
||||
error_summary: '<script data-canary="log-summary">unsafe()</script>',
|
||||
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(
|
||||
'<script data-canary="log-summary">unsafe()</script>',
|
||||
)
|
||||
|
||||
@@ -331,9 +331,7 @@ function estimatedCost(log: RequestLogItemDto): string {
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('monitor.logs.drawer.upstreamKey') }}</dt>
|
||||
<dd>
|
||||
<code>{{ attempt.key_mask }}</code> · #{{ attempt.key_id }}
|
||||
</dd>
|
||||
<dd>#{{ attempt.key_id }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('monitor.logs.drawer.upstreamModel') }}</dt>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user