QuantumNous new-api Midjourney Image Relay Endpoint relay-router.go GetByOnlyMJId authorization
Description
A security vulnerability has been detected in QuantumNous new-api up to 0.12.1. This affects the function RelayMidjourneyImage/GetByOnlyMJId of the file router/relay-router.go of the component Midjourney Image Relay Endpoint. Such manipulation leads to authorization bypass. The attack can be launched remotely. The attack requires a high level of complexity. The exploitability is reported as difficult. The exploit has been disclosed publicly and may be used. The vendor was contacted early about this disclosure but did not respond in any way.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
An authorization bypass in QuantumNous new-api up to 0.12.1 allows unauthenticated attackers to retrieve other users' Midjourney images via the /mj/image/:id endpoint.
Vulnerability
In QuantumNous new-api versions up to 0.12.1, the RelayMidjourneyImage handler for the /mj/image/:id endpoint in router/relay-router.go is registered before the TokenAuth and Distribute middleware are applied. The handler calls GetByOnlyMJId in model/midjourney.go, which performs a database query using only the mj_id parameter without verifying that the requesting user owns the corresponding task. This missing authentication and missing object-level authorization allow any unauthenticated request to access image data belonging to any user. [1]
Exploitation
An attacker needs no authentication and only requires a valid mj_id (which may be guessed, enumerated, or obtained through other means). The attack is performed by sending a GET request to /mj/image/{victim_mj_id}. The server then retrieves the image URL from the database via GetByOnlyMJId and streams the image content back to the attacker using io.Copy. The exploit has been publicly disclosed and is considered difficult due to the need to obtain a valid mj_id, but no user interaction or special privileges are required. [1]
Impact
Successful exploitation allows an unauthenticated attacker to exfiltrate any Midjourney-generated image belonging to any user of the application. This results in unauthorized disclosure of potentially sensitive or private image content, compromising user privacy and data confidentiality. [1]
Mitigation
The vendor was contacted but did not respond, and no official fix has been released as of the publication date. Users of QuantumNous new-api up to 0.12.1 should apply a custom patch to enforce authentication (e.g., move the route registration after the middleware) and add user ownership validation in the GetByOnlyMJId query. Until a fix is available, restricting network access to the endpoint or disabling the Midjourney relay feature may reduce risk. [1]
AI Insight generated on May 23, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
1- Range: <=0.12.1
Patches
203758a4a855erefactor(file-source): unify file source creation and enhance caching mechanisms
10 files changed · +290 −384
dto/claude.go+24 −30 modified@@ -98,6 +98,20 @@ func (c *ClaudeMediaMessage) ParseMediaContent() []ClaudeMediaMessage { return mediaContent } +func (m *ClaudeMediaMessage) ToFileSource() types.FileSource { + if m.Source == nil { + return nil + } + data := m.Source.Url + if data == "" { + data = common.Interface2String(m.Source.Data) + } + if data == "" { + return nil + } + return types.NewFileSourceFromData(data, m.Source.MediaType) +} + type ClaudeMessageSource struct { Type string `json:"type"` MediaType string `json:"media_type,omitempty"` @@ -223,14 +237,6 @@ type OutputConfigForEffort struct { Effort string `json:"effort,omitempty"` } -// createClaudeFileSource 根据数据内容创建正确类型的 FileSource -func createClaudeFileSource(data string) *types.FileSource { - if strings.HasPrefix(data, "http://") || strings.HasPrefix(data, "https://") { - return types.NewURLFileSource(data) - } - return types.NewBase64FileSource(data, "") -} - func (c *ClaudeRequest) GetTokenCountMeta() *types.TokenCountMeta { maxTokens := 0 if c.MaxTokens != nil { @@ -258,17 +264,11 @@ func (c *ClaudeRequest) GetTokenCountMeta() *types.TokenCountMeta { case "text": texts = append(texts, media.GetText()) case "image": - if media.Source != nil { - data := media.Source.Url - if data == "" { - data = common.Interface2String(media.Source.Data) - } - if data != "" { - fileMeta = append(fileMeta, &types.FileMeta{ - FileType: types.FileTypeImage, - Source: createClaudeFileSource(data), - }) - } + if source := media.ToFileSource(); source != nil { + fileMeta = append(fileMeta, &types.FileMeta{ + FileType: types.FileTypeImage, + Source: source, + }) } } } @@ -293,17 +293,11 @@ func (c *ClaudeRequest) GetTokenCountMeta() *types.TokenCountMeta { case "text": texts = append(texts, media.GetText()) case "image": - if media.Source != nil { - data := media.Source.Url - if data == "" { - data = common.Interface2String(media.Source.Data) - } - if data != "" { - fileMeta = append(fileMeta, &types.FileMeta{ - FileType: types.FileTypeImage, - Source: createClaudeFileSource(data), - }) - } + if source := media.ToFileSource(); source != nil { + fileMeta = append(fileMeta, &types.FileMeta{ + FileType: types.FileTypeImage, + Source: source, + }) } case "tool_use": if media.Name != "" {
dto/gemini.go+8 −11 modified@@ -64,14 +64,6 @@ type LatLng struct { Longitude *float64 `json:"longitude,omitempty"` } -// createGeminiFileSource 根据数据内容创建正确类型的 FileSource -func createGeminiFileSource(data string, mimeType string) *types.FileSource { - if strings.HasPrefix(data, "http://") || strings.HasPrefix(data, "https://") { - return types.NewURLFileSource(data) - } - return types.NewBase64FileSource(data, mimeType) -} - func (r *GeminiChatRequest) GetTokenCountMeta() *types.TokenCountMeta { var files []*types.FileMeta = make([]*types.FileMeta, 0) @@ -87,9 +79,8 @@ func (r *GeminiChatRequest) GetTokenCountMeta() *types.TokenCountMeta { if part.Text != "" { inputTexts = append(inputTexts, part.Text) } - if part.InlineData != nil && part.InlineData.Data != "" { + if source := part.InlineData.ToFileSource(); source != nil { mimeType := part.InlineData.MimeType - source := createGeminiFileSource(part.InlineData.Data, mimeType) var fileType types.FileType if strings.HasPrefix(mimeType, "image/") { fileType = types.FileTypeImage @@ -103,7 +94,6 @@ func (r *GeminiChatRequest) GetTokenCountMeta() *types.TokenCountMeta { files = append(files, &types.FileMeta{ FileType: fileType, Source: source, - MimeType: mimeType, }) } } @@ -215,6 +205,13 @@ type GeminiInlineData struct { Data string `json:"data"` } +func (d *GeminiInlineData) ToFileSource() types.FileSource { + if d == nil || d.Data == "" { + return nil + } + return types.NewFileSourceFromData(d.Data, d.MimeType) +} + // UnmarshalJSON custom unmarshaler for GeminiInlineData to support snake_case and camelCase for MimeType func (g *GeminiInlineData) UnmarshalJSON(data []byte) error { type Alias GeminiInlineData // Use type alias to avoid recursion
dto/openai_request.go+53 −47 modified@@ -108,14 +108,6 @@ type GeneralOpenAIRequest struct { ReasoningSplit json.RawMessage `json:"reasoning_split,omitempty"` } -// createFileSource 根据数据内容创建正确类型的 FileSource -func createFileSource(data string) *types.FileSource { - if strings.HasPrefix(data, "http://") || strings.HasPrefix(data, "https://") { - return types.NewURLFileSource(data) - } - return types.NewBase64FileSource(data, "") -} - func (r *GeneralOpenAIRequest) GetTokenCountMeta() *types.TokenCountMeta { var tokenCountMeta types.TokenCountMeta var texts = make([]string, 0) @@ -159,44 +151,24 @@ func (r *GeneralOpenAIRequest) GetTokenCountMeta() *types.TokenCountMeta { } arrayContent := message.ParseContent() for _, m := range arrayContent { - if m.Type == ContentTypeImageURL { - imageUrl := m.GetImageMedia() - if imageUrl != nil && imageUrl.Url != "" { - source := createFileSource(imageUrl.Url) - fileMeta = append(fileMeta, &types.FileMeta{ - FileType: types.FileTypeImage, - Source: source, - Detail: imageUrl.Detail, - }) - } - } else if m.Type == ContentTypeInputAudio { - inputAudio := m.GetInputAudio() - if inputAudio != nil && inputAudio.Data != "" { - source := createFileSource(inputAudio.Data) - fileMeta = append(fileMeta, &types.FileMeta{ - FileType: types.FileTypeAudio, - Source: source, - }) - } - } else if m.Type == ContentTypeFile { - file := m.GetFile() - if file != nil && file.FileData != "" { - source := createFileSource(file.FileData) - fileMeta = append(fileMeta, &types.FileMeta{ - FileType: types.FileTypeFile, - Source: source, - }) - } - } else if m.Type == ContentTypeVideoUrl { - videoUrl := m.GetVideoUrl() - if videoUrl != nil && videoUrl.Url != "" { - source := createFileSource(videoUrl.Url) - fileMeta = append(fileMeta, &types.FileMeta{ - FileType: types.FileTypeVideo, - Source: source, - }) + source := m.ToFileSource() + if source != nil { + meta := &types.FileMeta{Source: source} + switch m.Type { + case ContentTypeImageURL: + meta.FileType = types.FileTypeImage + if img := m.GetImageMedia(); img != nil { + meta.Detail = img.Detail + } + case ContentTypeInputAudio: + meta.FileType = types.FileTypeAudio + case ContentTypeFile: + meta.FileType = types.FileTypeFile + case ContentTypeVideoUrl: + meta.FileType = types.FileTypeVideo } - } else { + fileMeta = append(fileMeta, meta) + } else if m.Type == ContentTypeText { texts = append(texts, m.Text) } } @@ -391,6 +363,40 @@ func (m *MediaContent) GetVideoUrl() *MessageVideoUrl { return nil } +func (m *MediaContent) ToFileSource() types.FileSource { + switch m.Type { + case ContentTypeImageURL: + img := m.GetImageMedia() + if img == nil || img.Url == "" { + return nil + } + return types.NewFileSourceFromData(img.Url, img.MimeType) + case ContentTypeInputAudio: + audio := m.GetInputAudio() + if audio == nil || audio.Data == "" { + return nil + } + mimeType := "" + if audio.Format != "" { + mimeType = "audio/" + audio.Format + } + return types.NewFileSourceFromData(audio.Data, mimeType) + case ContentTypeFile: + file := m.GetFile() + if file == nil || file.FileData == "" { + return nil + } + return types.NewFileSourceFromData(file.FileData, "") + case ContentTypeVideoUrl: + video := m.GetVideoUrl() + if video == nil || video.Url == "" { + return nil + } + return types.NewFileSourceFromData(video.Url, "") + } + return nil +} + type MessageImageUrl struct { Url string `json:"url"` Detail string `json:"detail,omitempty"` @@ -865,15 +871,15 @@ func (r *OpenAIResponsesRequest) GetTokenCountMeta() *types.TokenCountMeta { if input.ImageUrl != "" { fileMeta = append(fileMeta, &types.FileMeta{ FileType: types.FileTypeImage, - Source: createFileSource(input.ImageUrl), + Source: types.NewFileSourceFromData(input.ImageUrl, ""), Detail: input.Detail, }) } } else if input.Type == "input_file" { if input.FileUrl != "" { fileMeta = append(fileMeta, &types.FileMeta{ FileType: types.FileTypeFile, - Source: createFileSource(input.FileUrl), + Source: types.NewFileSourceFromData(input.FileUrl, ""), }) } } else {
relay/channel/claude/relay-claude.go+15 −83 modified@@ -1,12 +1,10 @@ package claude import ( - "encoding/base64" "encoding/json" "fmt" "io" "net/http" - "path/filepath" "strings" "github.com/QuantumNous/new-api/common" @@ -46,61 +44,6 @@ func maybeMarkClaudeRefusal(c *gin.Context, stopReason string) { } } -func createClaudeFileSource(file *dto.MessageFile) *types.FileSource { - if file == nil || file.FileData == "" { - return nil - } - if strings.HasPrefix(file.FileData, "http://") || strings.HasPrefix(file.FileData, "https://") { - return types.NewURLFileSource(file.FileData) - } - mimeType := "" - if ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(file.FileName)), "."); ext != "" { - if detected := service.GetMimeTypeByExtension(ext); detected != "application/octet-stream" { - mimeType = detected - } - } - return types.NewBase64FileSource(file.FileData, mimeType) -} - -func buildClaudeFileMessage(c *gin.Context, file *dto.MessageFile) (*dto.ClaudeMediaMessage, error) { - source := createClaudeFileSource(file) - if source == nil { - return nil, nil - } - base64Data, mimeType, err := service.GetBase64Data(c, source, "formatting document for Claude") - if err != nil { - return nil, fmt.Errorf("get file data failed: %w", err) - } - switch strings.ToLower(mimeType) { - case "application/pdf": - return &dto.ClaudeMediaMessage{ - Type: "document", - Source: &dto.ClaudeMessageSource{ - Type: "base64", - MediaType: mimeType, - Data: base64Data, - }, - }, nil - case "text/plain": - decodedData, err := base64.StdEncoding.DecodeString(base64Data) - if err != nil { - return nil, fmt.Errorf("decode text file data failed: %w", err) - } - return &dto.ClaudeMediaMessage{ - Type: "text", - Text: common.GetPointer(string(decodedData)), - }, nil - default: - msg := fmt.Sprintf("claude: skip unsupported file content, filename=%q, mime=%q", file.FileName, mimeType) - if c != nil { - logger.LogInfo(c, msg) - } else { - common.SysLog(msg) - } - return nil, nil - } -} - func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRequest) (*dto.ClaudeRequest, error) { claudeTools := make([]any, 0, len(textRequest.Tools)) @@ -142,7 +85,7 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe // 解析 UserLocation JSON var userLocationMap map[string]interface{} - if err := json.Unmarshal(textRequest.WebSearchOptions.UserLocation, &userLocationMap); err == nil { + if err := common.Unmarshal(textRequest.WebSearchOptions.UserLocation, &userLocationMap); err == nil { // 检查是否有 approximate 字段 if approximateData, ok := userLocationMap["approximate"].(map[string]interface{}); ok { if timezone, ok := approximateData["timezone"].(string); ok && timezone != "" { @@ -406,44 +349,33 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe Type: "text", Text: common.GetPointer[string](mediaMessage.Text), }) - case dto.ContentTypeImageURL: + default: + source := mediaMessage.ToFileSource() + if source == nil { + continue + } + base64Data, mimeType, err := service.GetBase64Data(c, source, "formatting image for Claude") + if err != nil { + return nil, fmt.Errorf("get file data failed: %s", err.Error()) + } claudeMediaMessage := dto.ClaudeMediaMessage{ - Type: "image", Source: &dto.ClaudeMessageSource{ Type: "base64", }, } - imageUrl := mediaMessage.GetImageMedia() - if imageUrl == nil { - continue - } - // 使用统一的文件服务获取图片数据 - var source *types.FileSource - if strings.HasPrefix(imageUrl.Url, "http") { - source = types.NewURLFileSource(imageUrl.Url) + if strings.HasPrefix(mimeType, "application/pdf") { + claudeMediaMessage.Type = "document" } else { - source = types.NewBase64FileSource(imageUrl.Url, "") - } - base64Data, mimeType, err := service.GetBase64Data(c, source, "formatting image for Claude") - if err != nil { - return nil, fmt.Errorf("get file data failed: %s", err.Error()) + claudeMediaMessage.Type = "image" } + claudeMediaMessage.Source.MediaType = mimeType claudeMediaMessage.Source.Data = base64Data claudeMediaMessages = append(claudeMediaMessages, claudeMediaMessage) - // FIXME - //case dto.ContentTypeFile: - // claudeFileMessage, err := buildClaudeFileMessage(c, mediaMessage.GetFile()) - // if err != nil { - // return nil, err - // } - // if claudeFileMessage != nil { - // claudeMediaMessages = append(claudeMediaMessages, *claudeFileMessage) - // } - default: continue } } + if message.ToolCalls != nil { for _, toolCall := range message.ParseToolCalls() { inputObj := make(map[string]any)
relay/channel/gemini/relay-gemini.go+4 −38 modified@@ -585,14 +585,10 @@ func CovertOpenAI2Gemini(c *gin.Context, textRequest dto.GeneralOpenAIRequest, i Text: part.Text, }) } - } else if part.Type == dto.ContentTypeImageURL { - // 使用统一的文件服务获取图片数据 - var source *types.FileSource - imageUrl := part.GetImageMedia().Url - if strings.HasPrefix(imageUrl, "http") { - source = types.NewURLFileSource(imageUrl) - } else { - source = types.NewBase64FileSource(imageUrl, "") + } else { + source := part.ToFileSource() + if source == nil { + continue } base64Data, mimeType, err := service.GetBase64Data(c, source, "formatting image for Gemini") if err != nil { @@ -604,36 +600,6 @@ func CovertOpenAI2Gemini(c *gin.Context, textRequest dto.GeneralOpenAIRequest, i return nil, fmt.Errorf("mime type is not supported by Gemini: '%s', url: '%s', supported types are: %v", mimeType, source.GetIdentifier(), getSupportedMimeTypesList()) } - parts = append(parts, dto.GeminiPart{ - InlineData: &dto.GeminiInlineData{ - MimeType: mimeType, - Data: base64Data, - }, - }) - } else if part.Type == dto.ContentTypeFile { - if part.GetFile().FileId != "" { - return nil, fmt.Errorf("only base64 file is supported in gemini") - } - fileSource := types.NewBase64FileSource(part.GetFile().FileData, "") - base64Data, mimeType, err := service.GetBase64Data(c, fileSource, "formatting file for Gemini") - if err != nil { - return nil, fmt.Errorf("decode base64 file data failed: %s", err.Error()) - } - parts = append(parts, dto.GeminiPart{ - InlineData: &dto.GeminiInlineData{ - MimeType: mimeType, - Data: base64Data, - }, - }) - } else if part.Type == dto.ContentTypeInputAudio { - if part.GetInputAudio().Data == "" { - return nil, fmt.Errorf("only base64 audio is supported in gemini") - } - audioSource := types.NewBase64FileSource(part.GetInputAudio().Data, "audio/"+part.GetInputAudio().Format) - base64Data, mimeType, err := service.GetBase64Data(c, audioSource, "formatting audio for Gemini") - if err != nil { - return nil, fmt.Errorf("decode base64 audio data failed: %s", err.Error()) - } parts = append(parts, dto.GeminiPart{ InlineData: &dto.GeminiInlineData{ MimeType: mimeType,
relay/channel/ollama/relay-ollama.go+2 −9 modified@@ -98,15 +98,8 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam parts := m.ParseContent() for _, part := range parts { if part.Type == dto.ContentTypeImageURL { - img := part.GetImageMedia() - if img != nil && img.Url != "" { - // 使用统一的文件服务获取图片数据 - var source *types.FileSource - if strings.HasPrefix(img.Url, "http") { - source = types.NewURLFileSource(img.Url) - } else { - source = types.NewBase64FileSource(img.Url, "") - } + source := part.ToFileSource() + if source != nil { base64Data, _, err := service.GetBase64Data(c, source, "fetch image for ollama chat") if err != nil { return nil, err
service/file_service.go+53 −32 modified@@ -25,14 +25,26 @@ import ( // FileService 统一的文件处理服务 // 提供文件下载、解码、缓存等功能的统一入口 -// getContextCacheKey 生成 context 缓存的 key +// getContextCacheKey 生成 URL context 缓存的 key func getContextCacheKey(url string) string { return fmt.Sprintf("file_cache_%s", common.GenerateHMAC(url)) } +// getBase64ContextCacheKey 生成 base64 context 缓存的 key +// 使用 length + MIME + 前 128 字符作为输入,避免对整个 base64 数据做 hash +func getBase64ContextCacheKey(data string, mimeType string) string { + keyMaterial := fmt.Sprintf("%d:%s:", len(data), mimeType) + if len(data) > 128 { + keyMaterial += data[:128] + } else { + keyMaterial += data + } + return fmt.Sprintf("b64_cache_%s", common.GenerateHMAC(keyMaterial)) +} + // LoadFileSource 加载文件源数据 // 这是统一的入口,会自动处理缓存和不同的来源类型 -func LoadFileSource(c *gin.Context, source *types.FileSource, reason ...string) (*types.CachedFileData, error) { +func LoadFileSource(c *gin.Context, source types.FileSource, reason ...string) (*types.CachedFileData, error) { if source == nil { return nil, fmt.Errorf("file source is nil") } @@ -43,7 +55,6 @@ func LoadFileSource(c *gin.Context, source *types.FileSource, reason ...string) // 1. 快速检查内部缓存 if source.HasCache() { - // 即使命中内部缓存,也要确保注册到清理列表(如果尚未注册) if c != nil { registerSourceForCleanup(c, source) } @@ -62,39 +73,49 @@ func LoadFileSource(c *gin.Context, source *types.FileSource, reason ...string) return source.GetCache(), nil } - // 4. 如果是 URL,检查 Context 缓存 - var contextKey string - if source.IsURL() && c != nil { - contextKey = getContextCacheKey(source.URL) - if cachedData, exists := c.Get(contextKey); exists { - data := cachedData.(*types.CachedFileData) - source.SetCache(data) - registerSourceForCleanup(c, source) - return data, nil - } - } - - // 5. 执行加载逻辑 + // 4. 根据来源类型加载(含 URL context 缓存查找) var cachedData *types.CachedFileData + var contextKey string var err error - if source.IsURL() { - cachedData, err = loadFromURL(c, source.URL, reason...) - } else { - cachedData, err = loadFromBase64(source.Base64Data, source.MimeType) + switch s := source.(type) { + case *types.URLSource: + if c != nil { + contextKey = getContextCacheKey(s.URL) + if cached, exists := c.Get(contextKey); exists { + data := cached.(*types.CachedFileData) + source.SetCache(data) + registerSourceForCleanup(c, source) + return data, nil + } + } + cachedData, err = loadFromURL(c, s.URL, reason...) + case *types.Base64Source: + if c != nil { + contextKey = getBase64ContextCacheKey(s.Base64Data, s.MimeType) + if cached, exists := c.Get(contextKey); exists { + data := cached.(*types.CachedFileData) + source.SetCache(data) + registerSourceForCleanup(c, source) + return data, nil + } + } + cachedData, err = loadFromBase64(s.Base64Data, s.MimeType) + default: + return nil, fmt.Errorf("unsupported file source type: %T", source) } if err != nil { return nil, err } - // 6. 设置缓存 + // 5. 设置缓存 source.SetCache(cachedData) if contextKey != "" && c != nil { c.Set(contextKey, cachedData) } - // 7. 注册到 context 以便请求结束时自动清理 + // 6. 注册到 context 以便请求结束时自动清理 if c != nil { registerSourceForCleanup(c, source) } @@ -103,15 +124,15 @@ func LoadFileSource(c *gin.Context, source *types.FileSource, reason ...string) } // registerSourceForCleanup 注册 FileSource 到 context 以便请求结束时清理 -func registerSourceForCleanup(c *gin.Context, source *types.FileSource) { +func registerSourceForCleanup(c *gin.Context, source types.FileSource) { if source.IsRegistered() { return } key := string(constant.ContextKeyFileSourcesToCleanup) - var sources []*types.FileSource + var sources []types.FileSource if existing, exists := c.Get(key); exists { - sources = existing.([]*types.FileSource) + sources = existing.([]types.FileSource) } sources = append(sources, source) c.Set(key, sources) @@ -123,12 +144,12 @@ func registerSourceForCleanup(c *gin.Context, source *types.FileSource) { func CleanupFileSources(c *gin.Context) { key := string(constant.ContextKeyFileSourcesToCleanup) if sources, exists := c.Get(key); exists { - for _, source := range sources.([]*types.FileSource) { + for _, source := range sources.([]types.FileSource) { if cache := source.GetCache(); cache != nil { cache.Close() } } - c.Set(key, nil) // 清除引用 + c.Set(key, nil) } } @@ -363,7 +384,7 @@ func loadFromBase64(base64String string, providedMimeType string) (*types.Cached } // GetImageConfig 获取图片配置 -func GetImageConfig(c *gin.Context, source *types.FileSource) (image.Config, string, error) { +func GetImageConfig(c *gin.Context, source types.FileSource) (image.Config, string, error) { cachedData, err := LoadFileSource(c, source, "get_image_config") if err != nil { return image.Config{}, "", err @@ -394,7 +415,7 @@ func GetImageConfig(c *gin.Context, source *types.FileSource) (image.Config, str } // GetBase64Data 获取 base64 编码的数据 -func GetBase64Data(c *gin.Context, source *types.FileSource, reason ...string) (string, string, error) { +func GetBase64Data(c *gin.Context, source types.FileSource, reason ...string) (string, string, error) { cachedData, err := LoadFileSource(c, source, reason...) if err != nil { return "", "", err @@ -407,13 +428,13 @@ func GetBase64Data(c *gin.Context, source *types.FileSource, reason ...string) ( } // GetMimeType 获取文件的 MIME 类型 -func GetMimeType(c *gin.Context, source *types.FileSource) (string, error) { +func GetMimeType(c *gin.Context, source types.FileSource) (string, error) { if source.HasCache() { return source.GetCache().MimeType, nil } - if source.IsURL() { - mimeType, err := GetFileTypeFromUrl(c, source.URL, "get_mime_type") + if urlSource, ok := source.(*types.URLSource); ok { + mimeType, err := GetFileTypeFromUrl(c, urlSource.URL, "get_mime_type") if err == nil && mimeType != "" && mimeType != "application/octet-stream" { return mimeType, nil }
service/token_counter.go+0 −3 modified@@ -100,8 +100,6 @@ func getImageToken(c *gin.Context, fileMeta *types.FileMeta, model string, strea if err != nil { return 0, err } - fileMeta.MimeType = format - if config.Width == 0 || config.Height == 0 { // not an image, but might be a valid file if format != "" { @@ -268,7 +266,6 @@ func EstimateRequestToken(c *gin.Context, meta *types.TokenCountMeta, info *rela } continue } - file.MimeType = cachedData.MimeType file.FileType = DetectFileType(cachedData.MimeType) } }
types/file_source.go+127 −126 modified@@ -4,39 +4,144 @@ import ( "fmt" "image" "os" + "strings" "sync" ) -// FileSourceType 文件来源类型 -type FileSourceType string +// FileSource 统一的文件来源抽象接口 +// 支持 URL 和 base64 两种来源,提供懒加载和缓存机制 +type FileSource interface { + IsURL() bool + GetIdentifier() string + GetRawData() string + ClearRawData() -const ( - FileSourceTypeURL FileSourceType = "url" // URL 来源 - FileSourceTypeBase64 FileSourceType = "base64" // Base64 内联数据 -) + SetCache(data *CachedFileData) + GetCache() *CachedFileData + HasCache() bool + ClearCache() -// FileSource 统一的文件来源抽象 -// 支持 URL 和 base64 两种来源,提供懒加载和缓存机制 -type FileSource struct { - Type FileSourceType `json:"type"` // 来源类型 - URL string `json:"url,omitempty"` // URL(当 Type 为 url 时) - Base64Data string `json:"base64_data,omitempty"` // Base64 数据(当 Type 为 base64 时) - MimeType string `json:"mime_type,omitempty"` // MIME 类型(可选,会自动检测) + IsRegistered() bool + SetRegistered(registered bool) + Mu() *sync.Mutex +} - // 内部缓存(不导出,不序列化) +// baseFileSource 共享的缓存/锁/清理注册状态 +type baseFileSource struct { cachedData *CachedFileData cacheLoaded bool - registered bool // 是否已注册到清理列表 - mu sync.Mutex // 保护加载过程 + registered bool + mu sync.Mutex +} + +func (b *baseFileSource) SetCache(data *CachedFileData) { + b.cachedData = data + b.cacheLoaded = true +} + +func (b *baseFileSource) GetCache() *CachedFileData { + return b.cachedData +} + +func (b *baseFileSource) HasCache() bool { + return b.cacheLoaded && b.cachedData != nil +} + +func (b *baseFileSource) ClearCache() { + if b.cachedData != nil { + b.cachedData.Close() + } + b.cachedData = nil + b.cacheLoaded = false +} + +func (b *baseFileSource) IsRegistered() bool { + return b.registered +} + +func (b *baseFileSource) SetRegistered(registered bool) { + b.registered = registered } -// Mu 获取内部锁 -func (f *FileSource) Mu() *sync.Mutex { - return &f.mu +func (b *baseFileSource) Mu() *sync.Mutex { + return &b.mu } -// CachedFileData 缓存的文件数据 -// 支持内存缓存和磁盘缓存两种模式 +// --------------------------------------------------------------------------- +// URLSource — URL 来源的 FileSource 实现 +// --------------------------------------------------------------------------- + +type URLSource struct { + baseFileSource + URL string +} + +func (u *URLSource) IsURL() bool { return true } + +func (u *URLSource) GetIdentifier() string { + if len(u.URL) > 100 { + return u.URL[:100] + "..." + } + return u.URL +} + +func (u *URLSource) GetRawData() string { return u.URL } + +func (u *URLSource) ClearRawData() {} + +// --------------------------------------------------------------------------- +// Base64Source — Base64 内联数据来源的 FileSource 实现 +// --------------------------------------------------------------------------- + +type Base64Source struct { + baseFileSource + Base64Data string + MimeType string +} + +func (b *Base64Source) IsURL() bool { return false } + +func (b *Base64Source) GetIdentifier() string { + if len(b.Base64Data) > 50 { + return "base64:" + b.Base64Data[:50] + "..." + } + return "base64:" + b.Base64Data +} + +func (b *Base64Source) GetRawData() string { return b.Base64Data } + +func (b *Base64Source) ClearRawData() { + if len(b.Base64Data) > 1024 { + b.Base64Data = "" + } +} + +// --------------------------------------------------------------------------- +// Constructors +// --------------------------------------------------------------------------- + +func NewURLFileSource(url string) *URLSource { + return &URLSource{URL: url} +} + +func NewBase64FileSource(base64Data string, mimeType string) *Base64Source { + return &Base64Source{ + Base64Data: base64Data, + MimeType: mimeType, + } +} + +func NewFileSourceFromData(data string, mimeType string) FileSource { + if strings.HasPrefix(data, "http://") || strings.HasPrefix(data, "https://") { + return NewURLFileSource(data) + } + return NewBase64FileSource(data, mimeType) +} + +// --------------------------------------------------------------------------- +// CachedFileData — 缓存的文件数据(支持内存和磁盘两种模式) +// --------------------------------------------------------------------------- + type CachedFileData struct { base64Data string // 内存中的 base64 数据(小文件) MimeType string // MIME 类型 @@ -45,18 +150,15 @@ type CachedFileData struct { ImageConfig *image.Config // 图片配置(如果是图片) ImageFormat string // 图片格式(如果是图片) - // 磁盘缓存相关 diskPath string // 磁盘缓存文件路径(大文件) isDisk bool // 是否使用磁盘缓存 diskMu sync.Mutex // 磁盘操作锁(保护磁盘文件的读取和删除) diskClosed bool // 是否已关闭/清理 statDecremented bool // 是否已扣减统计 - // 统计回调,避免循环依赖 OnClose func(size int64) } -// NewMemoryCachedData 创建内存缓存的数据 func NewMemoryCachedData(base64Data string, mimeType string, size int64) *CachedFileData { return &CachedFileData{ base64Data: base64Data, @@ -66,7 +168,6 @@ func NewMemoryCachedData(base64Data string, mimeType string, size int64) *Cached } } -// NewDiskCachedData 创建磁盘缓存的数据 func NewDiskCachedData(diskPath string, mimeType string, size int64) *CachedFileData { return &CachedFileData{ diskPath: diskPath, @@ -76,7 +177,6 @@ func NewDiskCachedData(diskPath string, mimeType string, size int64) *CachedFile } } -// GetBase64Data 获取 base64 数据(自动处理内存/磁盘) func (c *CachedFileData) GetBase64Data() (string, error) { if !c.isDisk { return c.base64Data, nil @@ -89,30 +189,26 @@ func (c *CachedFileData) GetBase64Data() (string, error) { return "", fmt.Errorf("disk cache already closed") } - // 从磁盘读取 data, err := os.ReadFile(c.diskPath) if err != nil { return "", fmt.Errorf("failed to read from disk cache: %w", err) } return string(data), nil } -// SetBase64Data 设置 base64 数据(仅用于内存模式) func (c *CachedFileData) SetBase64Data(data string) { if !c.isDisk { c.base64Data = data } } -// IsDisk 是否使用磁盘缓存 func (c *CachedFileData) IsDisk() bool { return c.isDisk } -// Close 关闭并清理资源 func (c *CachedFileData) Close() error { if !c.isDisk { - c.base64Data = "" // 释放内存 + c.base64Data = "" return nil } @@ -126,7 +222,6 @@ func (c *CachedFileData) Close() error { c.diskClosed = true if c.diskPath != "" { err := os.Remove(c.diskPath) - // 只有在删除成功且未扣减过统计时,才执行回调 if err == nil && !c.statDecremented && c.OnClose != nil { c.OnClose(c.DiskSize) c.statDecremented = true @@ -135,97 +230,3 @@ func (c *CachedFileData) Close() error { } return nil } - -// NewURLFileSource 创建 URL 来源的 FileSource -func NewURLFileSource(url string) *FileSource { - return &FileSource{ - Type: FileSourceTypeURL, - URL: url, - } -} - -// NewBase64FileSource 创建 base64 来源的 FileSource -func NewBase64FileSource(base64Data string, mimeType string) *FileSource { - return &FileSource{ - Type: FileSourceTypeBase64, - Base64Data: base64Data, - MimeType: mimeType, - } -} - -// IsURL 判断是否是 URL 来源 -func (f *FileSource) IsURL() bool { - return f.Type == FileSourceTypeURL -} - -// IsBase64 判断是否是 base64 来源 -func (f *FileSource) IsBase64() bool { - return f.Type == FileSourceTypeBase64 -} - -// GetIdentifier 获取文件标识符(用于日志和错误追踪) -func (f *FileSource) GetIdentifier() string { - if f.IsURL() { - if len(f.URL) > 100 { - return f.URL[:100] + "..." - } - return f.URL - } - if len(f.Base64Data) > 50 { - return "base64:" + f.Base64Data[:50] + "..." - } - return "base64:" + f.Base64Data -} - -// GetRawData 获取原始数据(URL 或完整的 base64 字符串) -func (f *FileSource) GetRawData() string { - if f.IsURL() { - return f.URL - } - return f.Base64Data -} - -// SetCache 设置缓存数据 -func (f *FileSource) SetCache(data *CachedFileData) { - f.cachedData = data - f.cacheLoaded = true -} - -// IsRegistered 是否已注册到清理列表 -func (f *FileSource) IsRegistered() bool { - return f.registered -} - -// SetRegistered 设置注册状态 -func (f *FileSource) SetRegistered(registered bool) { - f.registered = registered -} - -// GetCache 获取缓存数据 -func (f *FileSource) GetCache() *CachedFileData { - return f.cachedData -} - -// HasCache 是否有缓存 -func (f *FileSource) HasCache() bool { - return f.cacheLoaded && f.cachedData != nil -} - -// ClearCache 清除缓存,释放内存和磁盘文件 -func (f *FileSource) ClearCache() { - // 如果有缓存数据,先关闭它(会清理磁盘文件) - if f.cachedData != nil { - f.cachedData.Close() - } - f.cachedData = nil - f.cacheLoaded = false -} - -// ClearRawData 清除原始数据,只保留必要的元信息 -// 用于在处理完成后释放大文件的内存 -func (f *FileSource) ClearRawData() { - // 保留 URL(通常很短),只清除大的 base64 数据 - if f.IsBase64() && len(f.Base64Data) > 1024 { - f.Base64Data = "" - } -}
types/request_meta.go+4 −5 modified@@ -32,21 +32,20 @@ type TokenCountMeta struct { type FileMeta struct { FileType - MimeType string - Source *FileSource // 统一的文件来源(URL 或 base64) - Detail string // 图片细节级别(low/high/auto) + Source FileSource // 统一的文件来源(URL 或 base64) + Detail string // 图片细节级别(low/high/auto) } // NewFileMeta 创建新的 FileMeta -func NewFileMeta(fileType FileType, source *FileSource) *FileMeta { +func NewFileMeta(fileType FileType, source FileSource) *FileMeta { return &FileMeta{ FileType: fileType, Source: source, } } // NewImageFileMeta 创建图片类型的 FileMeta -func NewImageFileMeta(source *FileSource, detail string) *FileMeta { +func NewImageFileMeta(source FileSource, detail string) *FileMeta { return &FileMeta{ FileType: FileTypeImage, Source: source,
eacc245bad1cMerge pull request #4106 from HynoR/feat/fix
4 files changed · +37 −9
web/src/components/playground/configStorage.js+4 −0 modified@@ -65,11 +65,15 @@ export const loadConfig = () => { const savedConfig = localStorage.getItem(STORAGE_KEYS.CONFIG); if (savedConfig) { const parsedConfig = JSON.parse(savedConfig); + const parsedMaxTokens = parseInt(parsedConfig?.inputs?.max_tokens, 10); const mergedConfig = { inputs: { ...DEFAULT_CONFIG.inputs, ...parsedConfig.inputs, + max_tokens: Number.isNaN(parsedMaxTokens) + ? parsedConfig?.inputs?.max_tokens + : parsedMaxTokens, }, parameterEnabled: { ...DEFAULT_CONFIG.parameterEnabled,
web/src/components/playground/ParameterControl.jsx+13 −7 modified@@ -18,7 +18,14 @@ For commercial licensing, please contact support@quantumnous.com */ import React from 'react'; -import { Input, Slider, Typography, Button, Tag } from '@douyinfe/semi-ui'; +import { + Input, + InputNumber, + Slider, + Typography, + Button, + Tag, +} from '@douyinfe/semi-ui'; import { useTranslation } from 'react-i18next'; import { Hash, @@ -241,15 +248,14 @@ const ParameterControl = ({ disabled={disabled} /> </div> - <Input + <InputNumber placeholder='MaxTokens' name='max_tokens' - required - autoComplete='new-password' - defaultValue={0} value={inputs.max_tokens} - onChange={(value) => onInputChange('max_tokens', value)} - className='!rounded-lg' + onNumberChange={(value) => onInputChange('max_tokens', value)} + min={0} + precision={0} + style={{ width: '100%' }} disabled={!parameterEnabled.max_tokens || disabled} /> </div>
web/src/helpers/api.js+12 −1 modified@@ -150,7 +150,18 @@ export const buildApiPayload = ( const value = inputs[param]; const hasValue = value !== undefined && value !== null; - if (enabled && hasValue) { + if (!enabled) { + return; + } + + if (param === 'max_tokens') { + if (typeof value === 'number') { + payload[param] = value; + } + return; + } + + if (hasValue) { payload[param] = value; } });
web/src/hooks/playground/usePlaygroundState.js+8 −1 modified@@ -167,7 +167,14 @@ export const usePlaygroundState = () => { // 配置导入/重置 const handleConfigImport = useCallback((importedConfig) => { if (importedConfig.inputs) { - setInputs((prev) => ({ ...prev, ...importedConfig.inputs })); + const parsedMaxTokens = parseInt(importedConfig.inputs.max_tokens, 10); + setInputs((prev) => ({ + ...prev, + ...importedConfig.inputs, + max_tokens: Number.isNaN(parsedMaxTokens) + ? importedConfig.inputs.max_tokens + : parsedMaxTokens, + })); } if (importedConfig.parameterEnabled) { setParameterEnabled((prev) => ({
Vulnerability mechanics
Root cause
"Missing authorization check in the RelayMidjourneyImage/GetByOnlyMJId function allows unauthenticated access to restricted resources."
Attack vector
An attacker can send a crafted HTTP request to the Midjourney Image Relay endpoint, targeting the `GetByOnlyMJId` function. The attack is remote and requires high complexity, meaning the attacker likely needs knowledge of internal identifiers or timing. By exploiting the missing authorization check [patch_id=1996419], the attacker can retrieve images or data that should be restricted to authenticated or authorized users. The public disclosure of an exploit increases the practical risk.
Affected code
The vulnerability resides in the Midjourney Image Relay Endpoint within `router/relay-router.go`, specifically in the `RelayMidjourneyImage/GetByOnlyMJId` function. The patch files provided do not touch this Go router file, so the exact code path is not shown in the supplied diff. The advisory indicates the endpoint lacks proper authorization checks, allowing an attacker to bypass access controls.
What the fix does
The supplied patch [patch_id=1996419] does not modify `router/relay-router.go` and therefore does not directly fix the authorization bypass in the Midjourney Image Relay endpoint. Instead, the patch improves input sanitization for `max_tokens` in the playground UI by switching from a generic `Input` to an `InputNumber` component, adding type validation in `buildApiPayload`, and parsing `max_tokens` as an integer during config import and load. These changes prevent non-numeric or malformed `max_tokens` values from being sent to the API, but they do not address the missing authorization check described in the advisory.
Preconditions
- networkThe attacker must have network access to the vulnerable Midjourney Image Relay endpoint.
- inputThe attacker likely needs to know or guess a valid internal identifier (e.g., MJ ID) to target specific resources.
- authNo authentication or session is required, as the vulnerability is an authorization bypass.
Generated on May 23, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4- gist.github.com/YLChen-007/13974ead25fc6dac42fd7bac62fbb2dfmitreexploit
- vuldb.com/submit/812196mitrethird-party-advisory
- vuldb.com/vuln/365253mitrevdb-entrytechnical-description
- vuldb.com/vuln/365253/ctimitresignaturepermissions-required
News mentions
0No linked articles in our index yet.