fix(api): 自定义渠道 - 移除浏览器 CORS 依赖
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user