fix(api): 自定义渠道 - 移除浏览器 CORS 依赖

This commit is contained in:
Hao OUYANG
2026-07-22 21:44:44 +08:00
parent 180baf4be8
commit a5bc71c194
9 changed files with 820 additions and 28 deletions

View File

@@ -72,6 +72,7 @@ func main() {
handler.RegisterAnnouncementRoutes(api, svc)
handler.RegisterFinanceRoutes(api, svc)
handler.RegisterSystemProxyRoutes(api, svc)
handler.RegisterCustomRelayRoutes(api, svc)
handler.RegisterTaskRoutes(api, svc)
handler.RegisterSessionRoutes(api, svc)
handler.RegisterSkillRoutes(api, svc)
@@ -117,7 +118,7 @@ func cors() gin.HandlerFunc {
c.Header("Access-Control-Allow-Credentials", "true")
c.Header("Vary", "Origin, Access-Control-Request-Method, Access-Control-Request-Headers")
}
c.Header("Access-Control-Allow-Headers", "Accept, Content-Type, Authorization, X-Requested-With, X-Canvas-Scene, X-Idempotency-Key")
c.Header("Access-Control-Allow-Headers", "Accept, Content-Type, Authorization, X-Requested-With, X-Canvas-Scene, X-Idempotency-Key, X-Canvas-Upstream-URL, X-Canvas-Upstream-Format")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
c.Header("Access-Control-Max-Age", "86400")
if c.Request.Method == "OPTIONS" {

View File

@@ -0,0 +1,287 @@
package handler
import (
"bytes"
"encoding/json"
"errors"
"io"
"mime"
"net/http"
"strings"
"sync"
"time"
"infinite-canvas/backend/internal/service"
"github.com/gin-gonic/gin"
)
const (
maxCustomRelayBodyBytes int64 = 32 << 20
maxCustomRelayModelResponseBytes int64 = 4 << 20
maxCustomRelayJSONResponseBytes int64 = 16 << 20
maxCustomRelayErrorResponseBytes int64 = 64 << 10
maxCustomRelayStreamBytes int64 = 32 << 20
maxCustomRelayConcurrency = 4
customRelayHTTPTimeout = 10 * time.Minute
)
var (
customRelayClient = service.CustomRelayHTTPClient
customRelayActive = struct {
sync.Mutex
users map[string]int
}{users: map[string]int{}}
)
func RegisterCustomRelayRoutes(r *gin.RouterGroup, svc *service.Service) {
r.Any("/ai/custom", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
if !enforceRateLimit(c, "custom-relay:"+user.ID, 120, time.Minute) {
return
}
if !acquireCustomRelaySlot(user.ID) {
fail(c, http.StatusTooManyRequests, errors.New("自定义渠道并发请求过多,请等待已有请求完成"))
return
}
defer releaseCustomRelaySlot(user.ID)
proxyCustomRelayRequest(c)
})
}
func proxyCustomRelayRequest(c *gin.Context) {
target, err := service.ValidateCustomRelayURL(c.GetHeader("X-Canvas-Upstream-URL"))
if err != nil {
failService(c, err)
return
}
apiFormat := strings.ToLower(strings.TrimSpace(c.GetHeader("X-Canvas-Upstream-Format")))
if apiFormat == "" {
apiFormat = "openai"
}
if err := authorizeCustomRelay(c.Request.Method, target, apiFormat, c.GetHeader("Content-Type")); err != nil {
fail(c, http.StatusForbidden, err)
return
}
apiKey, err := customRelayAPIKey(c.GetHeader("Authorization"))
if err != nil {
fail(c, http.StatusUnauthorized, err)
return
}
if c.Request.ContentLength > maxCustomRelayBodyBytes {
fail(c, http.StatusRequestEntityTooLarge, errors.New("自定义渠道请求不能超过 32MB"))
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxCustomRelayBodyBytes)
body, err := io.ReadAll(c.Request.Body)
if err != nil {
var maxBytesError *http.MaxBytesError
if errors.As(err, &maxBytesError) {
fail(c, http.StatusRequestEntityTooLarge, errors.New("自定义渠道请求不能超过 32MB"))
return
}
fail(c, http.StatusBadRequest, errors.New("读取自定义渠道请求失败"))
return
}
if c.Request.Method == http.MethodGet && len(body) != 0 {
fail(c, http.StatusBadRequest, errors.New("模型列表请求不允许携带请求体"))
return
}
upstreamReq, err := http.NewRequestWithContext(c.Request.Context(), c.Request.Method, target.String(), bytes.NewReader(body))
if err != nil {
fail(c, http.StatusBadRequest, errors.New("构造自定义渠道请求失败"))
return
}
if contentType := c.GetHeader("Content-Type"); contentType != "" {
upstreamReq.Header.Set("Content-Type", contentType)
}
if strings.Contains(strings.ToLower(c.GetHeader("Accept")), "text/event-stream") {
upstreamReq.Header.Set("Accept", "text/event-stream")
} else {
upstreamReq.Header.Set("Accept", "application/json")
}
upstreamReq.Header.Set("User-Agent", "InfiniteCanvas/custom-channel-relay")
if apiFormat == "gemini" {
upstreamReq.Header.Set("x-goog-api-key", apiKey)
} else {
upstreamReq.Header.Set("Authorization", "Bearer "+apiKey)
}
resp, err := customRelayClient(customRelayHTTPTimeout).Do(upstreamReq)
if err != nil {
fail(c, http.StatusBadGateway, errors.New("自定义渠道上游连接失败"))
return
}
defer resp.Body.Close()
writeCustomRelayResponse(c, resp, apiKey, c.Request.Method == http.MethodGet)
}
func writeCustomRelayResponse(c *gin.Context, resp *http.Response, apiKey string, modelList bool) {
c.Header("Cache-Control", "no-store")
c.Header("X-Content-Type-Options", "nosniff")
mediaType, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Type"))
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
writeCustomRelayError(c, resp, apiKey, mediaType)
return
}
if mediaType == "text/event-stream" {
c.Header("Content-Type", "text/event-stream; charset=utf-8")
c.Header("X-Accel-Buffering", "no")
c.Status(resp.StatusCode)
c.Writer.WriteHeaderNow()
copyCustomRelayStream(c, resp.Body, apiKey)
return
}
if mediaType != "application/json" && !strings.HasSuffix(mediaType, "+json") {
fail(c, http.StatusBadGateway, errors.New("自定义渠道上游返回了不支持的内容类型"))
return
}
limit := maxCustomRelayJSONResponseBytes
if modelList {
limit = maxCustomRelayModelResponseBytes
}
body, err := readLimitedRelayBody(resp.Body, limit)
if err != nil || !json.Valid(body) {
fail(c, http.StatusBadGateway, errors.New("自定义渠道上游返回无效或过大的 JSON"))
return
}
body = redactRelaySecret(body, apiKey)
c.Data(resp.StatusCode, "application/json; charset=utf-8", body)
}
func writeCustomRelayError(c *gin.Context, resp *http.Response, apiKey string, mediaType string) {
body, err := readLimitedRelayBody(resp.Body, maxCustomRelayErrorResponseBytes)
if err != nil {
fail(c, http.StatusBadGateway, errors.New("自定义渠道上游请求失败"))
return
}
body = redactRelaySecret(body, apiKey)
if (mediaType == "application/json" || strings.HasSuffix(mediaType, "+json")) && json.Valid(body) {
c.Data(resp.StatusCode, "application/json; charset=utf-8", body)
return
}
fail(c, resp.StatusCode, errors.New("自定义渠道上游请求失败"))
}
func copyCustomRelayStream(c *gin.Context, source io.Reader, apiKey string) {
redactor := newRelayStreamRedactor(apiKey)
buffer := make([]byte, 32<<10)
var written int64
for written < maxCustomRelayStreamBytes {
read, err := source.Read(buffer)
if read > 0 {
remaining := maxCustomRelayStreamBytes - written
if int64(read) > remaining {
read = int(remaining)
}
chunk := redactor.Push(buffer[:read], false)
if len(chunk) > 0 {
if _, writeErr := c.Writer.Write(chunk); writeErr != nil {
return
}
c.Writer.Flush()
}
written += int64(read)
}
if err != nil {
break
}
}
if tail := redactor.Push(nil, true); len(tail) > 0 {
_, _ = c.Writer.Write(tail)
c.Writer.Flush()
}
}
func readLimitedRelayBody(body io.Reader, limit int64) ([]byte, error) {
data, err := io.ReadAll(io.LimitReader(body, limit+1))
if err != nil {
return nil, err
}
if int64(len(data)) > limit {
return nil, errors.New("response body is too large")
}
return data, nil
}
func customRelayAPIKey(value string) (string, error) {
scheme, apiKey, found := strings.Cut(strings.TrimSpace(value), " ")
apiKey = strings.TrimSpace(apiKey)
if !found || !strings.EqualFold(scheme, "Bearer") || apiKey == "" || len(apiKey) > 512 || strings.ContainsAny(apiKey, "\r\n") {
return "", errors.New("自定义渠道 API Key 无效")
}
return apiKey, nil
}
func acquireCustomRelaySlot(userID string) bool {
customRelayActive.Lock()
defer customRelayActive.Unlock()
if customRelayActive.users[userID] >= maxCustomRelayConcurrency {
return false
}
customRelayActive.users[userID]++
return true
}
func releaseCustomRelaySlot(userID string) {
customRelayActive.Lock()
defer customRelayActive.Unlock()
if customRelayActive.users[userID] <= 1 {
delete(customRelayActive.users, userID)
return
}
customRelayActive.users[userID]--
}
func redactRelaySecret(body []byte, apiKey string) []byte {
if apiKey == "" {
return body
}
return bytes.ReplaceAll(body, []byte(apiKey), []byte("[REDACTED]"))
}
type relayStreamRedactor struct {
secret []byte
pending []byte
}
func newRelayStreamRedactor(secret string) *relayStreamRedactor {
return &relayStreamRedactor{secret: []byte(secret)}
}
func (r *relayStreamRedactor) Push(chunk []byte, final bool) []byte {
r.pending = append(r.pending, chunk...)
if len(r.secret) == 0 {
result := append([]byte(nil), r.pending...)
r.pending = r.pending[:0]
return result
}
r.pending = bytes.ReplaceAll(r.pending, r.secret, []byte("[REDACTED]"))
if final {
result := append([]byte(nil), r.pending...)
r.pending = r.pending[:0]
return result
}
keep := relaySecretPrefixSuffixLength(r.pending, r.secret)
cut := len(r.pending) - keep
result := append([]byte(nil), r.pending[:cut]...)
r.pending = append(r.pending[:0], r.pending[cut:]...)
return result
}
func relaySecretPrefixSuffixLength(data []byte, secret []byte) int {
limit := len(secret) - 1
if len(data) < limit {
limit = len(data)
}
for length := limit; length > 0; length-- {
if bytes.Equal(data[len(data)-length:], secret[:length]) {
return length
}
}
return 0
}

View File

@@ -0,0 +1,202 @@
package handler
import (
"bufio"
"bytes"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
)
func TestCustomRelayForwardsOpenAIRequestWithoutBrowserHeaders(t *testing.T) {
gin.SetMode(gin.TestMode)
const apiKey = "relay-secret-key"
upstream := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer "+apiKey {
t.Errorf("Authorization = %q", r.Header.Get("Authorization"))
}
for _, name := range []string{"Cookie", "Origin", "Referer", "X-Canvas-Upstream-URL", "X-Forwarded-For"} {
if value := r.Header.Get(name); value != "" {
t.Errorf("upstream received %s = %q", name, value)
}
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Set-Cookie", "upstream=unsafe")
_, _ = io.WriteString(w, `{"data":[]}`)
}))
defer upstream.Close()
useCustomRelayTestClient(t, upstream.Client())
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "127.0.0.1")
request := httptest.NewRequest(http.MethodGet, "/api/ai/custom", nil)
request.Header.Set("Authorization", "Bearer "+apiKey)
request.Header.Set("X-Canvas-Upstream-URL", upstream.URL+"/v1/models")
request.Header.Set("X-Canvas-Upstream-Format", "openai")
request.Header.Set("Cookie", "browser=session")
request.Header.Set("Origin", "https://canvas.example.com")
response := httptest.NewRecorder()
context, _ := gin.CreateTestContext(response)
context.Request = request
proxyCustomRelayRequest(context)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
if response.Header().Get("Set-Cookie") != "" {
t.Fatal("upstream Set-Cookie should not be forwarded")
}
if strings.Contains(response.Body.String(), apiKey) {
t.Fatal("response leaked API key")
}
}
func TestCustomRelayConvertsGeminiAuthentication(t *testing.T) {
gin.SetMode(gin.TestMode)
const apiKey = "gemini-secret-key"
upstream := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("x-goog-api-key") != apiKey {
t.Errorf("x-goog-api-key = %q", r.Header.Get("x-goog-api-key"))
}
if r.Header.Get("Authorization") != "" {
t.Errorf("Authorization should not be forwarded, got %q", r.Header.Get("Authorization"))
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"candidates":[]}`)
}))
defer upstream.Close()
useCustomRelayTestClient(t, upstream.Client())
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "127.0.0.1")
request := httptest.NewRequest(http.MethodPost, "/api/ai/custom", strings.NewReader(`{"contents":[]}`))
request.Header.Set("Authorization", "Bearer "+apiKey)
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Canvas-Upstream-URL", upstream.URL+"/v1beta/models/gemini-test:generateContent")
request.Header.Set("X-Canvas-Upstream-Format", "gemini")
response := httptest.NewRecorder()
context, _ := gin.CreateTestContext(response)
context.Request = request
proxyCustomRelayRequest(context)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
}
func TestCustomRelayStreamsBeforeUpstreamCompletes(t *testing.T) {
gin.SetMode(gin.TestMode)
const apiKey = "stream-secret"
release := make(chan struct{})
upstream := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = io.WriteString(w, "data: first\n\n")
w.(http.Flusher).Flush()
<-release
_, _ = io.WriteString(w, "data: second\n\n")
}))
defer upstream.Close()
useCustomRelayTestClient(t, upstream.Client())
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "127.0.0.1")
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
context, _ := gin.CreateTestContext(w)
context.Request = r
proxyCustomRelayRequest(context)
}))
defer proxy.Close()
request, _ := http.NewRequest(http.MethodPost, proxy.URL, strings.NewReader(`{"model":"test"}`))
request.Header.Set("Authorization", "Bearer "+apiKey)
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "text/event-stream")
request.Header.Set("X-Canvas-Upstream-URL", upstream.URL+"/v1/responses")
request.Header.Set("X-Canvas-Upstream-Format", "openai")
client := &http.Client{Timeout: 3 * time.Second}
response, err := client.Do(request)
if err != nil {
close(release)
t.Fatal(err)
}
reader := bufio.NewReader(response.Body)
line, err := reader.ReadString('\n')
if err != nil {
close(release)
_ = response.Body.Close()
t.Fatal(err)
}
if line != "data: first\n" {
close(release)
_ = response.Body.Close()
t.Fatalf("first streamed line = %q", line)
}
close(release)
_ = response.Body.Close()
}
func TestRelayStreamRedactorHandlesSplitSecret(t *testing.T) {
redactor := newRelayStreamRedactor("split-secret")
output := append(redactor.Push([]byte("before split-"), false), redactor.Push([]byte("secret after"), true)...)
if bytes.Contains(output, []byte("split-secret")) || !bytes.Contains(output, []byte("[REDACTED]")) {
t.Fatalf("redacted output = %q", output)
}
}
func TestCustomRelayRejectsOversizedDeclaredBodyBeforeConnecting(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "127.0.0.1")
connected := false
previous := customRelayClient
customRelayClient = func(time.Duration) *http.Client {
connected = true
return http.DefaultClient
}
t.Cleanup(func() { customRelayClient = previous })
request := httptest.NewRequest(http.MethodPost, "/api/ai/custom", strings.NewReader(`{"model":"test"}`))
request.ContentLength = maxCustomRelayBodyBytes + 1
request.Header.Set("Authorization", "Bearer test-key")
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Canvas-Upstream-URL", "https://127.0.0.1/v1/responses")
request.Header.Set("X-Canvas-Upstream-Format", "openai")
response := httptest.NewRecorder()
context, _ := gin.CreateTestContext(response)
context.Request = request
proxyCustomRelayRequest(context)
if response.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
if connected {
t.Fatal("oversized request should not create an upstream client")
}
}
func TestCustomRelayConcurrencyLimitReleasesSlots(t *testing.T) {
const userID = "relay-limit-test-user"
for index := 0; index < maxCustomRelayConcurrency; index++ {
if !acquireCustomRelaySlot(userID) {
t.Fatalf("slot %d should be available", index)
}
}
if acquireCustomRelaySlot(userID) {
t.Fatal("request above the concurrency limit should be rejected")
}
for index := 0; index < maxCustomRelayConcurrency; index++ {
releaseCustomRelaySlot(userID)
}
if !acquireCustomRelaySlot(userID) {
t.Fatal("released slot should be reusable")
}
releaseCustomRelaySlot(userID)
}
func useCustomRelayTestClient(t *testing.T, client *http.Client) {
t.Helper()
previous := customRelayClient
customRelayClient = func(time.Duration) *http.Client { return client }
t.Cleanup(func() { customRelayClient = previous })
}

View File

@@ -23,9 +23,10 @@ import (
const maxSystemProxyBodyBytes int64 = 64 << 20
var (
runtimeService *service.Service
geminiGeneratePath = regexp.MustCompile(`^/models/([^/:]+):(generateContent|streamGenerateContent)$`)
openAIPostEndpoints = map[string]bool{
runtimeService *service.Service
geminiGeneratePath = regexp.MustCompile(`^/models/([^/:]+):(generateContent|streamGenerateContent)$`)
customGeminiRelayPath = regexp.MustCompile(`(?:^|/)models/[^/:]+:(generateContent|streamGenerateContent)$`)
openAIPostEndpoints = map[string]bool{
"/responses": true, "/chat/completions": true, "/images/generations": true, "/images/edits": true,
"/audio/speech": true,
}
@@ -35,6 +36,72 @@ func ConfigureRuntime(svc *service.Service) {
runtimeService = svc
}
func authorizeCustomRelay(method string, target *url.URL, apiFormat string, contentType string) error {
requestPath, err := normalizedCustomRelayPath(target.EscapedPath())
if err != nil {
return err
}
query, err := url.ParseQuery(target.RawQuery)
if err != nil {
return errors.New("自定义渠道查询参数无效")
}
for key := range query {
switch strings.ToLower(strings.TrimSpace(key)) {
case "key", "api_key", "access_token", "token":
return errors.New("自定义渠道地址不允许在查询参数中携带密钥")
}
}
apiFormat = strings.ToLower(strings.TrimSpace(apiFormat))
if apiFormat != "openai" && apiFormat != "gemini" {
return errors.New("自定义渠道调用格式无效")
}
if method == http.MethodGet {
if len(query) != 0 || (requestPath != "/models" && !strings.HasSuffix(requestPath, "/models")) {
return errors.New("自定义渠道不允许访问该上游接口")
}
return nil
}
mediaType, _, err := mime.ParseMediaType(contentType)
if err != nil || mediaType != "application/json" {
return errors.New("自定义渠道生成请求必须使用 application/json")
}
if method != http.MethodPost {
return errors.New("自定义渠道不允许使用该请求方法")
}
if apiFormat == "openai" {
if len(query) != 0 || (!strings.HasSuffix(requestPath, "/responses") && !strings.HasSuffix(requestPath, "/chat/completions")) {
return errors.New("自定义渠道不允许访问该上游接口")
}
return nil
}
if !customGeminiRelayPath.MatchString(requestPath) {
return errors.New("自定义渠道不允许访问该上游接口")
}
if len(query) == 0 {
return nil
}
if len(query) == 1 && len(query["alt"]) == 1 && query.Get("alt") == "sse" && strings.HasSuffix(requestPath, ":streamGenerateContent") {
return nil
}
return errors.New("自定义渠道不允许使用该查询参数")
}
func normalizedCustomRelayPath(value string) (string, error) {
if len(value) > 2048 {
return "", errors.New("自定义渠道请求路径过长")
}
decoded, err := url.PathUnescape(value)
if err != nil || strings.Contains(decoded, "\\") || strings.Contains(decoded, "\x00") {
return "", errors.New("自定义渠道请求路径无效")
}
cleaned := path.Clean("/" + strings.TrimPrefix(decoded, "/"))
if cleaned != decoded && cleaned != "/"+strings.TrimPrefix(decoded, "/") {
return "", errors.New("自定义渠道请求路径无效")
}
return cleaned, nil
}
func enforceRateLimit(c *gin.Context, key string, limit int, window time.Duration) bool {
if runtimeService == nil {
fail(c, http.StatusServiceUnavailable, errors.New("请求协调器尚未初始化"))

View File

@@ -3,6 +3,7 @@ package handler
import (
"mime/multipart"
"net/http"
"net/url"
"strings"
"testing"
@@ -17,6 +18,54 @@ func TestAuthorizeSystemProxyAllowsConfiguredGenerationModel(t *testing.T) {
}
}
func TestAuthorizeCustomRelayAllowsModelsAndAgentEndpoints(t *testing.T) {
tests := []struct {
method string
target string
apiFormat string
contentType string
}{
{method: http.MethodGet, target: "https://api.example.com/v1/models", apiFormat: "openai"},
{method: http.MethodPost, target: "https://api.example.com/v1/responses", apiFormat: "openai", contentType: "application/json"},
{method: http.MethodPost, target: "https://api.example.com/v1/chat/completions", apiFormat: "openai", contentType: "application/json; charset=utf-8"},
{method: http.MethodPost, target: "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:streamGenerateContent?alt=sse", apiFormat: "gemini", contentType: "application/json"},
}
for _, test := range tests {
target, err := url.Parse(test.target)
if err != nil {
t.Fatal(err)
}
if err := authorizeCustomRelay(test.method, target, test.apiFormat, test.contentType); err != nil {
t.Fatalf("authorizeCustomRelay(%s %s) error = %v", test.method, test.target, err)
}
}
}
func TestAuthorizeCustomRelayRejectsArbitraryRequestsAndCredentialQueries(t *testing.T) {
tests := []struct {
method string
target string
apiFormat string
contentType string
}{
{method: http.MethodDelete, target: "https://api.example.com/v1/models", apiFormat: "openai"},
{method: http.MethodGet, target: "https://api.example.com/account", apiFormat: "openai"},
{method: http.MethodGet, target: "https://api.example.com/v1/models?api_key=secret", apiFormat: "openai"},
{method: http.MethodPost, target: "https://api.example.com/v1/responses", apiFormat: "openai", contentType: "text/plain"},
{method: http.MethodPost, target: "https://api.example.com/v1/../account/chat/completions", apiFormat: "openai", contentType: "application/json"},
{method: http.MethodPost, target: "https://api.example.com/v1/models/gemini:streamGenerateContent?alt=sse&token=secret", apiFormat: "gemini", contentType: "application/json"},
}
for _, test := range tests {
target, err := url.Parse(test.target)
if err != nil {
t.Fatal(err)
}
if err := authorizeCustomRelay(test.method, target, test.apiFormat, test.contentType); err == nil {
t.Fatalf("authorizeCustomRelay(%s %s) should fail", test.method, test.target)
}
}
}
func TestAuthorizeSystemProxyRejectsArbitraryPathAndModel(t *testing.T) {
channel := &model.ModelChannel{APIFormat: "openai", ModelsJSON: `["gpt-image-1"]`}
if err := authorizeSystemProxy(channel, http.MethodDelete, "/account", "application/json", nil); err == nil {

View File

@@ -5,6 +5,7 @@ import (
"errors"
"net"
"net/http"
"net/netip"
"net/url"
"os"
"strings"
@@ -13,7 +14,22 @@ import (
const maxOutboundRedirects = 5
var outboundTransport = newOutboundTransport()
var (
outboundTransport = newOutboundTransport(resolveOutboundHost)
customRelayTransport = newOutboundTransport(resolveCustomRelayHost)
blockedCustomRelayPrefixes = []netip.Prefix{
netip.MustParsePrefix("0.0.0.0/8"),
netip.MustParsePrefix("100.64.0.0/10"),
netip.MustParsePrefix("192.0.0.0/24"),
netip.MustParsePrefix("192.0.2.0/24"),
netip.MustParsePrefix("198.18.0.0/15"),
netip.MustParsePrefix("198.51.100.0/24"),
netip.MustParsePrefix("203.0.113.0/24"),
netip.MustParsePrefix("240.0.0.0/4"),
netip.MustParsePrefix("100::/64"),
netip.MustParsePrefix("2001:db8::/32"),
}
)
func ValidateOutboundURL(rawURL string) (*url.URL, error) {
parsed, err := url.Parse(strings.TrimSpace(rawURL))
@@ -32,6 +48,31 @@ func ValidateOutboundURL(rawURL string) (*url.URL, error) {
return parsed, nil
}
// 用户自定义渠道必须使用更严格的出口策略:只允许 HTTPS,不接受 URL 凭据,
// 仅部署者精确配置的主机可以路由到私网。
func ValidateCustomRelayURL(rawURL string) (*url.URL, error) {
if len(strings.TrimSpace(rawURL)) > 4096 {
return nil, BadAuthRequest("自定义渠道地址过长")
}
parsed, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil || parsed.Hostname() == "" || !parsed.IsAbs() {
return nil, BadAuthRequest("自定义渠道地址无效")
}
if parsed.Scheme != "https" {
return nil, BadAuthRequest("自定义渠道中转只支持 HTTPS")
}
if parsed.User != nil {
return nil, BadAuthRequest("自定义渠道地址不允许包含认证信息")
}
if parsed.Fragment != "" {
return nil, BadAuthRequest("自定义渠道地址不允许包含片段")
}
if err := validateCustomRelayHost(parsed.Hostname()); err != nil {
return nil, err
}
return parsed, nil
}
func OutboundHTTPClient(timeout time.Duration) *http.Client {
return &http.Client{
Transport: outboundTransport,
@@ -46,7 +87,17 @@ func OutboundHTTPClient(timeout time.Duration) *http.Client {
}
}
func newOutboundTransport() *http.Transport {
func CustomRelayHTTPClient(timeout time.Duration) *http.Client {
return &http.Client{
Transport: customRelayTransport,
Timeout: timeout,
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return errors.New("自定义渠道中转不允许重定向")
},
}
}
func newOutboundTransport(resolveHost func(context.Context, string) ([]net.IP, error)) *http.Transport {
dialer := &net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second}
return &http.Transport{
DialContext: func(ctx context.Context, network string, address string) (net.Conn, error) {
@@ -54,7 +105,7 @@ func newOutboundTransport() *http.Transport {
if err != nil {
return nil, err
}
addresses, err := resolveOutboundHost(ctx, host)
addresses, err := resolveHost(ctx, host)
if err != nil {
return nil, err
}
@@ -74,12 +125,37 @@ func validateOutboundHost(host string) error {
return err
}
func validateCustomRelayHost(host string) error {
_, err := resolveCustomRelayHost(context.Background(), host)
return err
}
func resolveOutboundHost(ctx context.Context, host string) ([]net.IP, error) {
host = normalizeOutboundHost(host)
return resolveOutboundHostWithPolicy(ctx, host, allowPrivateUpstreams() || allowedPrivateUpstreamHost(host))
}
func resolveCustomRelayHost(ctx context.Context, host string) ([]net.IP, error) {
host = normalizeOutboundHost(host)
allowPrivateHost := allowedPrivateUpstreamHost(host)
addresses, err := resolveOutboundHostWithPolicy(ctx, host, allowPrivateHost)
if err != nil {
return nil, err
}
if !allowPrivateHost {
for _, ip := range addresses {
if blockedCustomRelayIP(ip) {
return nil, BadAuthRequest("不允许访问保留地址或特殊用途地址")
}
}
}
return addresses, nil
}
func resolveOutboundHostWithPolicy(ctx context.Context, host string, allowPrivateHost bool) ([]net.IP, error) {
if host == "" {
return nil, BadAuthRequest("外部服务域名无效")
}
allowPrivateHost := allowPrivateUpstreams() || allowedPrivateUpstreamHost(host)
if !allowPrivateHost && (host == "localhost" || strings.HasSuffix(host, ".localhost")) {
return nil, BadAuthRequest("不允许访问本机或内网地址")
}
@@ -104,6 +180,23 @@ func blockedOutboundIP(ip net.IP) bool {
return ip == nil || ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalMulticast() || ip.IsLinkLocalUnicast() || ip.IsUnspecified() || ip.IsMulticast()
}
func blockedCustomRelayIP(ip net.IP) bool {
if blockedOutboundIP(ip) {
return true
}
address, ok := netip.AddrFromSlice(ip)
if !ok {
return true
}
address = address.Unmap()
for _, prefix := range blockedCustomRelayPrefixes {
if prefix.Contains(address) {
return true
}
}
return false
}
func allowPrivateUpstreams() bool {
value := strings.ToLower(strings.TrimSpace(os.Getenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS")))
return value == "1" || value == "true" || value == "yes"

View File

@@ -1,6 +1,12 @@
package service
import "testing"
import (
"net"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestValidateOutboundURLRejectsPrivateHosts(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "false")
@@ -40,3 +46,63 @@ func TestAllowedPrivateUpstreamHostUsesExactCaseInsensitiveMatch(t *testing.T) {
t.Fatal("allowedPrivateUpstreamHost() should reject hostname suffix confusion")
}
}
func TestValidateCustomRelayURLUsesHTTPSAndExactPrivateAllowlist(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "")
if _, err := ValidateCustomRelayURL("http://127.0.0.1:8080/v1/models"); err == nil {
t.Fatal("ValidateCustomRelayURL() should reject HTTP")
}
if _, err := ValidateCustomRelayURL("https://127.0.0.1:8080/v1/models"); err == nil {
t.Fatal("ValidateCustomRelayURL() should ignore the global private upstream override")
}
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "127.0.0.1")
if _, err := ValidateCustomRelayURL("https://127.0.0.1:8080/v1/models"); err != nil {
t.Fatalf("ValidateCustomRelayURL() error = %v", err)
}
}
func TestValidateCustomRelayURLRejectsCredentialsAndFragment(t *testing.T) {
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "127.0.0.1")
for _, rawURL := range []string{
"https://user:pass@127.0.0.1/v1/models",
"https://127.0.0.1/v1/models#secret",
} {
if _, err := ValidateCustomRelayURL(rawURL); err == nil {
t.Fatalf("ValidateCustomRelayURL(%q) should fail", rawURL)
}
}
}
func TestBlockedCustomRelayIPRejectsCarrierGradeNATAndReservedRanges(t *testing.T) {
for _, value := range []string{"100.100.100.200", "192.0.2.10", "198.18.0.1", "2001:db8::1"} {
if !blockedCustomRelayIP(net.ParseIP(value)) {
t.Fatalf("blockedCustomRelayIP(%q) = false", value)
}
}
if blockedCustomRelayIP(net.ParseIP("8.8.8.8")) {
t.Fatal("blockedCustomRelayIP() rejected a public address")
}
}
func TestCustomRelayHTTPClientDoesNotFollowRedirects(t *testing.T) {
redirected := false
destination := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
redirected = true
}))
defer destination.Close()
source := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Location", destination.URL)
w.WriteHeader(http.StatusFound)
}))
defer source.Close()
client := CustomRelayHTTPClient(time.Second)
client.Transport = source.Client().Transport
if _, err := client.Get(source.URL); err == nil {
t.Fatal("CustomRelayHTTPClient() should reject redirects")
}
if redirected {
t.Fatal("redirect destination should not receive the request")
}
}

View File

@@ -0,0 +1,28 @@
import { isSystemProxyBaseUrl, resolveBackendApiUrl, type AiConfig } from "@/stores/use-config-store";
type RelayConfig = Pick<AiConfig, "baseUrl" | "apiKey" | "apiFormat">;
export type ChannelRequest = {
url: string;
headers: Record<string, string>;
credentials: RequestCredentials;
};
/** 自定义渠道统一经登录态后端中转,避免依赖第三方服务的浏览器 CORS。 */
export function channelRequest(config: RelayConfig, upstreamUrl: string, headers: HeadersInit = {}): ChannelRequest {
const normalizedHeaders = new Headers(headers);
if (isSystemProxyBaseUrl(config.baseUrl)) {
return { url: upstreamUrl, headers: Object.fromEntries(normalizedHeaders.entries()), credentials: "include" };
}
const normalizedUpstreamUrl = new URL(upstreamUrl).toString();
normalizedHeaders.delete("x-goog-api-key");
normalizedHeaders.set("Authorization", `Bearer ${config.apiKey}`);
normalizedHeaders.set("X-Canvas-Upstream-URL", normalizedUpstreamUrl);
normalizedHeaders.set("X-Canvas-Upstream-Format", config.apiFormat === "gemini" ? "gemini" : "openai");
return {
url: resolveBackendApiUrl("/api/ai/custom"),
headers: Object.fromEntries(normalizedHeaders.entries()),
credentials: "include",
};
}

View File

@@ -4,6 +4,7 @@ import { buildApiUrl, isSystemProxyBaseUrl, resolveBackendApiUrl, resolveModelRe
import { nanoid } from "nanoid";
import { dataUrlToFile } from "@/lib/image-utils";
import { buildImageReferencePromptText } from "@/lib/image-reference-prompt";
import { channelRequest } from "@/services/api/custom-channel-relay";
import { imageToDataUrl } from "@/services/image-storage";
import type { ReferenceImage } from "@/types/image";
@@ -249,10 +250,6 @@ function aiHeaders(config: AiConfig, contentType?: string) {
};
}
function aiFetchCredentials(config: Pick<AiConfig, "baseUrl">): RequestCredentials {
return isSystemProxyBaseUrl(config.baseUrl) ? "include" : "same-origin";
}
function geminiBaseUrl(config: Pick<AiConfig, "baseUrl">) {
const normalizedBaseUrl = resolveBackendApiUrl(config.baseUrl).replace(/\/+$/, "");
const lowerBaseUrl = normalizedBaseUrl.toLowerCase();
@@ -454,12 +451,13 @@ function consumeResponseStreamText(state: ResponseStreamState, text: string, onD
}
async function requestStreamingResponse(config: AiConfig, body: Record<string, unknown>, onDelta?: (text: string) => void, options?: RequestOptions): Promise<ToolResponseResult> {
const response = await fetch(aiApiUrl(config, "/responses"), {
const request = channelRequest(config, aiApiUrl(config, "/responses"), { ...aiHeaders(config, "application/json"), Accept: "text/event-stream" });
const response = await fetch(request.url, {
method: "POST",
headers: { ...aiHeaders(config, "application/json"), Accept: "text/event-stream" },
headers: request.headers,
body: JSON.stringify({ ...body, stream: true }),
signal: options?.signal,
credentials: aiFetchCredentials(config),
credentials: request.credentials,
});
if (!response.ok) throw new Error(await readFetchError(response, "请求失败"));
if (!response.body) {
@@ -534,12 +532,13 @@ function consumeChatCompletionStreamText(state: ChatCompletionStreamState, text:
}
async function requestStreamingChatCompletion(config: AiConfig, body: Record<string, unknown>, onDelta?: (text: string) => void, options?: RequestOptions): Promise<ToolResponseResult> {
const response = await fetch(aiApiUrl(config, "/chat/completions"), {
const request = channelRequest(config, aiApiUrl(config, "/chat/completions"), { ...aiHeaders(config, "application/json"), Accept: "text/event-stream" });
const response = await fetch(request.url, {
method: "POST",
headers: { ...aiHeaders(config, "application/json"), Accept: "text/event-stream" },
headers: request.headers,
body: JSON.stringify({ ...body, stream: true }),
signal: options?.signal,
credentials: aiFetchCredentials(config),
credentials: request.credentials,
});
if (!response.ok) throw new Error(await readFetchError(response, "请求失败"));
const contentType = response.headers.get("content-type") || "";
@@ -640,12 +639,13 @@ function toGeminiToolOptions(tools: ResponseFunctionTool[], toolChoice: ToolChoi
}
async function requestGeminiStreamingResponse(config: AiConfig, body: Record<string, unknown>, onDelta?: (text: string) => void, options?: RequestOptions): Promise<ToolResponseResult> {
const response = await fetch(`${geminiApiUrl(config, "streamGenerateContent")}?alt=sse`, {
const request = channelRequest(config, `${geminiApiUrl(config, "streamGenerateContent")}?alt=sse`, geminiHeaders(config));
const response = await fetch(request.url, {
method: "POST",
headers: geminiHeaders(config),
headers: request.headers,
body: JSON.stringify(body),
signal: options?.signal,
credentials: aiFetchCredentials(config),
credentials: request.credentials,
});
if (!response.ok) throw new Error(await readFetchError(response, "请求失败"));
if (!response.body) {
@@ -887,18 +887,17 @@ export async function requestToolResponse(config: AiConfig, messages: ResponseIn
export async function fetchImageModels(config: Pick<AiConfig, "baseUrl" | "apiKey" | "apiFormat">) {
try {
if (config.apiFormat === "gemini") {
const response = await axios.get<GeminiPayload>(geminiApiUrl({ ...defaultGeminiConfig, ...config }), { headers: geminiHeaders({ ...defaultGeminiConfig, ...config }) });
const requestConfig = { ...defaultGeminiConfig, ...config };
const request = channelRequest(requestConfig, geminiApiUrl(requestConfig), geminiHeaders(requestConfig));
const response = await axios.get<GeminiPayload>(request.url, { headers: request.headers, withCredentials: request.credentials === "include" });
validateGeminiPayload(response.data);
return (response.data.models || [])
.map((model) => model.name?.replace(/^models\//, ""))
.filter((id): id is string => Boolean(id))
.sort((a, b) => a.localeCompare(b));
}
const response = await axios.get<{ data?: Array<{ id?: string }>; error?: { message?: string } }>(buildApiUrl(config.baseUrl, "/models"), {
headers: {
Authorization: `Bearer ${config.apiKey}`,
},
});
const request = channelRequest(config, buildApiUrl(config.baseUrl, "/models"), { Authorization: `Bearer ${config.apiKey}` });
const response = await axios.get<{ data?: Array<{ id?: string }>; error?: { message?: string } }>(request.url, { headers: request.headers, withCredentials: request.credentials === "include" });
return (response.data.data || [])
.map((model) => model.id)
.filter((id): id is string => Boolean(id))