From 3c53ba01a7747d7f40302bd15e1d92c908fddbad Mon Sep 17 00:00:00 2001 From: feeeei Date: Sat, 5 Sep 2026 00:25:38 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(images):=20=E8=B4=A6=E6=88=B7=E5=BC=80?= =?UTF-8?q?=E5=85=B3=E5=B0=86=E7=94=9F=E5=9B=BE=E7=BB=93=E6=9E=9C=20url=20?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD=E5=9B=9E=E5=A1=AB=E4=B8=BA=20b64=5Fjson?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 部分第三方 GPT-image 上游把 Images 响应里的 b64_json 换成 url,按官方接口实现的 客户端读不到图。账户 extra.images_url_to_b64_json 开启后,OpenAI API Key 的 Images 非流式响应里 b64_json 缺失或为空且 url 非空的项,由网关下载 url 内容并以 base64 回填 b64_json,url 与其余字段原样保留;异步生图任务复用同一路径。 - 下载经账户代理发起,沿用 base_url 的出站 URL 校验策略(validateUpstreamBaseURL 抽出 validateOutboundURL,nil cfg 按仅格式校验处理),单图上限 20MB 且须为图片 内容(响应头或字节嗅探);data: URL 直接取 base64 载荷 - 单项下载失败仅记日志并保留原样;客户端显式 response_format=url 时不回填; 流式响应不处理 - 创建/编辑账户弹窗在 OpenAI API Key 的 Responses 模式块后新增“生图结果 URL 转 base64”开关,中英文案;新增 openai_images_b64_backfill_test.go - 只被 unit 标签测试引用的定价目录测试辅助补上 //go:build unit(admin 包整文件加标签, service 包的 newPricingServiceFromModelPricing 搬到 pricing_stub_helpers_unit_test.go), 消除 golangci-lint 在默认标签下的 unused 报错 --- .../service/openai_gateway_request_body.go | 28 +- backend/internal/service/openai_images.go | 11 +- .../service/openai_images_b64_backfill.go | 151 +++++++++ .../openai_images_b64_backfill_test.go | 311 ++++++++++++++++++ .../components/account/CreateAccountModal.vue | 40 +++ .../components/account/EditAccountModal.vue | 39 +++ .../__tests__/CreateAccountModal.spec.ts | 21 ++ .../__tests__/EditAccountModal.spec.ts | 36 ++ .../src/i18n/locales/en/admin/accounts.ts | 3 + .../src/i18n/locales/zh/admin/accounts.ts | 3 + 10 files changed, 629 insertions(+), 14 deletions(-) create mode 100644 backend/internal/service/openai_images_b64_backfill.go create mode 100644 backend/internal/service/openai_images_b64_backfill_test.go diff --git a/backend/internal/service/openai_gateway_request_body.go b/backend/internal/service/openai_gateway_request_body.go index 46f69b54d6..7aecb939d8 100644 --- a/backend/internal/service/openai_gateway_request_body.go +++ b/backend/internal/service/openai_gateway_request_body.go @@ -18,24 +18,28 @@ import ( ) func (s *OpenAIGatewayService) validateUpstreamBaseURL(raw string) (string, error) { - if s.cfg != nil && !s.cfg.Security.URLAllowlist.Enabled { - normalized, err := urlvalidator.ValidateURLFormat(raw, s.cfg.Security.URLAllowlist.AllowInsecureHTTP) - if err != nil { - return "", fmt.Errorf("invalid base_url: %w", err) - } - return normalized, nil - } - normalized, err := urlvalidator.ValidateHTTPSURL(raw, urlvalidator.ValidationOptions{ - AllowedHosts: s.cfg.Security.URLAllowlist.UpstreamHosts, - RequireAllowlist: true, - AllowPrivate: s.cfg.Security.URLAllowlist.AllowPrivateHosts, - }) + normalized, err := s.validateOutboundURL(raw) if err != nil { return "", fmt.Errorf("invalid base_url: %w", err) } return normalized, nil } +// validateOutboundURL 按 security.url_allowlist 策略校验网关主动连接的出站 URL。 +func (s *OpenAIGatewayService) validateOutboundURL(raw string) (string, error) { + if s.cfg == nil { + return urlvalidator.ValidateURLFormat(raw, false) + } + if !s.cfg.Security.URLAllowlist.Enabled { + return urlvalidator.ValidateURLFormat(raw, s.cfg.Security.URLAllowlist.AllowInsecureHTTP) + } + return urlvalidator.ValidateHTTPSURL(raw, urlvalidator.ValidationOptions{ + AllowedHosts: s.cfg.Security.URLAllowlist.UpstreamHosts, + RequireAllowlist: true, + AllowPrivate: s.cfg.Security.URLAllowlist.AllowPrivateHosts, + }) +} + // buildOpenAIResponsesURL 组装 OpenAI Responses 端点。 // - base 以 /v1 结尾:追加 /responses // - base 以其他版本段结尾(如 /v4):追加 /responses diff --git a/backend/internal/service/openai_images.go b/backend/internal/service/openai_images.go index d2962ba43d..371b2986c2 100644 --- a/backend/internal/service/openai_images.go +++ b/backend/internal/service/openai_images.go @@ -722,7 +722,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey( ImageOutputSizes: imageOutputSizes, }, nil } else { - nonStreamUsage, nonStreamCount, nonStreamSizes, err := s.handleOpenAIImagesNonStreamingResponse(resp, c) + nonStreamUsage, nonStreamCount, nonStreamSizes, err := s.handleOpenAIImagesNonStreamingResponse(upstreamCtx, resp, c, account, parsed) if err != nil { return nil, err } @@ -894,11 +894,18 @@ func cloneMultipartHeader(src textproto.MIMEHeader) textproto.MIMEHeader { return dst } -func (s *OpenAIGatewayService) handleOpenAIImagesNonStreamingResponse(resp *http.Response, c *gin.Context) (OpenAIUsage, int, []string, error) { +func (s *OpenAIGatewayService) handleOpenAIImagesNonStreamingResponse( + ctx context.Context, + resp *http.Response, + c *gin.Context, + account *Account, + parsed *OpenAIImagesRequest, +) (OpenAIUsage, int, []string, error) { body, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError) if err != nil { return OpenAIUsage{}, 0, nil, err } + body = s.backfillOpenAIImagesB64JSON(ctx, account, parsed, body) responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) contentType := "application/json" if s.cfg != nil && !s.cfg.Security.ResponseHeaders.Enabled { diff --git a/backend/internal/service/openai_images_b64_backfill.go b/backend/internal/service/openai_images_b64_backfill.go new file mode 100644 index 0000000000..30fb9c20c6 --- /dev/null +++ b/backend/internal/service/openai_images_b64_backfill.go @@ -0,0 +1,151 @@ +package service + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// AccountExtraImagesURLToB64JSON 是账户 extra 中的开关键。开启后,Images 端点的非流式响应里 +// b64_json 缺失或为空但 url 非空的图片项,由网关下载 url 内容并以 base64 回填到 b64_json。 +const AccountExtraImagesURLToB64JSON = "images_url_to_b64_json" + +// openAIImageURLDownloadTimeout 是单张图片 url 下载的超时上限。 +const openAIImageURLDownloadTimeout = 60 * time.Second + +// ImagesURLToB64JSONEnabled 返回账户是否开启了 url 转 b64_json 回填。 +func ImagesURLToB64JSONEnabled(account *Account) bool { + return account != nil && account.getExtraBool(AccountExtraImagesURLToB64JSON) +} + +// backfillOpenAIImagesB64JSON 对 Images 端点的非流式响应做 url 转 b64_json 回填。 +// +// 只处理 data[i].b64_json 缺失或为空且 url 非空的项;url 字段原样保留。 +// 单项失败仅记日志并保留该项原样,响应整体照常返回。 +// 客户端显式要求 response_format=url 时不做回填。 +func (s *OpenAIGatewayService) backfillOpenAIImagesB64JSON( + ctx context.Context, + account *Account, + parsed *OpenAIImagesRequest, + body []byte, +) []byte { + if !ImagesURLToB64JSONEnabled(account) { + return body + } + if parsed != nil && parsed.ResponseFormat == "url" { + return body + } + if len(body) == 0 || !gjson.ValidBytes(body) { + return body + } + items := gjson.GetBytes(body, "data") + if !items.IsArray() { + return body + } + for index, item := range items.Array() { + if !item.IsObject() { + continue + } + if strings.TrimSpace(item.Get("b64_json").String()) != "" { + continue + } + rawURL := strings.TrimSpace(item.Get("url").String()) + if rawURL == "" { + continue + } + encoded, err := s.fetchOpenAIImageURLBase64(ctx, account, rawURL) + if err != nil { + logger.LegacyPrintf( + "service.openai_gateway", + "[OpenAI] Images b64_json backfill skipped account_id=%d index=%d err=%s", + account.ID, + index, + sanitizeUpstreamErrorMessage(err.Error()), + ) + continue + } + updated, err := sjson.SetBytes(body, fmt.Sprintf("data.%d.b64_json", index), encoded) + if err != nil { + logger.LegacyPrintf( + "service.openai_gateway", + "[OpenAI] Images b64_json backfill skipped account_id=%d index=%d err=%s", + account.ID, + index, + sanitizeUpstreamErrorMessage(err.Error()), + ) + continue + } + body = updated + } + return body +} + +// fetchOpenAIImageURLBase64 取得图片 url 内容的标准 base64 编码。 +// data: 形式的 url 直接取其 base64 载荷;其余 url 沿用 base_url 的出站 URL 策略校验后, +// 经账户代理下载,大小上限与 OAuth 路径的单图下载一致,且必须是图片内容。 +func (s *OpenAIGatewayService) fetchOpenAIImageURLBase64(ctx context.Context, account *Account, rawURL string) (string, error) { + if strings.HasPrefix(strings.ToLower(rawURL), "data:") { + if encoded := normalizeOpenAIImageBase64(rawURL); encoded != "" { + return encoded, nil + } + return "", errors.New("data url payload is not valid base64") + } + if s == nil || s.httpUpstream == nil { + return "", errors.New("http upstream is not configured") + } + downloadURL, err := s.validateOutboundURL(rawURL) + if err != nil { + return "", fmt.Errorf("invalid image url: %w", err) + } + ctx, cancel := context.WithTimeout(ctx, openAIImageURLDownloadTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil) + if err != nil { + return "", fmt.Errorf("build image download request: %w", err) + } + req.Header.Set("Accept", "image/*,*/*;q=0.8") + proxyURL := "" + if account.ProxyID != nil && account.Proxy != nil { + proxyURL = account.Proxy.URL() + } + resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency) + if err != nil { + return "", fmt.Errorf("download image: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return "", fmt.Errorf("download image: unexpected status %d", resp.StatusCode) + } + data, err := io.ReadAll(io.LimitReader(resp.Body, openAIImageMaxDownloadBytes+1)) + if err != nil { + return "", fmt.Errorf("read image body: %w", err) + } + if int64(len(data)) > openAIImageMaxDownloadBytes { + return "", fmt.Errorf("downloaded image exceeds %d bytes", openAIImageMaxDownloadBytes) + } + if len(data) == 0 { + return "", errors.New("download image: empty body") + } + if !isImageContent(resp.Header.Get("Content-Type"), data) { + return "", errors.New("download image: content is not an image") + } + return base64.StdEncoding.EncodeToString(data), nil +} + +// isImageContent 由响应头声明或字节嗅探判定内容是否为图片。 +func isImageContent(contentType string, data []byte) bool { + declared := strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0])) + if strings.HasPrefix(declared, "image/") { + return true + } + return detectedImageContentType(data) != "" +} diff --git a/backend/internal/service/openai_images_b64_backfill_test.go b/backend/internal/service/openai_images_b64_backfill_test.go new file mode 100644 index 0000000000..1bbfc0b7d7 --- /dev/null +++ b/backend/internal/service/openai_images_b64_backfill_test.go @@ -0,0 +1,311 @@ +package service + +import ( + "bytes" + "context" + "encoding/base64" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// 8 字节 PNG 魔数足以让字节嗅探判定为 image/png。 +var b64BackfillPNGBytes = []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 0, 0, 0, 0} + +func b64BackfillImageResponse(status int, contentType string, payload []byte) *http.Response { + header := http.Header{} + if contentType != "" { + header.Set("Content-Type", contentType) + } + return &http.Response{ + StatusCode: status, + Header: header, + Body: io.NopCloser(bytes.NewReader(payload)), + } +} + +func b64BackfillAccount(enabled bool) *Account { + account := &Account{ + ID: 7, + Name: "openai-apikey", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "sk-test", + "base_url": "https://relay.example.com/v1", + }, + } + if enabled { + account.Extra = map[string]any{AccountExtraImagesURLToB64JSON: true} + } + return account +} + +func TestImagesURLToB64JSONEnabled(t *testing.T) { + require.False(t, ImagesURLToB64JSONEnabled(nil)) + require.False(t, ImagesURLToB64JSONEnabled(&Account{})) + require.False(t, ImagesURLToB64JSONEnabled(&Account{Extra: map[string]any{AccountExtraImagesURLToB64JSON: "true"}})) + require.False(t, ImagesURLToB64JSONEnabled(&Account{Extra: map[string]any{AccountExtraImagesURLToB64JSON: false}})) + require.True(t, ImagesURLToB64JSONEnabled(&Account{Extra: map[string]any{AccountExtraImagesURLToB64JSON: true}})) +} + +func TestBackfillOpenAIImagesB64JSON(t *testing.T) { + wantB64 := base64.StdEncoding.EncodeToString(b64BackfillPNGBytes) + + tests := []struct { + name string + enabled bool + parsed *OpenAIImagesRequest + body string + upstream *httpUpstreamRecorder + wantB64 []string + wantDownloads int + }{ + { + name: "开关关闭时原样返回", + enabled: false, + body: `{"created":1,"data":[{"url":"https://cdn.example.com/a.png"}]}`, + upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)}, + wantB64: []string{""}, + wantDownloads: 0, + }, + { + name: "缺少 b64_json 且有 url 时下载回填并保留 url", + enabled: true, + body: `{"created":1,"data":[{"url":"https://cdn.example.com/a.png","revised_prompt":"a cat"}]}`, + upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)}, + wantB64: []string{wantB64}, + wantDownloads: 1, + }, + { + name: "b64_json 为空串时同样回填", + enabled: true, + body: `{"created":1,"data":[{"b64_json":"","url":"https://cdn.example.com/a.png"}]}`, + upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)}, + wantB64: []string{wantB64}, + wantDownloads: 1, + }, + { + name: "b64_json 已有值时不下载", + enabled: true, + body: `{"created":1,"data":[{"b64_json":"aW1n","url":"https://cdn.example.com/a.png"}]}`, + upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)}, + wantB64: []string{"aW1n"}, + wantDownloads: 0, + }, + { + name: "多项混合时只回填缺失项", + enabled: true, + body: `{"created":1,"data":[{"b64_json":"aW1n"},{"url":"https://cdn.example.com/b.png"},{"revised_prompt":"none"}]}`, + upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)}, + wantB64: []string{"aW1n", wantB64, ""}, + wantDownloads: 1, + }, + { + name: "data URL 直接取载荷不下载", + enabled: true, + body: `{"created":1,"data":[{"url":"data:image/png;base64,` + wantB64 + `"}]}`, + upstream: &httpUpstreamRecorder{}, + wantB64: []string{wantB64}, + wantDownloads: 0, + }, + { + name: "客户端显式要求 url 时不回填", + enabled: true, + parsed: &OpenAIImagesRequest{ResponseFormat: "url"}, + body: `{"created":1,"data":[{"url":"https://cdn.example.com/a.png"}]}`, + upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)}, + wantB64: []string{""}, + wantDownloads: 0, + }, + { + name: "下载非 2xx 时保留原样", + enabled: true, + body: `{"created":1,"data":[{"url":"https://cdn.example.com/a.png"}]}`, + upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusNotFound, "text/plain", []byte("gone"))}, + wantB64: []string{""}, + wantDownloads: 1, + }, + { + name: "下载内容不是图片时保留原样", + enabled: true, + body: `{"created":1,"data":[{"url":"https://cdn.example.com/a.png"}]}`, + upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "text/html", []byte("login required"))}, + wantB64: []string{""}, + wantDownloads: 1, + }, + { + name: "响应头未声明图片类型但字节嗅探为图片时回填", + enabled: true, + body: `{"created":1,"data":[{"url":"https://cdn.example.com/a"}]}`, + upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "application/octet-stream", b64BackfillPNGBytes)}, + wantB64: []string{wantB64}, + wantDownloads: 1, + }, + { + name: "非 http(s) 协议的 url 不下载", + enabled: true, + body: `{"created":1,"data":[{"url":"ftp://cdn.example.com/a.png"}]}`, + upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)}, + wantB64: []string{""}, + wantDownloads: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: tt.upstream} + got := svc.backfillOpenAIImagesB64JSON(context.Background(), b64BackfillAccount(tt.enabled), tt.parsed, []byte(tt.body)) + require.True(t, gjson.ValidBytes(got)) + items := gjson.GetBytes(got, "data").Array() + require.Len(t, items, len(tt.wantB64)) + for i, want := range tt.wantB64 { + require.Equal(t, want, items[i].Get("b64_json").String(), "data.%d.b64_json", i) + } + require.Len(t, tt.upstream.requests, tt.wantDownloads) + // 除 b64_json 外的字段必须原样保留。 + original := gjson.Parse(tt.body) + original.Get("data").ForEach(func(key, item gjson.Result) bool { + item.ForEach(func(field, value gjson.Result) bool { + if field.String() == "b64_json" { + return true + } + require.Equal(t, value.Raw, gjson.GetBytes(got, "data."+key.String()+"."+field.String()).Raw) + return true + }) + return true + }) + require.Equal(t, original.Get("created").Raw, gjson.GetBytes(got, "created").Raw) + }) + } +} + +func TestBackfillOpenAIImagesB64JSON_DownloadRequestShape(t *testing.T) { + upstream := &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := b64BackfillAccount(true) + proxyID := int64(3) + account.ProxyID = &proxyID + account.Proxy = &Proxy{Protocol: "http", Host: "127.0.0.1", Port: 7890} + + body := []byte(`{"created":1,"data":[{"url":"https://cdn.example.com/a.png?sig=abc"}]}`) + got := svc.backfillOpenAIImagesB64JSON(context.Background(), account, nil, body) + require.Equal(t, base64.StdEncoding.EncodeToString(b64BackfillPNGBytes), gjson.GetBytes(got, "data.0.b64_json").String()) + + require.Len(t, upstream.requests, 1) + req := upstream.requests[0] + require.Equal(t, http.MethodGet, req.Method) + require.Equal(t, "https://cdn.example.com/a.png?sig=abc", req.URL.String()) + require.Equal(t, "http://127.0.0.1:7890", upstream.lastProxyURL) + _, hasDeadline := req.Context().Deadline() + require.True(t, hasDeadline) +} + +func TestBackfillOpenAIImagesB64JSON_NonObjectBodies(t *testing.T) { + upstream := &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := b64BackfillAccount(true) + + for _, body := range []string{``, `not json`, `{"created":1}`, `{"created":1,"data":{}}`, `{"created":1,"data":[]}`, `{"created":1,"data":["x"]}`} { + got := svc.backfillOpenAIImagesB64JSON(context.Background(), account, nil, []byte(body)) + require.Equal(t, body, string(got), "body=%q", body) + } + require.Empty(t, upstream.requests) +} + +func TestOpenAIGatewayServiceForwardImages_APIKeyBackfillsB64JSONFromURL(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","size":"1024x1024"}`) + + req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + c.Set("api_key", &APIKey{ID: 42}) + + svc := &OpenAIGatewayService{cfg: &config.Config{}} + parsed, err := svc.ParseOpenAIImagesRequest(c, body) + require.NoError(t, err) + + upstream := &httpUpstreamRecorder{ + responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + "X-Request-Id": []string{"req_img_url"}, + }, + Body: io.NopCloser(strings.NewReader( + `{"created":1710000000,"data":[{"url":"https://cdn.example.com/cat.png","revised_prompt":"a cat"}],"usage":{"input_tokens":10,"output_tokens":20}}`, + )), + }, + b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes), + }, + } + svc.httpUpstream = upstream + + result, err := svc.ForwardImages(context.Background(), c, b64BackfillAccount(true), body, parsed, "") + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, 1, result.ImageCount) + require.False(t, result.Stream) + + require.Len(t, upstream.requests, 2) + require.Equal(t, http.MethodPost, upstream.requests[0].Method) + require.Equal(t, "https://relay.example.com/v1/images/generations", upstream.requests[0].URL.String()) + require.Equal(t, http.MethodGet, upstream.requests[1].Method) + require.Equal(t, "https://cdn.example.com/cat.png", upstream.requests[1].URL.String()) + + require.Equal(t, http.StatusOK, rec.Code) + wantB64 := base64.StdEncoding.EncodeToString(b64BackfillPNGBytes) + require.Equal(t, wantB64, gjson.Get(rec.Body.String(), "data.0.b64_json").String()) + require.Equal(t, "https://cdn.example.com/cat.png", gjson.Get(rec.Body.String(), "data.0.url").String()) + require.Equal(t, "a cat", gjson.Get(rec.Body.String(), "data.0.revised_prompt").String()) + require.Equal(t, int64(1710000000), gjson.Get(rec.Body.String(), "created").Int()) +} + +func TestOpenAIGatewayServiceForwardImages_APIKeyLeavesURLOnlyResponseWhenDisabled(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","size":"1024x1024"}`) + + req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + c.Set("api_key", &APIKey{ID: 42}) + + svc := &OpenAIGatewayService{cfg: &config.Config{}} + parsed, err := svc.ParseOpenAIImagesRequest(c, body) + require.NoError(t, err) + + upstreamBody := `{"created":1710000000,"data":[{"url":"https://cdn.example.com/cat.png"}]}` + upstream := &httpUpstreamRecorder{ + responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(upstreamBody)), + }, + b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes), + }, + } + svc.httpUpstream = upstream + + result, err := svc.ForwardImages(context.Background(), c, b64BackfillAccount(false), body, parsed, "") + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, 1, result.ImageCount) + + require.Len(t, upstream.requests, 1) + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, upstreamBody, rec.Body.String()) +} diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 4692c78e86..e68ffc4609 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -3313,6 +3313,37 @@ + +
+
+ +

+ {{ t('admin.accounts.openai.imagesUrlToB64JsonDesc') }} +

+
+ +
+
@@ -4246,6 +4277,8 @@ const openAILongContextBillingEnabled = ref(false) const openAILongContextBillingTouched = ref(false) const openAICompactMode = ref('auto') const openAIResponsesMode = ref('auto') +// Images 非流式响应缺 b64_json 时由网关下载 url 回填(仅 OpenAI API Key)。 +const openAIImagesUrlToB64JsonEnabled = ref(false) const openAIEndpointCapabilities = ref(['chat_completions', 'embeddings']) const openaiOAuthResponsesWebSocketV2Mode = ref(OPENAI_WS_MODE_OFF) const openaiAPIKeyResponsesWebSocketV2Mode = ref(OPENAI_WS_MODE_OFF) @@ -4724,6 +4757,7 @@ watch( // 避免上一平台的配置行被提交到新平台账号 headerOverrideEnabled.value = false headerOverrideRows.value = [] + openAIImagesUrlToB64JsonEnabled.value = false grokOAuthCustomBaseUrlEnabled.value = false grokOAuthBaseUrl.value = '' // Reset OAuth states @@ -5149,6 +5183,7 @@ const resetForm = () => { customErrorCodeInput.value = null headerOverrideEnabled.value = false headerOverrideRows.value = [] + openAIImagesUrlToB64JsonEnabled.value = false grokOAuthCustomBaseUrlEnabled.value = false grokOAuthBaseUrl.value = '' interceptWarmupRequests.value = false @@ -5286,6 +5321,11 @@ const buildOpenAIExtra = (base?: Record): Record 0 ? extra : undefined } diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 1c0beebf50..85a6cb96ad 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -1821,6 +1821,37 @@
+ +
+
+ +

+ {{ t('admin.accounts.openai.imagesUrlToB64JsonDesc') }} +

+
+ +
+
('') const openAICompactMode = ref('auto') const openAIResponsesMode = ref('auto') +// Images 非流式响应缺 b64_json 时由网关下载 url 回填(仅 OpenAI API Key)。 +const openAIImagesUrlToB64JsonEnabled = ref(false) const openAIEndpointCapabilities = ref(['chat_completions', 'embeddings']) const openaiOAuthResponsesWebSocketV2Mode = ref(OPENAI_WS_MODE_OFF) const openaiAPIKeyResponsesWebSocketV2Mode = ref(OPENAI_WS_MODE_OFF) @@ -3742,6 +3775,7 @@ const syncFormFromAccount = (newAccount: Account | null) => { mixedScheduling.value = extra?.mixed_scheduling === true allowOverages.value = extra?.allow_overages === true upstreamRequestIdHeader.value = readUpstreamRequestIdHeader(extra) + openAIImagesUrlToB64JsonEnabled.value = extra?.images_url_to_b64_json === true autoPause5hThreshold.value = typeof extra?.auto_pause_5h_threshold === 'number' ? extra.auto_pause_5h_threshold * 100 : null autoPause7dThreshold.value = typeof extra?.auto_pause_7d_threshold === 'number' ? extra.auto_pause_7d_threshold * 100 : null autoPause5hDisabled.value = extra?.auto_pause_5h_disabled === true @@ -5214,6 +5248,11 @@ const handleSubmit = async () => { delete newExtra.openai_responses_mode } else { newExtra.openai_responses_mode = openAIResponsesMode.value + } + if (openAIImagesUrlToB64JsonEnabled.value) { + newExtra.images_url_to_b64_json = true + } else { + delete newExtra.images_url_to_b64_json } } if (autoPause5hThreshold.value != null && autoPause5hThreshold.value > 0) { diff --git a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts index 4a052d61f8..c13a69fbdd 100644 --- a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts @@ -268,6 +268,27 @@ describe('CreateAccountModal OpenAI long-context billing', () => { expect(createAccountMock.mock.calls[0]?.[0]?.extra?.upstream_request_id_header).toBe('X-Oneapi-Request-Id') }) + it('omits images_url_to_b64_json from extra by default', async () => { + await submitApiKeyAccount('openai') + + expect(createAccountMock).toHaveBeenCalledTimes(1) + expect(createAccountMock.mock.calls[0]?.[0]?.extra).not.toHaveProperty('images_url_to_b64_json') + }) + + it('sends images_url_to_b64_json in extra when the toggle is enabled', async () => { + const wrapper = mountModal() + await selectButtonByText(wrapper, 'OpenAI') + await selectButtonByText(wrapper, 'API Key') + await wrapper.get('form#create-account-form input[type="text"]').setValue('openai account') + await wrapper.get('form#create-account-form input[type="password"]').setValue('test-api-key') + await wrapper.get('[data-testid="openai-images-url-to-b64-json-toggle"]').trigger('click') + await wrapper.get('form#create-account-form').trigger('submit.prevent') + await flushPromises() + + expect(createAccountMock).toHaveBeenCalledTimes(1) + expect(createAccountMock.mock.calls[0]?.[0]?.extra?.images_url_to_b64_json).toBe(true) + }) + it('persists upstream model metadata after creating an account from preview', async () => { const wrapper = mountModal() await selectButtonByText(wrapper, 'OpenAI') diff --git a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts index 8232e2e619..7d50e0f91f 100644 --- a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts @@ -718,6 +718,42 @@ describe('EditAccountModal', () => { expect(updateAccountMock.mock.calls[0]?.[1]?.extra).not.toHaveProperty('upstream_request_id_header') }) + it('writes images_url_to_b64_json into extra when toggled on', async () => { + const account = buildAccount() + updateAccountMock.mockReset() + checkMixedChannelRiskMock.mockReset() + checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) + updateAccountMock.mockResolvedValue(account) + + const wrapper = mountModal(account) + const toggle = wrapper.get('[data-testid="openai-images-url-to-b64-json-toggle"]') + expect(toggle.attributes('aria-checked')).toBe('false') + await toggle.trigger('click') + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + + expect(updateAccountMock).toHaveBeenCalledTimes(1) + expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.images_url_to_b64_json).toBe(true) + }) + + it('removes images_url_to_b64_json from extra when toggled off', async () => { + const account = buildAccount() + account.extra = { images_url_to_b64_json: true } + updateAccountMock.mockReset() + checkMixedChannelRiskMock.mockReset() + checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) + updateAccountMock.mockResolvedValue(account) + + const wrapper = mountModal(account) + const toggle = wrapper.get('[data-testid="openai-images-url-to-b64-json-toggle"]') + expect(toggle.attributes('aria-checked')).toBe('true') + await toggle.trigger('click') + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + + expect(updateAccountMock).toHaveBeenCalledTimes(1) + expect(updateAccountMock.mock.calls[0]?.[1]?.extra).toBeDefined() + expect(updateAccountMock.mock.calls[0]?.[1]?.extra).not.toHaveProperty('images_url_to_b64_json') + }) + it('hides the Codex namespace flatten toggle for non-OAuth OpenAI accounts', async () => { const account = buildAccount() const wrapper = mountModal(account) diff --git a/frontend/src/i18n/locales/en/admin/accounts.ts b/frontend/src/i18n/locales/en/admin/accounts.ts index 125c5d48df..3f10ae253c 100644 --- a/frontend/src/i18n/locales/en/admin/accounts.ts +++ b/frontend/src/i18n/locales/en/admin/accounts.ts @@ -611,6 +611,9 @@ export default { responsesModeForceChatCompletions: 'Force Chat Completions', responsesModeTextDisabledHint: 'Not applicable when the Responses / Chat Completions endpoint is not enabled.', + imagesUrlToB64Json: 'Image result URL to base64', + imagesUrlToB64JsonDesc: + 'Only applies to non-streaming Images responses of OpenAI API Key accounts. When an upstream image item has a url but no b64_json, the gateway downloads the url and fills b64_json with its base64 content (url is kept) for clients built on the official API; the response is returned unchanged if the download fails.', endpointCapabilities: 'Endpoint capabilities', endpointCapabilitiesDesc: 'Used by account routing. The text endpoint follows the Responses API support setting above and is shown as Responses, Chat Completions, or auto mode; Embeddings independently controls /v1/embeddings.', diff --git a/frontend/src/i18n/locales/zh/admin/accounts.ts b/frontend/src/i18n/locales/zh/admin/accounts.ts index bba25d1be5..1f50a3f2d6 100644 --- a/frontend/src/i18n/locales/zh/admin/accounts.ts +++ b/frontend/src/i18n/locales/zh/admin/accounts.ts @@ -692,6 +692,9 @@ export default { responsesModeForceResponses: '强制 Responses', responsesModeForceChatCompletions: '强制 Chat Completions', responsesModeTextDisabledHint: '未启用 Responses / Chat Completions 端点时,此设置不适用。', + imagesUrlToB64Json: '生图结果 URL 转 base64', + imagesUrlToB64JsonDesc: + '仅对 OpenAI API Key 的 Images 非流式响应生效。上游返回的图片缺少 b64_json 但带 url 时,网关下载该 url 并以 base64 回填 b64_json(url 保留),兼容按官方接口实现的客户端;下载失败则原样返回。', endpointCapabilities: '端点能力', endpointCapabilitiesDesc: '用于调度筛选。文本端点会跟随上方 Responses API 支持显示为 Responses、Chat Completions 或自动模式;Embeddings 独立控制 /v1/embeddings。', From c227863d51468bee3a48f999515bd233bc2d41cf Mon Sep 17 00:00:00 2001 From: feeeei Date: Sat, 5 Sep 2026 15:50:56 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(images):=20=E7=94=9F=E5=9B=BE=20url=20?= =?UTF-8?q?=E5=9B=9E=E5=A1=AB=E4=B8=8B=E8=BD=BD=E6=8B=92=E7=BB=9D=E7=A7=81?= =?UTF-8?q?=E7=BD=91=E7=9B=AE=E7=9A=84=E5=9C=B0=E5=B9=B6=E5=8F=AA=E4=BF=A1?= =?UTF-8?q?=E5=AD=97=E8=8A=82=E5=97=85=E6=8E=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 账户开关 images_url_to_b64_json 下载的图片 url 来自上游响应,是不可信输入。此前沿用 base_url 的 security.url_allowlist 策略,默认配置下只做格式校验,上游返回 localhost:8080 之类地址时网关会真的去请求内网。 - 下载 URL 主机为 localhost、回环、私网、链路本地、未指定地址的字面量时直接拒绝, 不受 security.url_allowlist 配置影响(urlvalidator 导出 IsBlockedHost) - 新增 ctx 标记 WithHTTPUpstreamPublicHostsOnly:共享上游客户端对带标记的请求无视配置 校验解析后 IP,并为其克隆客户端挂 redirectChecker,重定向保持跟随但每一跳都校验 - 下载内容改为只信 http.DetectContentType 嗅探结果且限 png/jpeg/webp/gif, 响应头不再作为依据 - 下载失败仍只记日志并保留原样;超时与端口策略不变 --- backend/internal/repository/http_upstream.go | 33 ++++++--- .../internal/repository/http_upstream_test.go | 60 ++++++++++++++++ .../internal/service/http_upstream_profile.go | 16 +++++ .../service/http_upstream_profile_test.go | 14 ++++ .../service/openai_images_b64_backfill.go | 47 ++++++++++--- .../openai_images_b64_backfill_test.go | 70 +++++++++++++++++++ .../internal/util/urlvalidator/validator.go | 6 ++ .../util/urlvalidator/validator_test.go | 17 +++++ 8 files changed, 243 insertions(+), 20 deletions(-) diff --git a/backend/internal/repository/http_upstream.go b/backend/internal/repository/http_upstream.go index bdd6478cff..001e597e0f 100644 --- a/backend/internal/repository/http_upstream.go +++ b/backend/internal/repository/http_upstream.go @@ -213,7 +213,7 @@ func (s *httpUpstreamService) Do(req *http.Request, proxyURL string, accountID i } // 执行请求 - client := httpClientForUpstreamRequest(entry.client, req) + client := s.httpClientForUpstreamRequest(entry.client, req) client = httpClientWithGrokAccessDeniedFallback(client) resp, err := servertiming.Do(client, req) if err != nil { @@ -277,7 +277,7 @@ func (s *httpUpstreamService) DoWithTLS(req *http.Request, proxyURL string, acco return nil, err } - client := httpClientForUpstreamRequest(entry.client, req) + client := s.httpClientForUpstreamRequest(entry.client, req) client = httpClientWithGrokAccessDeniedFallback(client) resp, err := servertiming.Do(client, req) if err != nil { @@ -297,15 +297,27 @@ func (s *httpUpstreamService) DoWithTLS(req *http.Request, proxyURL string, acco return resp, nil } -func httpClientForUpstreamRequest(client *http.Client, req *http.Request) *http.Client { - if client == nil || req == nil || !service.HTTPUpstreamRedirectsDisabled(req.Context()) { +// httpClientForUpstreamRequest 按请求上下文的标记派生客户端:禁用重定向,或对重定向的每一跳做主机校验。 +// 派生的克隆与缓存客户端共享 Transport;未打标记时原样返回。 +func (s *httpUpstreamService) httpClientForUpstreamRequest(client *http.Client, req *http.Request) *http.Client { + if client == nil || req == nil { return client } - clone := *client - clone.CheckRedirect = func(*http.Request, []*http.Request) error { - return http.ErrUseLastResponse + ctx := req.Context() + switch { + case service.HTTPUpstreamRedirectsDisabled(ctx): + clone := *client + clone.CheckRedirect = func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + } + return &clone + case service.HTTPUpstreamPublicHostsOnly(ctx) && client.CheckRedirect == nil: + clone := *client + clone.CheckRedirect = s.redirectChecker + return &clone + default: + return client } - return &clone } // grokAccessDeniedFallbackTransport preserves the subscription CLI proxy as @@ -587,8 +599,11 @@ func (s *httpUpstreamService) shouldValidateResolvedIP() bool { return !s.cfg.Security.URLAllowlist.AllowPrivateHosts } +// validateRequestHost 校验请求主机的解析结果不落在回环、私网、链路本地或未指定地址。 +// 是否全局启用由 security.url_allowlist 决定;带 WithHTTPUpstreamPublicHostsOnly 标记的请求无论配置如何都校验。 func (s *httpUpstreamService) validateRequestHost(req *http.Request) error { - if !s.shouldValidateResolvedIP() { + publicHostsOnly := req != nil && service.HTTPUpstreamPublicHostsOnly(req.Context()) + if !s.shouldValidateResolvedIP() && !publicHostsOnly { return nil } if req == nil || req.URL == nil { diff --git a/backend/internal/repository/http_upstream_test.go b/backend/internal/repository/http_upstream_test.go index 3c7840dc47..8750771d04 100644 --- a/backend/internal/repository/http_upstream_test.go +++ b/backend/internal/repository/http_upstream_test.go @@ -961,3 +961,63 @@ func hasEntry(svc *httpUpstreamService, target *upstreamClientEntry) bool { } return false } + +func TestHTTPUpstreamDoPublicHostsOnlyRejectsPrivateDestinationBeforeConnecting(t *testing.T) { + var calls atomic.Int64 + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + calls.Add(1) + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(target.Close) + + upstream := NewHTTPUpstream(nil) + + plain, err := http.NewRequestWithContext(t.Context(), http.MethodGet, target.URL, nil) + require.NoError(t, err) + resp, err := upstream.Do(plain, "", 1, 1) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + require.Equal(t, int64(1), calls.Load(), "loopback stays reachable for requests without the marker") + + guarded, err := http.NewRequestWithContext(service.WithHTTPUpstreamPublicHostsOnly(t.Context()), http.MethodGet, target.URL, nil) + require.NoError(t, err) + resp, err = upstream.Do(guarded, "", 1, 1) + require.Error(t, err) + require.Nil(t, resp) + require.Contains(t, err.Error(), "not allowed") + require.Equal(t, int64(1), calls.Load(), "marked request must be rejected before any connection is made") +} + +func TestHTTPUpstreamPublicHostsOnlyValidatesEveryRedirectHop(t *testing.T) { + upstream, ok := NewHTTPUpstream(nil).(*httpUpstreamService) + require.True(t, ok) + base := &http.Client{} + + plain, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "https://cdn.example.com/a.png", nil) + require.NoError(t, err) + require.Same(t, base, upstream.httpClientForUpstreamRequest(base, plain)) + + guarded, err := http.NewRequestWithContext(service.WithHTTPUpstreamPublicHostsOnly(t.Context()), http.MethodGet, "https://cdn.example.com/a.png", nil) + require.NoError(t, err) + client := upstream.httpClientForUpstreamRequest(base, guarded) + require.NotSame(t, base, client) + require.NotNil(t, client.CheckRedirect) + require.Nil(t, base.CheckRedirect, "the cached client must stay untouched") + + via := []*http.Request{guarded} + for _, hop := range []string{ + "http://127.0.0.1:8080/a.png", + "http://[::1]:8080/a.png", + "http://10.0.0.8/a.png", + "http://169.254.169.254/latest/meta-data/", + "http://0.0.0.0/a.png", + } { + hopReq, err := http.NewRequestWithContext(guarded.Context(), http.MethodGet, hop, nil) + require.NoError(t, err) + require.Error(t, client.CheckRedirect(hopReq, via), "hop=%s", hop) + } + publicHop, err := http.NewRequestWithContext(guarded.Context(), http.MethodGet, "http://93.184.216.34/a.png", nil) + require.NoError(t, err) + require.NoError(t, client.CheckRedirect(publicHop, via)) + require.Error(t, client.CheckRedirect(publicHop, make([]*http.Request, 10)), "redirect chain stays capped") +} diff --git a/backend/internal/service/http_upstream_profile.go b/backend/internal/service/http_upstream_profile.go index b2de6002cf..327813de19 100644 --- a/backend/internal/service/http_upstream_profile.go +++ b/backend/internal/service/http_upstream_profile.go @@ -14,6 +14,7 @@ const ( type httpUpstreamProfileContextKey struct{} type httpUpstreamDisableRedirectsContextKey struct{} +type httpUpstreamPublicHostsOnlyContextKey struct{} // WithHTTPUpstreamProfile injects an upstream transport profile into ctx. func WithHTTPUpstreamProfile(ctx context.Context, profile HTTPUpstreamProfile) context.Context { @@ -55,3 +56,18 @@ func WithHTTPUpstreamRedirectsDisabled(ctx context.Context) context.Context { func HTTPUpstreamRedirectsDisabled(ctx context.Context) bool { return ctx != nil && ctx.Value(httpUpstreamDisableRedirectsContextKey{}) == true } + +// WithHTTPUpstreamPublicHostsOnly marks a request whose destination, and every +// redirect hop after it, must resolve to a public address. The shared upstream +// client enforces it regardless of the security.url_allowlist configuration; +// use it for fetches whose URL comes from an untrusted upstream response. +func WithHTTPUpstreamPublicHostsOnly(ctx context.Context) context.Context { + if ctx == nil { + ctx = context.Background() + } + return context.WithValue(ctx, httpUpstreamPublicHostsOnlyContextKey{}, true) +} + +func HTTPUpstreamPublicHostsOnly(ctx context.Context) bool { + return ctx != nil && ctx.Value(httpUpstreamPublicHostsOnlyContextKey{}) == true +} diff --git a/backend/internal/service/http_upstream_profile_test.go b/backend/internal/service/http_upstream_profile_test.go index 9cd4bf4ff9..ea95b92611 100644 --- a/backend/internal/service/http_upstream_profile_test.go +++ b/backend/internal/service/http_upstream_profile_test.go @@ -30,3 +30,17 @@ func TestWithHTTPUpstreamRedirectsDisabled(t *testing.T) { t.Fatal("redirects should remain enabled by default") } } + +func TestWithHTTPUpstreamPublicHostsOnly(t *testing.T) { + //nolint:staticcheck // Exercises the defensive nil-context fallback. + ctx := WithHTTPUpstreamPublicHostsOnly(nil) + if !HTTPUpstreamPublicHostsOnly(ctx) { + t.Fatal("expected public-hosts-only marker to be set") + } + if HTTPUpstreamPublicHostsOnly(context.Background()) { + t.Fatal("marker must be absent by default") + } + if HTTPUpstreamRedirectsDisabled(ctx) { + t.Fatal("public-hosts-only must not disable redirects") + } +} diff --git a/backend/internal/service/openai_images_b64_backfill.go b/backend/internal/service/openai_images_b64_backfill.go index 30fb9c20c6..3210c0fcbe 100644 --- a/backend/internal/service/openai_images_b64_backfill.go +++ b/backend/internal/service/openai_images_b64_backfill.go @@ -7,10 +7,12 @@ import ( "fmt" "io" "net/http" + "net/url" "strings" "time" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/Wei-Shaw/sub2api/internal/util/urlvalidator" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) @@ -90,8 +92,9 @@ func (s *OpenAIGatewayService) backfillOpenAIImagesB64JSON( } // fetchOpenAIImageURLBase64 取得图片 url 内容的标准 base64 编码。 -// data: 形式的 url 直接取其 base64 载荷;其余 url 沿用 base_url 的出站 URL 策略校验后, -// 经账户代理下载,大小上限与 OAuth 路径的单图下载一致,且必须是图片内容。 +// data: 形式的 url 直接取其 base64 载荷;其余 url 先沿用 base_url 的出站 URL 策略校验, +// 再无条件拒绝回环、私网、链路本地等目的地(含重定向的每一跳),经账户代理下载, +// 大小上限与 OAuth 路径的单图下载一致,且前 512 字节须嗅探为 png/jpeg/webp/gif。 func (s *OpenAIGatewayService) fetchOpenAIImageURLBase64(ctx context.Context, account *Account, rawURL string) (string, error) { if strings.HasPrefix(strings.ToLower(rawURL), "data:") { if encoded := normalizeOpenAIImageBase64(rawURL); encoded != "" { @@ -106,7 +109,10 @@ func (s *OpenAIGatewayService) fetchOpenAIImageURLBase64(ctx context.Context, ac if err != nil { return "", fmt.Errorf("invalid image url: %w", err) } - ctx, cancel := context.WithTimeout(ctx, openAIImageURLDownloadTimeout) + if err := rejectPrivateImageHost(downloadURL); err != nil { + return "", err + } + ctx, cancel := context.WithTimeout(WithHTTPUpstreamPublicHostsOnly(ctx), openAIImageURLDownloadTimeout) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil) if err != nil { @@ -135,17 +141,36 @@ func (s *OpenAIGatewayService) fetchOpenAIImageURLBase64(ctx context.Context, ac if len(data) == 0 { return "", errors.New("download image: empty body") } - if !isImageContent(resp.Header.Get("Content-Type"), data) { - return "", errors.New("download image: content is not an image") + if !isBackfillImageContent(data) { + return "", errors.New("download image: content is not an allowed image format") } return base64.StdEncoding.EncodeToString(data), nil } -// isImageContent 由响应头声明或字节嗅探判定内容是否为图片。 -func isImageContent(contentType string, data []byte) bool { - declared := strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0])) - if strings.HasPrefix(declared, "image/") { - return true +// rejectPrivateImageHost 拒绝主机为 localhost 或回环、私网、链路本地、未指定地址字面量的下载 URL。 +// 不受 security.url_allowlist 配置影响;域名的解析结果与重定向的每一跳由上游客户端按 +// WithHTTPUpstreamPublicHostsOnly 标记校验。 +func rejectPrivateImageHost(downloadURL string) error { + parsed, err := url.Parse(downloadURL) + if err != nil { + return fmt.Errorf("invalid image url: %w", err) } - return detectedImageContentType(data) != "" + if host := parsed.Hostname(); urlvalidator.IsBlockedHost(host) { + return fmt.Errorf("image url host is not allowed: %s", host) + } + return nil +} + +// openAIImageBackfillContentTypes 是允许回填的图片格式,以字节嗅探结果为准,响应头不作为依据。 +var openAIImageBackfillContentTypes = map[string]struct{}{ + "image/png": {}, + "image/jpeg": {}, + "image/webp": {}, + "image/gif": {}, +} + +// isBackfillImageContent 报告 data 的前 512 字节是否嗅探为允许回填的图片格式。 +func isBackfillImageContent(data []byte) bool { + _, ok := openAIImageBackfillContentTypes[detectedImageContentType(data)] + return ok } diff --git a/backend/internal/service/openai_images_b64_backfill_test.go b/backend/internal/service/openai_images_b64_backfill_test.go index 1bbfc0b7d7..bbaa3f088b 100644 --- a/backend/internal/service/openai_images_b64_backfill_test.go +++ b/backend/internal/service/openai_images_b64_backfill_test.go @@ -149,6 +149,14 @@ func TestBackfillOpenAIImagesB64JSON(t *testing.T) { wantB64: []string{wantB64}, wantDownloads: 1, }, + { + name: "响应头声明图片但字节不是图片时保留原样", + enabled: true, + body: `{"created":1,"data":[{"url":"https://cdn.example.com/a.png"}]}`, + upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/svg+xml", []byte(``))}, + wantB64: []string{""}, + wantDownloads: 1, + }, { name: "非 http(s) 协议的 url 不下载", enabled: true, @@ -206,6 +214,67 @@ func TestBackfillOpenAIImagesB64JSON_DownloadRequestShape(t *testing.T) { require.Equal(t, "http://127.0.0.1:7890", upstream.lastProxyURL) _, hasDeadline := req.Context().Deadline() require.True(t, hasDeadline) + // 目的地与重定向各跳都必须解析到公网地址;重定向本身保持跟随。 + require.True(t, HTTPUpstreamPublicHostsOnly(req.Context())) + require.False(t, HTTPUpstreamRedirectsDisabled(req.Context())) +} + +func TestBackfillOpenAIImagesB64JSON_RejectsPrivateHosts(t *testing.T) { + upstream := &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)} + cfg := &config.Config{} + cfg.Security.URLAllowlist.AllowInsecureHTTP = true + svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream} + account := b64BackfillAccount(true) + + for _, rawURL := range []string{ + "http://localhost:8080/api/v1/admin/accounts", + "http://Foo.LocalHost/a.png", + "http://127.0.0.1:8080/a.png", + "https://127.0.0.1/a.png", + "http://[::1]:8080/a.png", + "http://10.0.0.5/a.png", + "http://172.16.0.9:9000/bucket/a.png", + "http://192.168.1.10:9000/bucket/a.png", + "http://169.254.169.254/latest/meta-data/", + "http://0.0.0.0:8080/a.png", + "http://[fe80::1]/a.png", + "http://[::ffff:127.0.0.1]/a.png", + } { + body := `{"created":1,"data":[{"url":"` + rawURL + `"}]}` + got := svc.backfillOpenAIImagesB64JSON(context.Background(), account, nil, []byte(body)) + require.Equal(t, body, string(got), "url=%s", rawURL) + } + require.Empty(t, upstream.requests, "private destinations must never be requested") + + // 同一配置下公网主机照常下载,证明拒绝依据是目的地而非协议。 + got := svc.backfillOpenAIImagesB64JSON(context.Background(), account, nil, []byte(`{"created":1,"data":[{"url":"http://cdn.example.com/a.png"}]}`)) + require.Equal(t, base64.StdEncoding.EncodeToString(b64BackfillPNGBytes), gjson.GetBytes(got, "data.0.b64_json").String()) + require.Len(t, upstream.requests, 1) +} + +func TestIsBackfillImageContent(t *testing.T) { + webp := append([]byte("RIFF\x24\x00\x00\x00WEBPVP8 "), make([]byte, 8)...) + tests := []struct { + name string + data []byte + want bool + }{ + {name: "png", data: b64BackfillPNGBytes, want: true}, + {name: "jpeg", data: []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 'J', 'F', 'I', 'F', 0x00}, want: true}, + {name: "webp", data: webp, want: true}, + {name: "gif", data: []byte("GIF89a\x01\x00\x01\x00"), want: true}, + {name: "bmp 不在允许列表", data: []byte("BM\x00\x00\x00\x00\x00\x00\x00\x00"), want: false}, + {name: "ico 不在允许列表", data: []byte{0x00, 0x00, 0x01, 0x00, 0x01, 0x00}, want: false}, + {name: "svg 文本", data: []byte(``), want: false}, + {name: "html", data: []byte("login required"), want: false}, + {name: "json", data: []byte(`{"error":"unauthorized"}`), want: false}, + {name: "空", data: nil, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, isBackfillImageContent(tt.data)) + }) + } } func TestBackfillOpenAIImagesB64JSON_NonObjectBodies(t *testing.T) { @@ -263,6 +332,7 @@ func TestOpenAIGatewayServiceForwardImages_APIKeyBackfillsB64JSONFromURL(t *test require.Equal(t, "https://relay.example.com/v1/images/generations", upstream.requests[0].URL.String()) require.Equal(t, http.MethodGet, upstream.requests[1].Method) require.Equal(t, "https://cdn.example.com/cat.png", upstream.requests[1].URL.String()) + require.True(t, HTTPUpstreamPublicHostsOnly(upstream.requests[1].Context())) require.Equal(t, http.StatusOK, rec.Code) wantB64 := base64.StdEncoding.EncodeToString(b64BackfillPNGBytes) diff --git a/backend/internal/util/urlvalidator/validator.go b/backend/internal/util/urlvalidator/validator.go index fc2b9bc4d7..882487b73b 100644 --- a/backend/internal/util/urlvalidator/validator.go +++ b/backend/internal/util/urlvalidator/validator.go @@ -162,6 +162,12 @@ func isAllowedHost(host string, allowlist []string) bool { return false } +// IsBlockedHost 报告 host 是否为 localhost、*.localhost,或回环、私网、链路本地、未指定地址的字面量 IP。 +// 只判断字面量,域名的解析结果由 ValidateResolvedIP 校验。 +func IsBlockedHost(host string) bool { + return isBlockedHost(strings.ToLower(strings.TrimSpace(host))) +} + func isBlockedHost(host string) bool { if host == "localhost" || strings.HasSuffix(host, ".localhost") { return true diff --git a/backend/internal/util/urlvalidator/validator_test.go b/backend/internal/util/urlvalidator/validator_test.go index bec9bb2110..5f401df170 100644 --- a/backend/internal/util/urlvalidator/validator_test.go +++ b/backend/internal/util/urlvalidator/validator_test.go @@ -73,3 +73,20 @@ func TestValidateHTTPURL(t *testing.T) { t.Fatalf("expected localhost to be blocked when allow_private_hosts is false") } } + +func TestIsBlockedHost(t *testing.T) { + for _, host := range []string{ + "localhost", "LOCALHOST", "foo.localhost", " 127.0.0.1 ", + "127.0.0.1", "::1", "10.0.0.5", "172.16.0.1", "192.168.1.1", + "169.254.169.254", "0.0.0.0", "::", "fe80::1", "fc00::1", "::ffff:127.0.0.1", + } { + if !IsBlockedHost(host) { + t.Fatalf("expected %q to be blocked", host) + } + } + for _, host := range []string{"example.com", "cdn.example.com", "93.184.216.34", "8.8.8.8", "2606:4700:4700::1111", ""} { + if IsBlockedHost(host) { + t.Fatalf("expected %q to be allowed", host) + } + } +}