223 lines
6.7 KiB
Go
223 lines
6.7 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"net"
|
||
"net/http"
|
||
"net/netip"
|
||
"net/url"
|
||
"os"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
const maxOutboundRedirects = 5
|
||
|
||
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))
|
||
if err != nil || parsed.Hostname() == "" {
|
||
return nil, BadAuthRequest("外部服务地址无效")
|
||
}
|
||
if parsed.Scheme != "https" && parsed.Scheme != "http" {
|
||
return nil, BadAuthRequest("外部服务地址只支持 http/https")
|
||
}
|
||
if parsed.User != nil {
|
||
return nil, BadAuthRequest("外部服务地址不允许包含认证信息")
|
||
}
|
||
if err := validateOutboundHost(parsed.Hostname()); err != nil {
|
||
return nil, err
|
||
}
|
||
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,
|
||
Timeout: timeout,
|
||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||
if len(via) >= maxOutboundRedirects {
|
||
return errors.New("外部服务重定向次数过多")
|
||
}
|
||
_, err := ValidateOutboundURL(req.URL.String())
|
||
return err
|
||
},
|
||
}
|
||
}
|
||
|
||
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) {
|
||
host, port, err := net.SplitHostPort(address)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
addresses, err := resolveHost(ctx, host)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return dialer.DialContext(ctx, network, net.JoinHostPort(addresses[0].String(), port))
|
||
},
|
||
ForceAttemptHTTP2: true,
|
||
MaxIdleConns: 100,
|
||
MaxIdleConnsPerHost: 20,
|
||
IdleConnTimeout: 90 * time.Second,
|
||
TLSHandshakeTimeout: 15 * time.Second,
|
||
ExpectContinueTimeout: time.Second,
|
||
}
|
||
}
|
||
|
||
func validateOutboundHost(host string) error {
|
||
_, err := resolveOutboundHost(context.Background(), host)
|
||
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("外部服务域名无效")
|
||
}
|
||
if !allowPrivateHost && (host == "localhost" || strings.HasSuffix(host, ".localhost")) {
|
||
return nil, BadAuthRequest("不允许访问本机或内网地址")
|
||
}
|
||
addresses, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
|
||
if err != nil {
|
||
return nil, BadAuthRequest("外部服务域名解析失败")
|
||
}
|
||
if len(addresses) == 0 {
|
||
return nil, BadAuthRequest("外部服务域名没有可用地址")
|
||
}
|
||
if !allowPrivateHost {
|
||
for _, ip := range addresses {
|
||
if blockedOutboundIP(ip) {
|
||
return nil, BadAuthRequest("不允许访问本机、内网或链路本地地址")
|
||
}
|
||
}
|
||
}
|
||
return addresses, nil
|
||
}
|
||
|
||
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"
|
||
}
|
||
|
||
// allowedPrivateUpstreamHost lets operators pin only explicitly trusted upstream
|
||
// hostnames to an internal route without disabling SSRF protection for every URL.
|
||
func allowedPrivateUpstreamHost(host string) bool {
|
||
host = normalizeOutboundHost(host)
|
||
if host == "" {
|
||
return false
|
||
}
|
||
for _, configured := range strings.Split(os.Getenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS"), ",") {
|
||
if normalizeOutboundHost(configured) == host {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func normalizeOutboundHost(host string) string {
|
||
return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")
|
||
}
|