From 0d4b25795aaa342ad682a68e1eb2046efdc8afc4 Mon Sep 17 00:00:00 2001 From: Seefs <40468931+seefs001@users.noreply.github.com> Date: Tue, 19 May 2026 18:28:03 +0800 Subject: [PATCH] fix: expose param override audits for sensitive message fields (#4974) --- relay/common/override.go | 32 ++++--- relay/common/override_test.go | 90 +++++++++++++++++++ .../components/dialogs/details-dialog.tsx | 5 +- 3 files changed, 114 insertions(+), 13 deletions(-) diff --git a/relay/common/override.go b/relay/common/override.go index 1a28303f..5368061d 100644 --- a/relay/common/override.go +++ b/relay/common/override.go @@ -26,13 +26,20 @@ const ( var errSourceHeaderNotFound = errors.New("source header does not exist") -var paramOverrideKeyAuditPaths = map[string]struct{}{ - "model": {}, - "original_model": {}, - "upstream_model": {}, - "service_tier": {}, - "inference_geo": {}, - "speed": {}, +var paramOverrideSensitivePathPrefixes = []string{ + "model", + "original_model", + "upstream_model", + "service_tier", + "inference_geo", + "speed", + "messages", + "input", + "instructions", + "system", + "contents", + "systemInstruction", + "system_instruction", } type paramOverrideAuditRecorder struct { @@ -206,6 +213,7 @@ func shouldEnableParamOverrideAudit(paramOverride map[string]interface{}) bool { if operations, ok := tryParseOperations(paramOverride); ok { for _, operation := range operations { if shouldAuditParamPath(strings.TrimSpace(operation.Path)) || + shouldAuditParamPath(strings.TrimSpace(operation.From)) || shouldAuditParamPath(strings.TrimSpace(operation.To)) { return true } @@ -255,15 +263,19 @@ func shouldAuditParamPath(path string) bool { if common.DebugEnabled { return true } - _, ok := paramOverrideKeyAuditPaths[path] - return ok + for _, prefix := range paramOverrideSensitivePathPrefixes { + if path == prefix || strings.HasPrefix(path, prefix+".") { + return true + } + } + return false } func shouldAuditOperation(mode, path, from, to string) bool { if common.DebugEnabled { return true } - for _, candidate := range []string{path, to} { + for _, candidate := range []string{path, from, to} { if shouldAuditParamPath(candidate) { return true } diff --git a/relay/common/override_test.go b/relay/common/override_test.go index 6e35cf73..8c7b7772 100644 --- a/relay/common/override_test.go +++ b/relay/common/override_test.go @@ -12,6 +12,7 @@ import ( "github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/setting/model_setting" "github.com/samber/lo" + "github.com/stretchr/testify/require" ) func TestApplyParamOverrideTrimPrefix(t *testing.T) { @@ -2184,6 +2185,95 @@ func TestApplyParamOverrideWithRelayInfoRecordsOnlyKeyOperationsWhenDebugDisable } } +func TestApplyParamOverrideWithRelayInfoRecordsConversationBodyOperationsWhenDebugDisabled(t *testing.T) { + originalDebugEnabled := common2.DebugEnabled + common2.DebugEnabled = false + t.Cleanup(func() { + common2.DebugEnabled = originalDebugEnabled + }) + + info := &RelayInfo{ + ChannelMeta: &ChannelMeta{ + ParamOverride: map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "mode": "replace", + "path": "messages.0.content", + "from": "hello", + "to": "hi", + }, + map[string]interface{}{ + "mode": "set", + "path": "input.0.content.0.text", + "value": "rewritten response input", + }, + map[string]interface{}{ + "mode": "set", + "path": "instructions", + "value": "new instruction", + }, + map[string]interface{}{ + "mode": "append", + "path": "contents.0.parts", + "value": map[string]interface{}{"text": "new gemini part"}, + }, + map[string]interface{}{ + "mode": "copy", + "from": "system", + "to": "metadata.system_copy", + }, + map[string]interface{}{ + "mode": "set", + "path": "temperature", + "value": 0.1, + }, + }, + }, + }, + } + + out, err := ApplyParamOverrideWithRelayInfo([]byte(`{ + "messages":[{"role":"user","content":"hello world"}], + "input":[{"role":"user","content":[{"type":"input_text","text":"original response input"}]}], + "instructions":"old instruction", + "system":"old system", + "contents":[{"role":"user","parts":[{"text":"hello gemini"}]}], + "temperature":0.7 + }`), info) + require.NoError(t, err) + assertJSONEqual(t, `{ + "messages":[{"role":"user","content":"hi world"}], + "input":[{"role":"user","content":[{"type":"input_text","text":"rewritten response input"}]}], + "instructions":"new instruction", + "system":"old system", + "contents":[{"role":"user","parts":[{"text":"hello gemini"},{"text":"new gemini part"}]}], + "temperature":0.1, + "metadata":{"system_copy":"old system"} + }`, string(out)) + + require.Equal(t, []string{ + "replace messages.0.content from hello to hi", + "set input.0.content.0.text = rewritten response input", + "set instructions = new instruction", + "append contents.0.parts with {\"text\":\"new gemini part\"}", + "copy system -> metadata.system_copy", + }, info.ParamOverrideAudit) +} + +func TestShouldAuditParamPathUsesFieldBoundaryPrefixMatching(t *testing.T) { + originalDebugEnabled := common2.DebugEnabled + common2.DebugEnabled = false + t.Cleanup(func() { + common2.DebugEnabled = originalDebugEnabled + }) + + require.True(t, shouldAuditParamPath("messages")) + require.True(t, shouldAuditParamPath("messages.0.content")) + require.True(t, shouldAuditParamPath("systemInstruction.parts.0.text")) + require.False(t, shouldAuditParamPath("model_name")) + require.False(t, shouldAuditParamPath("message")) +} + func assertJSONEqual(t *testing.T, want, got string) { t.Helper() diff --git a/web/default/src/features/usage-logs/components/dialogs/details-dialog.tsx b/web/default/src/features/usage-logs/components/dialogs/details-dialog.tsx index 2785a552..929695a9 100644 --- a/web/default/src/features/usage-logs/components/dialogs/details-dialog.tsx +++ b/web/default/src/features/usage-logs/components/dialogs/details-dialog.tsx @@ -985,9 +985,8 @@ export function DetailsDialog(props: DetailsDialogProps) { )} - {/* Param override (admin only) */} - {props.isAdmin && - other?.po && + {/* Param override */} + {other?.po && Array.isArray(other.po) && other.po.length > 0 && (