Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 27 additions & 6 deletions controller/video_proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"time"

"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"

Expand Down Expand Up @@ -36,7 +38,7 @@ func VideoProxy(c *gin.Context) {
return
}
if !exists || task == nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to get task %s: %s", taskID, err.Error()))
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to get task %s: %v", taskID, err))
Comment thread
creamlike1024 marked this conversation as resolved.
c.JSON(http.StatusNotFound, gin.H{
"error": gin.H{
"message": "Task not found",
Expand All @@ -58,7 +60,7 @@ func VideoProxy(c *gin.Context) {

channel, err := model.CacheGetChannel(task.ChannelId)
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to get channel %d: %s", task.ChannelId, err.Error()))
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to get task %s: not found", taskID))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix incorrect error log message.

This logs "Failed to get task" but the error is from channel retrieval (line 61), not task retrieval. Additionally, logging "not found" assumes the error type when it could be a database error, network error, etc.

Apply this diff:

- logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to get task %s: not found", taskID))
+ logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to get channel for task %s: %v", taskID, err))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to get task %s: not found", taskID))
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to get channel for task %s: %v", taskID, err))
🤖 Prompt for AI Agents
In controller/video_proxy.go around line 63, the log wrongly says "Failed to get
task ...: not found" although the error is from channel retrieval; update the
log to reference channel retrieval (e.g., "Failed to get channel <channelID> for
task <taskID>") and include the actual error value rather than assuming "not
found" (pass err into the log message or parameters) so the log shows real error
context and identifiers using c.Request.Context().

c.JSON(http.StatusInternalServerError, gin.H{
"error": gin.H{
"message": "Failed to retrieve channel information",
Expand All @@ -71,15 +73,15 @@ func VideoProxy(c *gin.Context) {
if baseURL == "" {
baseURL = "https://api.openai.com"
}
videoURL := fmt.Sprintf("%s/v1/videos/%s/content", baseURL, task.TaskID)

var videoURL string
client := &http.Client{
Timeout: 60 * time.Second,
}

req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, videoURL, nil)
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, "", nil)
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to create request for %s: %s", videoURL, err.Error()))
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to create request: %s", err.Error()))
c.JSON(http.StatusInternalServerError, gin.H{
"error": gin.H{
"message": "Failed to create proxy request",
Expand All @@ -89,7 +91,26 @@ func VideoProxy(c *gin.Context) {
return
}

req.Header.Set("Authorization", "Bearer "+channel.Key)
if channel.Type == constant.ChannelTypeGemini {
videoURL = fmt.Sprintf("%s&key=%s", c.Query("url"), channel.Key)
req.Header.Set("x-goog-api-key", channel.Key)
Comment on lines +94 to +96

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: SSRF vulnerability and URL construction bugs.

Line 95 introduces multiple critical issues:

  1. SSRF vulnerability: c.Query("url") is user-controlled input used directly to construct the proxy URL. An attacker could:

    • Access internal services (e.g., http://localhost:8080/admin)
    • Hit cloud metadata endpoints (e.g., http://169.254.169.254/latest/meta-data/)
    • Exfiltrate data through DNS or HTTP requests
  2. URL construction bug: Appending &key=... assumes the URL already has query parameters. If c.Query("url") is http://example.com, the result is http://example.com&key=... (invalid URL).

  3. API key exposure: Placing the API key in the URL can leak it through logs, caches, or HTTP referrer headers.

  4. Redundant authentication: The key is set in both the URL (line 95) and the x-goog-api-key header (line 96).

Recommended fixes:

  1. For Gemini channels: The video URL should be derived from trusted sources (e.g., the task record stored in the database), not from user input. Consider storing the Gemini video URL in the task record when the video is generated, then retrieve it here.

  2. If you must accept a URL parameter: Implement strict validation:

    • Whitelist allowed schemes (https only)
    • Whitelist allowed domains (e.g., only *.googleapis.com)
    • Reject private IP ranges and localhost
  3. Fix query parameter construction: Use Go's url.Parse and url.Values to properly append query parameters.

  4. Remove API key from URL: Use only the header for authentication.

Example validation approach:

if channel.Type == constant.ChannelTypeGemini {
    rawURL := c.Query("url")
    if rawURL == "" {
        // return error: URL parameter required
    }
    
    parsedURL, err := url.Parse(rawURL)
    if err != nil {
        // return error: invalid URL
    }
    
    // Validate scheme and host
    if parsedURL.Scheme != "https" {
        // return error: only HTTPS allowed
    }
    if !strings.HasSuffix(parsedURL.Host, ".googleapis.com") {
        // return error: only googleapis.com domains allowed
    }
    
    videoURL = parsedURL.String()
    req.Header.Set("x-goog-api-key", channel.Key)
} else {
    // ... existing logic
}

Would you like me to generate a complete implementation with proper validation and error handling?

} else {
// Default (Sora, etc.): Use original logic
videoURL = fmt.Sprintf("%s/v1/videos/%s/content", baseURL, task.TaskID)
req.Header.Set("Authorization", "Bearer "+channel.Key)
}

req.URL, err = url.Parse(videoURL)
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to parse URL %s: %s", videoURL, err.Error()))
c.JSON(http.StatusInternalServerError, gin.H{
"error": gin.H{
"message": "Failed to create proxy request",
"type": "server_error",
},
})
return
}

resp, err := client.Do(req)
if err != nil {
Expand Down
276 changes: 276 additions & 0 deletions relay/channel/task/gemini/adaptor.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
package gemini

import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"

"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/relay/channel"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/model_setting"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/gin-gonic/gin"
"github.com/pkg/errors"
)

// ============================
// Request / Response structures
// ============================

// GeminiVideoGenerationConfig represents the video generation configuration
// Based on: https://ai.google.dev/gemini-api/docs/video
type GeminiVideoGenerationConfig struct {
AspectRatio string `json:"aspectRatio,omitempty"` // "16:9" or "9:16"
DurationSeconds float64 `json:"durationSeconds,omitempty"` // 4, 6, or 8 (as number)
NegativePrompt string `json:"negativePrompt,omitempty"` // unwanted elements
PersonGeneration string `json:"personGeneration,omitempty"` // "allow_all" for text-to-video, "allow_adult" for image-to-video
Resolution string `json:"resolution,omitempty"` // video resolution
}

// GeminiVideoRequest represents a single video generation instance
type GeminiVideoRequest struct {
Prompt string `json:"prompt"`
}

// GeminiVideoPayload represents the complete video generation request payload
type GeminiVideoPayload struct {
Instances []GeminiVideoRequest `json:"instances"`
Parameters GeminiVideoGenerationConfig `json:"parameters,omitempty"`
}

type submitResponse struct {
Name string `json:"name"`
}

type operationVideo struct {
MimeType string `json:"mimeType"`
BytesBase64Encoded string `json:"bytesBase64Encoded"`
Encoding string `json:"encoding"`
}

type operationResponse struct {
Name string `json:"name"`
Done bool `json:"done"`
Response struct {
Type string `json:"@type"`
RaiMediaFilteredCount int `json:"raiMediaFilteredCount"`
Videos []operationVideo `json:"videos"`
BytesBase64Encoded string `json:"bytesBase64Encoded"`
Encoding string `json:"encoding"`
Video string `json:"video"`
GenerateVideoResponse struct {
GeneratedSamples []struct {
Video struct {
URI string `json:"uri"`
} `json:"video"`
} `json:"generatedSamples"`
} `json:"generateVideoResponse"`
} `json:"response"`
Error struct {
Message string `json:"message"`
} `json:"error"`
}

// ============================
// Adaptor implementation
// ============================

type TaskAdaptor struct {
ChannelType int
apiKey string
baseURL string
}

func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) {
a.ChannelType = info.ChannelType
a.baseURL = info.ChannelBaseUrl
a.apiKey = info.ApiKey
}

// ValidateRequestAndSetAction parses body, validates fields and sets default action.
func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.TaskError) {
// Use the standard validation method for TaskSubmitReq
return relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionTextGenerate)
}

// BuildRequestURL constructs the upstream URL.
func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) {
modelName := info.OriginModelName
version := model_setting.GetGeminiVersionSetting(modelName)

return fmt.Sprintf(
"%s/%s/models/%s:predictLongRunning",
a.baseURL,
version,
modelName,
), nil
}

// BuildRequestHeader sets required headers.
func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("x-goog-api-key", a.apiKey)
return nil
}

// BuildRequestBody converts request into Gemini specific format.
func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) {
v, ok := c.Get("task_request")
if !ok {
return nil, fmt.Errorf("request not found in context")
}
req, ok := v.(relaycommon.TaskSubmitReq)
if !ok {
return nil, fmt.Errorf("unexpected task_request type")
}

Comment thread
creamlike1024 marked this conversation as resolved.
// Create structured video generation request
body := GeminiVideoPayload{
Instances: []GeminiVideoRequest{
{Prompt: req.Prompt},
},
Parameters: GeminiVideoGenerationConfig{},
}

metadata := req.Metadata
medaBytes, err := json.Marshal(metadata)
if err != nil {
return nil, errors.Wrap(err, "metadata marshal metadata failed")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix typo in error message.

The error message contains a duplicate word.

Apply this diff:

-		return nil, errors.Wrap(err, "metadata marshal metadata failed")
+		return nil, errors.Wrap(err, "marshal metadata failed")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return nil, errors.Wrap(err, "metadata marshal metadata failed")
return nil, errors.Wrap(err, "marshal metadata failed")
🤖 Prompt for AI Agents
In relay/channel/task/gemini/adaptor.go around line 148, the error message
passed to errors.Wrap contains a duplicated word ("metadata marshal metadata
failed"); update the message to remove the duplicate and read clearly (for
example: "metadata marshal failed" or "marshal metadata failed") by replacing
the string in the errors.Wrap call.

}
err = json.Unmarshal(medaBytes, &body.Parameters)
if err != nil {
return nil, errors.Wrap(err, "unmarshal metadata failed")
}

data, err := json.Marshal(body)
if err != nil {
return nil, err
}
return bytes.NewReader(data), nil
}

// DoRequest delegates to common helper.
func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) {
return channel.DoTaskApiRequest(a, c, info, requestBody)
}

// DoResponse handles upstream response, returns taskID etc.
func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) {
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", nil, service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError)
}
_ = resp.Body.Close()

var s submitResponse
if err := json.Unmarshal(responseBody, &s); err != nil {
return "", nil, service.TaskErrorWrapper(err, "unmarshal_response_failed", http.StatusInternalServerError)
}
if strings.TrimSpace(s.Name) == "" {
return "", nil, service.TaskErrorWrapper(fmt.Errorf("missing operation name"), "invalid_response", http.StatusInternalServerError)
}
taskID = encodeLocalTaskID(s.Name)
ov := dto.NewOpenAIVideo()
ov.ID = taskID
ov.TaskID = taskID
ov.CreatedAt = time.Now().Unix()
ov.Model = info.OriginModelName
c.JSON(http.StatusOK, ov)
return taskID, responseBody, nil
}

func (a *TaskAdaptor) GetModelList() []string {
return []string{"veo-3.0-generate-001", "veo-3.1-generate-preview", "veo-3.1-fast-generate-preview"}
}

func (a *TaskAdaptor) GetChannelName() string {
return "gemini"
}

// FetchTask fetch task status
func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http.Response, error) {
taskID, ok := body["task_id"].(string)
if !ok {
return nil, fmt.Errorf("invalid task_id")
}

upstreamName, err := decodeLocalTaskID(taskID)
if err != nil {
return nil, fmt.Errorf("decode task_id failed: %w", err)
}

// For Gemini API, we use GET request to the operations endpoint
version := model_setting.GetGeminiVersionSetting("default")
url := fmt.Sprintf("%s/%s/%s", baseUrl, version, upstreamName)

req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}

req.Header.Set("Accept", "application/json")
req.Header.Set("x-goog-api-key", key)

return service.GetHttpClient().Do(req)
}

func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) {
var op operationResponse
if err := json.Unmarshal(respBody, &op); err != nil {
return nil, fmt.Errorf("unmarshal operation response failed: %w", err)
}

ti := &relaycommon.TaskInfo{}

if op.Error.Message != "" {
ti.Status = model.TaskStatusFailure
ti.Reason = op.Error.Message
ti.Progress = "100%"
return ti, nil
}
Comment on lines +235 to +240

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Verify whether error messages can contain data: URLs.

According to project learnings, data: URLs (containing base64 video data) should be prevented from being stored in task failure reasons. While it's unlikely that op.Error.Message contains a data: URL, defensive coding would check before assignment.

Based on learnings.

Run the following script to check whether Gemini API error responses have been observed to contain data: URLs or large base64-encoded content:


🏁 Script executed:

#!/bin/bash
# Search for patterns where Gemini error responses might contain base64 or data: URLs
rg -nP --type=go -C3 '(Error|error|message).*data:|base64' -g '**/gemini/**'

Length of output: 6682


Address URL encoding issue on line 255 when constructing the proxy URL.

The URI extracted from the Gemini API response must be properly URL-encoded before interpolating it into the query parameter. Special characters in the URI will break the URL if not encoded. This was a concern from a prior review that remains unresolved.

🤖 Prompt for AI Agents
In relay/channel/task/gemini/adaptor.go around lines 235 to 240 (and
specifically where the proxy URL is built near line 255), the URI obtained from
the Gemini API must be URL-encoded before being interpolated into the proxy
query parameter; update the code that constructs the proxy URL to pass the URI
through a URL-encoding function appropriate for query values (e.g.,
url.QueryEscape) and use the encoded value when building the final URL, ensuring
you handle empty/nil URI cases consistently.


if !op.Done {
ti.Status = model.TaskStatusInProgress
ti.Progress = "50%"
return ti, nil
}

ti.Status = model.TaskStatusSuccess
ti.Progress = "100%"

// Extract URL from generateVideoResponse if available
if len(op.Response.GenerateVideoResponse.GeneratedSamples) > 0 {
if uri := op.Response.GenerateVideoResponse.GeneratedSamples[0].Video.URI; uri != "" {
taskID := encodeLocalTaskID(op.Name)
ti.Url = fmt.Sprintf("%s/v1/videos/%s/content?url=%s", system_setting.ServerAddress, taskID, uri)
}
}
Comment on lines +251 to +257

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

URL-encode the upstream URI when constructing the proxy link.

Without encoding, any “?” or “&” in the URI breaks the url=... query param, yielding truncated links.

+// import "net/url" at top
- ti.Url = fmt.Sprintf("%s/v1/videos/%s/content?url=%s", system_setting.ServerAddress, taskID, uri)
+ ti.Url = fmt.Sprintf("%s/v1/videos/%s/content?url=%s",
+   system_setting.ServerAddress, taskID, url.QueryEscape(uri))

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In relay/channel/task/gemini/adaptor.go around lines 248 to 254, the code builds
a proxy URL by interpolating the upstream video URI directly into a query
parameter which breaks when the URI contains "?" or "&"; URL-encode the upstream
URI before adding it to the url= query param (use net/url's QueryEscape or
url.Values/URL struct to encode the value) so the generated ti.Url is a valid,
safely-escaped URL.


return ti, nil
}

// ============================
// helpers
// ============================

func encodeLocalTaskID(name string) string {
return base64.RawURLEncoding.EncodeToString([]byte(name))
}

func decodeLocalTaskID(local string) (string, error) {
b, err := base64.RawURLEncoding.DecodeString(local)
if err != nil {
return "", err
}
return string(b), nil
}
3 changes: 3 additions & 0 deletions relay/relay_adaptor.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (
"github.com/QuantumNous/new-api/relay/channel/siliconflow"
"github.com/QuantumNous/new-api/relay/channel/submodel"
taskdoubao "github.com/QuantumNous/new-api/relay/channel/task/doubao"
taskGemini "github.com/QuantumNous/new-api/relay/channel/task/gemini"
taskjimeng "github.com/QuantumNous/new-api/relay/channel/task/jimeng"
"github.com/QuantumNous/new-api/relay/channel/task/kling"
tasksora "github.com/QuantumNous/new-api/relay/channel/task/sora"
Expand Down Expand Up @@ -141,6 +142,8 @@ func GetTaskAdaptor(platform constant.TaskPlatform) channel.TaskAdaptor {
return &taskdoubao.TaskAdaptor{}
case constant.ChannelTypeSora, constant.ChannelTypeOpenAI:
return &tasksora.TaskAdaptor{}
case constant.ChannelTypeGemini:
return &taskGemini.TaskAdaptor{}
}
}
return nil
Expand Down