This commit is contained in:
luc
2026-08-30 15:30:08 +03:00
committed by GitHub
10 changed files with 364 additions and 8 deletions
+6
View File
@@ -141,3 +141,9 @@ func MaskEmail(email string) string {
func MaskSensitiveInfo(str string) string {
return kitutil.MaskSensitiveInfo(str)
}
// MaskSensitiveKeys masks only credential-shaped values (no URL/domain/IP
// rewriting), for enum-like fields where full masking would mangle values.
func MaskSensitiveKeys(str string) string {
return kitutil.MaskSensitiveKeys(str)
}
+13 -1
View File
@@ -95,8 +95,20 @@ func AlphaSearchHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError
if contentType := httpResp.Header.Get("Content-Type"); contentType != "" {
c.Writer.Header().Set("Content-Type", contentType)
}
// F-65: read + mask the upstream body before relaying. /v1/alpha/search
// used to io.Copy the raw body to the client, bypassing the F-13/F-20
// masking system; an upstream that echoes the channel Authorization value
// in a 200 body would leak the key. (F-36 already caps body size here via
// DoApiRequest's LimitReader.)
responseBody, err := io.ReadAll(httpResp.Body)
if err != nil {
return types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError)
}
// F-65/F-6826: credential-only masking for successful bodies so resource
// URLs in search results are preserved while channel keys are masked.
responseBody = []byte(common.MaskSensitiveKeys(string(responseBody)))
c.Writer.WriteHeader(httpResp.StatusCode)
if _, err := io.Copy(c.Writer, httpResp.Body); err != nil {
if _, err := c.Writer.Write(responseBody); err != nil {
return types.NewError(err, types.ErrorCodeDoRequestFailed, types.ErrOptionWithSkipRetry())
}
+3
View File
@@ -187,6 +187,9 @@ func OpenaiRealtimeHandler(c *gin.Context, info *relaycommon.RelayInfo) (*types.
localUsage.OutputTokenDetails.AudioTokens += audioToken
}
// F-13 layer A: upstream realtime frames are relayed verbatim;
// mask error frames that may echo the channel key before forwarding.
message = []byte(helper.MaskStreamErrorData(string(message)))
err = helper.WssString(c, clientConn, string(message))
if err != nil {
errChan <- fmt.Errorf("error writing to client: %v", err)
+154
View File
@@ -3,6 +3,7 @@ package helper
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
@@ -74,6 +75,158 @@ func ExtendWriteDeadline(c *gin.Context) {
_ = http.NewResponseController(c.Writer).SetWriteDeadline(time.Now().Add(streamWriteTimeout))
}
// MaskStreamErrorData masks sensitive values inside SSE error chunks before
// they reach a downstream handler. Upstreams may echo the gateway's upstream
// Authorization header (i.e. the channel API key) in streamed error messages;
// the non-stream error path already masks, but streamed chunks used to be
// relayed verbatim (F-13 layer A).
//
// F-20: also handles chunks that carry the key outside a top-level "error" key
// (e.g. {"message":"invalid key: sk-..."}) and masks non-message fields inside
// the error object (code/type/param), which the first F-13 masker skipped.
func MaskStreamErrorData(data string) string {
if !json.Valid([]byte(data)) {
return data
}
var probe map[string]json.RawMessage
if err := json.Unmarshal([]byte(data), &probe); err != nil {
return data
}
changed := false
maskField := func(raw json.RawMessage, masker func(string) string) (json.RawMessage, bool) {
var s string
if err := json.Unmarshal(raw, &s); err != nil || s == "" {
return raw, false
}
masked := masker(s)
if masked == s {
return raw, false
}
b, err := json.Marshal(masked)
if err != nil {
return raw, false
}
return b, true
}
// Top-level fields commonly used by error-ish chunks (F-20b: no "error" key).
for _, key := range []string{"message", "code", "type"} {
if raw, ok := probe[key]; ok {
masker := common.MaskSensitiveInfo
if key != "message" {
// Enum-like fields must not be URL/domain/IP-mangled.
masker = common.MaskSensitiveKeys
}
if b, ok2 := maskField(raw, masker); ok2 {
probe[key] = b
changed = true
}
}
}
// "error" key: object or string.
if raw, ok := probe["error"]; ok && len(raw) > 0 {
switch raw[0] {
case '{':
var errObj map[string]json.RawMessage
if err := json.Unmarshal(raw, &errObj); err == nil {
for _, key := range []string{"message", "code", "type", "param"} {
if v, ok := errObj[key]; ok {
masker := common.MaskSensitiveInfo
if key != "message" {
masker = common.MaskSensitiveKeys
}
if b, ok2 := maskField(v, masker); ok2 {
errObj[key] = b
changed = true
}
}
}
if b, err := json.Marshal(errObj); err == nil {
probe["error"] = b
}
}
case '"':
if b, ok2 := maskField(raw, common.MaskSensitiveKeys); ok2 {
probe["error"] = b
changed = true
}
}
}
if !changed {
// F-20 residual: upstreams may also echo the channel key inside
// non-error chunks (e.g. a mid-stream "usage" frame carrying unknown
// metadata). Recursively mask string values of the chunk, but only
// re-marshal when something actually changed so legitimate chunks
// keep their original byte layout (JSON key order preserved).
if strings.Contains(data, "usage") {
var walk func(raw json.RawMessage) (json.RawMessage, bool)
walk = func(raw json.RawMessage) (json.RawMessage, bool) {
var obj map[string]json.RawMessage
if err := json.Unmarshal(raw, &obj); err == nil {
objChanged := false
for k, v := range obj {
// Never rewrite assistant content fields: masking is for
// error metadata, and re-marshalling a completion chunk
// can corrupt valid output (F-6826).
if k == "content" || k == "delta" || k == "text" || k == "input_text" {
continue
}
nv, ch := walk(v)
if ch {
obj[k] = nv
objChanged = true
}
}
if objChanged {
if b, err := json.Marshal(obj); err == nil {
return b, true
}
}
return raw, false
}
var arr []json.RawMessage
if err := json.Unmarshal(raw, &arr); err == nil {
arrChanged := false
for i, v := range arr {
nv, ch := walk(v)
if ch {
arr[i] = nv
arrChanged = true
}
}
if arrChanged {
if b, err := json.Marshal(arr); err == nil {
return b, true
}
}
return raw, false
}
if b, ok2 := maskField(raw, common.MaskSensitiveKeys); ok2 {
return b, true
}
return raw, false
}
anyChanged := false
for key, raw := range probe {
nv, ch := walk(raw)
if ch {
probe[key] = nv
anyChanged = true
}
}
if anyChanged {
if b, err := json.Marshal(probe); err == nil {
return string(b)
}
}
}
return data
}
if b, err := json.Marshal(probe); err == nil {
return string(b)
}
return data
}
func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo, dataHandler func(data string, sr *StreamResult)) {
if resp == nil || dataHandler == nil {
@@ -262,6 +415,7 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
if data == "" {
continue
}
data = MaskStreamErrorData(data)
if !strings.HasPrefix(data, "[DONE]") {
info.SetFirstResponseTime()
info.ReceivedResponseCount++
+16 -4
View File
@@ -77,7 +77,8 @@ func RelayMidjourneyImage(c *gin.Context) {
if resp.StatusCode != http.StatusOK {
responseBody, _ := io.ReadAll(resp.Body)
c.JSON(resp.StatusCode, gin.H{
"error": string(responseBody),
// F-42: mask upstream error body before echoing it to the client.
"error": common.MaskSensitiveInfo(string(responseBody)),
})
return
}
@@ -240,7 +241,9 @@ func RelaySwapFace(c *gin.Context, info *relaycommon.RelayInfo) *dto.MidjourneyR
MjId: midjResponse.Result,
Prompt: "InsightFace",
PromptEn: "",
Description: midjResponse.Description,
// F-64: mask upstream-controlled text before persistence; task fetch
// replays these fields to the client verbatim.
Description: common.MaskSensitiveKeys(midjResponse.Description),
State: "",
SubmitTime: info.StartTime.UnixNano() / int64(time.Millisecond),
StartTime: time.Now().UnixNano() / int64(time.Millisecond),
@@ -327,6 +330,9 @@ func RelayMidjourneyTaskImageSeed(c *gin.Context) *dto.MidjourneyResponse {
if err != nil {
return service.MidjourneyErrorWrapper(constant.MjRequestError, "unmarshal_response_body_failed")
}
// F-64: the marshaled upstream response is copied to the client verbatim;
// use credential-only masking so image/resource URLs survive.
respBody = []byte(common.MaskSensitiveKeys(string(respBody)))
service.IOCopyBytesGracefully(c, nil, respBody)
return nil
}
@@ -559,7 +565,8 @@ func RelayMidjourneySubmit(c *gin.Context, relayInfo *relaycommon.RelayInfo) *dt
MjId: midjResponse.Result,
Prompt: midjRequest.Prompt,
PromptEn: "",
Description: midjResponse.Description,
// F-64: mask upstream-controlled text before persistence.
Description: common.MaskSensitiveKeys(midjResponse.Description),
State: "",
SubmitTime: time.Now().UnixNano() / int64(time.Millisecond),
StartTime: 0,
@@ -582,7 +589,9 @@ func RelayMidjourneySubmit(c *gin.Context, relayInfo *relaycommon.RelayInfo) *dt
}
if midjResponse.Code != 1 && midjResponse.Code != 21 && midjResponse.Code != 22 {
//非1-提交成功,21-任务已存在和22-排队中,则记录错误原因
midjourneyTask.FailReason = midjResponse.Description
// F-64: upstream error bodies may echo the channel key (mj-api-secret);
// mask before persistence so /task/:id/fetch cannot replay it.
midjourneyTask.FailReason = common.MaskSensitiveKeys(midjResponse.Description)
consumeQuota = false
}
@@ -658,6 +667,9 @@ func RelayMidjourneySubmit(c *gin.Context, relayInfo *relaycommon.RelayInfo) *dt
responseBody = []byte(newBody)
}
//resp.Body = io.NopCloser(bytes.NewBuffer(responseBody))
// F-64: raw upstream body is relayed verbatim; mask it before writing so an
// upstream that echoes mj-api-secret (or any channel key) cannot leak it.
responseBody = []byte(common.MaskSensitiveKeys(string(responseBody)))
bodyReader := io.NopCloser(bytes.NewBuffer(responseBody))
//for k, v := range resp.Header {
+100
View File
@@ -12,6 +12,41 @@ var (
maskIPPattern = regexp.MustCompile(`\b(?:\d{1,3}\.){3}\d{1,3}\b`)
// maskApiKeyPattern matches patterns like 'api_key:xxx' or "api_key:xxx" to mask the API key value
maskApiKeyPattern = regexp.MustCompile(`(['"]?)api_key:([^\s'"]+)(['"]?)`)
// maskBareKeyPattern masks bare upstream API key formats that providers may
// echo in error messages (sk-..., gsk_..., hf_..., xai-..., AIza...).
maskBareKeyPattern = regexp.MustCompile(`(?i)(\bsk-[A-Za-z0-9_\-]{8,}|\bgsk_[A-Za-z0-9_\-]{8,}|\bhf_[A-Za-z0-9_\-]{8,}|\bxai-[A-Za-z0-9_\-]{8,}|\bAIza[A-Za-z0-9_\-]{8,})`)
// maskMoreKeyPrefixPattern covers additional provider key prefixes that
// F-13's bare pattern missed (Perplexity, NVIDIA NIM, Replicate, personal
// access tokens, GitHub tokens, GitLab tokens).
maskMoreKeyPrefixPattern = regexp.MustCompile(`(?i)(\bpplx-[A-Za-z0-9_\-]{8,}|\bnvapi-[A-Za-z0-9_\-]{8,}|\br8_[A-Za-z0-9_\-]{8,}|\bpat_[A-Za-z0-9_\-]{8,}|\brt_[A-Za-z0-9_\-]{8,}|\bghp_[A-Za-z0-9_\-]{8,}|\bgho_[A-Za-z0-9_\-]{8,}|\bgithub_pat_[A-Za-z0-9_\-]{8,}|\bglpat-[A-Za-z0-9_\-]{8,})`)
// maskJwtPattern masks JWT-shaped credentials (header.payload.signature).
maskJwtPattern = regexp.MustCompile(`(?i)\beyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+`)
// maskKeyValuePattern masks prefix-less long values adjacent to credential
// field names (api_key:/token:/secret:/authorization:/password:/credential:),
// which covers providers that issue prefix-less keys (Cohere, Cloudflare...).
maskKeyValuePattern = regexp.MustCompile(`(?i)((?:\\?["'])?\b(?:api[_-]?key|key|token|secret|authorization|password|credential)(?:\\?["'])?\s*[:=]\s*(?:\\?["'])?)([A-Za-z0-9_\-\.]{12,})(?:\\?["'])?`)
// maskBearerPattern masks "Bearer <credential>" tokens.
maskBearerPattern = regexp.MustCompile(`(?i)(\bbearer\s+)([A-Za-z0-9_\-\.]{12,})`)
// maskInvalidKeyPattern masks prefix-less values after "invalid/bad/wrong/
// unauthorized/incorrect key <value>" phrasing (no colon).
maskInvalidKeyPattern = regexp.MustCompile(`(?i)\b(invalid|bad|wrong|unauthorized|incorrect|missing)\s+key\s+([A-Za-z0-9_\-\.]{12,})`)
// maskPemBlockPattern masks PEM private key blocks, including the
// JSON-escaped form (\\n) used when a service-account key JSON is echoed
// back inside a string. Covers RSA/EC/Ed25519/OPENSSH/ENCRYPTED keys.
maskPemBlockPattern = regexp.MustCompile(`(?s)-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----`)
// maskServiceAccountFieldPattern masks prefix-less long values adjacent to
// service-account credential fields (private_key, client_secret, client_id)
// and handles JSON-escaped quotes (\"field\":\"value\") inside nested
// strings — the base key-value pattern misses both.
maskServiceAccountFieldPattern = regexp.MustCompile(`(?i)((?:\\?["'])?\b(?:private[_-]?key|client[_-]?secret|client[_-]?id)(?:\\?["'])?\s*[:=]\s*(?:\\?["'])?)([A-Za-z0-9_\-\.]{12,})(?:\\?["'])?`)
)
// maskHostTail returns the tail parts of a domain/host that should be preserved.
@@ -130,5 +165,70 @@ func MaskSensitiveInfo(str string) string {
// Mask API keys (e.g., "api_key:AIzaSyAAAaUooTUni8AdaOkSRMda30n_Q4vrV70" -> "api_key:***")
str = maskApiKeyPattern.ReplaceAllString(str, "${1}api_key:***${3}")
// Mask bare key formats (defense in depth; upstreams may echo the gateway's
// Authorization value verbatim in error messages — F-13 layer B).
str = maskBareKeyPattern.ReplaceAllStringFunc(str, func(k string) string {
if len(k) > 6 {
return k[:4] + "***"
}
return k
})
// F-43: additional prefixed formats, JWTs, and key-word-adjacent values.
str = maskMoreKeyPrefixPattern.ReplaceAllStringFunc(str, func(k string) string {
if len(k) > 6 {
return k[:4] + "***"
}
return k
})
str = maskJwtPattern.ReplaceAllString(str, "eyJ***")
str = maskKeyValuePattern.ReplaceAllString(str, "${1}***${3}")
str = maskBearerPattern.ReplaceAllString(str, "${1}***")
str = maskInvalidKeyPattern.ReplaceAllString(str, "${1} key ***")
str = maskServiceAccountFieldPattern.ReplaceAllString(str, "${1}***")
// F-67: PEM private key blocks (Vertex service-account keys and similar
// channel credentials) must never be echoed to clients even when the
// surrounding JSON field names were masked.
str = maskPemBlockPattern.ReplaceAllStringFunc(str, func(block string) string {
if len(block) > 30 {
return block[:20] + "***[REDACTED PEM]***"
}
return "***[REDACTED PEM]***"
})
return str
}
// MaskSensitiveKeys masks only credential-shaped values (API keys, tokens,
// JWTs, "key:/token:" values) without URL/domain/IP rewriting, so enum-like
// fields (type/code/param in SSE events) are never mangled (F-20 regression).
func MaskSensitiveKeys(str string) string {
str = maskApiKeyPattern.ReplaceAllString(str, "${1}api_key:***${3}")
str = maskBareKeyPattern.ReplaceAllStringFunc(str, func(k string) string {
if len(k) > 6 {
return k[:4] + "***"
}
return k
})
str = maskMoreKeyPrefixPattern.ReplaceAllStringFunc(str, func(k string) string {
if len(k) > 6 {
return k[:4] + "***"
}
return k
})
str = maskJwtPattern.ReplaceAllString(str, "eyJ***")
str = maskKeyValuePattern.ReplaceAllString(str, "${1}***${3}")
str = maskBearerPattern.ReplaceAllString(str, "${1}***")
str = maskInvalidKeyPattern.ReplaceAllString(str, "${1} key ***")
str = maskServiceAccountFieldPattern.ReplaceAllString(str, "${1}***")
// F-67: PEM private key blocks (Vertex service-account keys and similar
// channel credentials) must never be echoed to clients even when the
// surrounding JSON field names were masked.
str = maskPemBlockPattern.ReplaceAllStringFunc(str, func(block string) string {
if len(block) > 30 {
return block[:20] + "***[REDACTED PEM]***"
}
return "***[REDACTED PEM]***"
})
return str
}
@@ -0,0 +1,38 @@
package kitutil
import (
"testing"
)
func TestMaskSensitiveInfoKeyFormats(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{"openai sk-", "invalid key sk-abc1234567890", "invalid key sk-a***"},
{"openrouter sk-or-v1-", "invalid key sk-or-v1-abcdefghijklmnop", "invalid key sk-o***"},
{"anthropic sk-ant-", "invalid key sk-ant-api03-abcdefghijklmnop", "invalid key sk-a***"},
{"groq gsk_", "invalid key gsk_abcdefghijklmnop", "invalid key gsk_***"},
{"hf hf_", "invalid key hf_abcdefghijklmnop", "invalid key hf_a***"},
{"xai xai-", "invalid key xai-abcdefghijklmnop", "invalid key xai-***"},
{"gemini AIza", "invalid key AIzaSyABCDEFGHIJKLMNOPQRSTUVWXYZ012345", "invalid key AIza***"},
{"perplexity pplx-", "invalid key pplx-abcdefghijklmnop", "invalid key pplx***"},
{"nvidia nvapi-", "invalid key nvapi-abcdefghijklmnop", "invalid key nvap***"},
{"replicate r8_", "invalid key r8_abcdefghijklmnop", "invalid key r8_a***"},
{"cohere no-prefix 40", "invalid key abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOP", "invalid key ***"},
{"cloudflare no-prefix 40", "invalid key 0123456789abcdef0123456789abcdef01234567", "invalid key ***"},
{"jwt eyJ", "invalid token eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signature1234567890", "invalid token eyJ***"},
{"token-colon no prefix", "invalid token: abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOP", "invalid token: ***"},
{"key-colon no prefix", "invalid key: abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOP", "invalid key: ***"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
out := MaskSensitiveInfo(tc.in)
if out != tc.want {
t.Errorf("mask mismatch\n in: %q\n got: %q\nwant: %q", tc.in, out, tc.want)
}
})
}
}
+11
View File
@@ -202,7 +202,17 @@ func (e *NewAPIError) ToOpenAIError() OpenAIError {
}
}
if e.errorCode != ErrorCodeCountTokenFailed {
// F-20a: upstreams may echo the channel key in non-message fields
// (code/type/param). Mask Message with URL/domain masking and the
// remaining string fields with credential-only masking so enum-like
// values are never mangled. String masking cannot fail; if the code
// field is not a string it cannot carry a credential and is kept.
result.Message = kitutil.MaskSensitiveInfo(result.Message)
result.Type = kitutil.MaskSensitiveKeys(result.Type)
result.Param = kitutil.MaskSensitiveKeys(result.Param)
if s, ok := result.Code.(string); ok {
result.Code = kitutil.MaskSensitiveKeys(s)
}
}
if result.Message == "" {
result.Message = string(e.errorType)
@@ -232,6 +242,7 @@ func (e *NewAPIError) ToClaudeError() ClaudeError {
}
if e.errorCode != ErrorCodeCountTokenFailed {
result.Message = kitutil.MaskSensitiveInfo(result.Message)
result.Type = kitutil.MaskSensitiveKeys(result.Type)
}
if result.Message == "" {
result.Message = string(e.errorType)
+5 -3
View File
@@ -200,11 +200,13 @@ func TaskErrorWrapperLocal(err error, code string, statusCode int) *taskdto.Task
func TaskErrorWrapper(err error, code string, statusCode int) *taskdto.TaskError {
text := err.Error()
lowerText := strings.ToLower(text)
// F-42: mask unconditionally. Task upstreams (suno/video/audio) may echo the
// channel API key in error bodies that do not contain "post"/"dial"/"http",
// which previously skipped masking entirely.
if strings.Contains(lowerText, "post") || strings.Contains(lowerText, "dial") || strings.Contains(lowerText, "http") {
common.SysLog(fmt.Sprintf("error: %s", text))
//text = "请求上游地址失败"
text = common.MaskSensitiveInfo(text)
}
text = common.MaskSensitiveInfo(text)
//避免暴露内部错误
taskError := &taskdto.TaskError{
Code: code,
@@ -223,7 +225,7 @@ func TaskErrorFromAPIError(apiErr *types.NewAPIError) *taskdto.TaskError {
}
return &taskdto.TaskError{
Code: string(apiErr.GetErrorCode()),
Message: apiErr.Err.Error(),
Message: common.MaskSensitiveInfo(apiErr.Error()),
StatusCode: apiErr.StatusCode,
Error: apiErr.Err,
}
+18
View File
@@ -38,6 +38,24 @@ func ShouldCopyUpstreamHeader(c *gin.Context, k string, v []string) bool {
}
return false
}
// F-66: never forward credential-bearing response headers from the upstream
// to the client. A misbehaving/malicious upstream may echo the channel key
// (or any credential it received) back in an Authorization / X-Api-Key /
// X-Goog-Api-Key response header; IOCopyBytesGracefully copies all
// non-blocked response headers verbatim.
switch strings.ToLower(k) {
case "authorization",
"proxy-authorization",
"x-api-key",
"x-goog-api-key",
"x-goog-auth",
"api-key",
"apikey",
"x-auth-token",
"x-access-token",
"x-amz-security-token":
return false
}
return true
}