247 lines
8.1 KiB
Go
247 lines
8.1 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"net"
|
||
"net/http"
|
||
"net/url"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
const LocalChannelRequestHeader = "X-Canvas-Allow-Local-Channel"
|
||
const LocalChannelBaseURLHeader = "X-Canvas-Upstream-Base-URL"
|
||
|
||
// RuntimeCapabilities 只来自后端启动配置,HTTP 请求不得修改这些能力。
|
||
type RuntimeCapabilities struct {
|
||
desktopLocalChannels bool
|
||
}
|
||
|
||
func RuntimeCapabilitiesForDeployment(bindAddr string, desktopLocalChannelsSetting string) RuntimeCapabilities {
|
||
if !truthyRuntimeCapability(desktopLocalChannelsSetting) || !isExplicitDesktopLoopbackBind(bindAddr) {
|
||
return RuntimeCapabilities{}
|
||
}
|
||
return RuntimeCapabilities{desktopLocalChannels: true}
|
||
}
|
||
|
||
func truthyRuntimeCapability(value string) bool {
|
||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||
case "1", "true", "yes":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func isExplicitDesktopLoopbackBind(bindAddr string) bool {
|
||
host, port, err := net.SplitHostPort(strings.TrimSpace(bindAddr))
|
||
if err != nil || host != "127.0.0.1" {
|
||
return false
|
||
}
|
||
value, err := strconv.Atoi(port)
|
||
return err == nil && value >= 1 && value <= 65535
|
||
}
|
||
|
||
func (s *Service) DesktopLocalChannelsEnabled() bool {
|
||
return s != nil && s.runtimeCapabilities.desktopLocalChannels
|
||
}
|
||
|
||
func (s *Service) effectiveAllowLocalChannel(requested bool) bool {
|
||
return requested && s.DesktopLocalChannelsEnabled()
|
||
}
|
||
|
||
type outboundLookup func(context.Context, string) ([]net.IP, error)
|
||
|
||
// OutboundPolicy 是独立的桌面 loopback 出口策略。它不读取任何 legacy private-upstream 环境变量。
|
||
type OutboundPolicy struct {
|
||
lookup outboundLookup
|
||
loopbackOnly bool
|
||
}
|
||
|
||
func desktopLoopbackOutboundPolicy(lookup outboundLookup) OutboundPolicy {
|
||
if lookup == nil {
|
||
lookup = func(ctx context.Context, host string) ([]net.IP, error) {
|
||
return net.DefaultResolver.LookupIP(ctx, "ip", host)
|
||
}
|
||
}
|
||
return OutboundPolicy{lookup: lookup, loopbackOnly: true}
|
||
}
|
||
|
||
func validateOutboundURLWithPolicy(rawURL string, policy OutboundPolicy) (*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" && parsed.Scheme != "http" {
|
||
return nil, BadAuthRequest("外部服务地址只支持 http/https")
|
||
}
|
||
if parsed.User != nil {
|
||
return nil, BadAuthRequest("外部服务地址不允许包含认证信息")
|
||
}
|
||
if parsed.Fragment != "" {
|
||
return nil, BadAuthRequest("外部服务地址不允许包含片段")
|
||
}
|
||
if policy.loopbackOnly {
|
||
if err := validateDesktopLoopbackPort(parsed); err != nil {
|
||
return nil, err
|
||
}
|
||
if _, err := resolveDesktopLoopbackHost(context.Background(), parsed.Hostname(), policy.lookup); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
return parsed, nil
|
||
}
|
||
|
||
func validateDesktopLoopbackPort(parsed *url.URL) error {
|
||
if parsed == nil || strings.HasSuffix(parsed.Host, ":") {
|
||
return BadAuthRequest("外部服务地址端口无效")
|
||
}
|
||
port := parsed.Port()
|
||
if port == "" {
|
||
return nil
|
||
}
|
||
for _, char := range port {
|
||
if char < '0' || char > '9' {
|
||
return BadAuthRequest("外部服务地址端口无效")
|
||
}
|
||
}
|
||
value, err := strconv.Atoi(port)
|
||
if err != nil || value < 1 || value > 65535 {
|
||
return BadAuthRequest("外部服务地址端口无效")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func resolveDesktopLoopbackHost(ctx context.Context, host string, lookup outboundLookup) ([]net.IP, error) {
|
||
if host == "127.0.0.1" {
|
||
return []net.IP{net.ParseIP("127.0.0.1")}, nil
|
||
}
|
||
if !strings.EqualFold(host, "localhost") {
|
||
return nil, BadAuthRequest("不允许访问本机、内网或链路本地地址")
|
||
}
|
||
addresses, err := lookup(ctx, host)
|
||
if err != nil {
|
||
return nil, BadAuthRequest("外部服务域名解析失败")
|
||
}
|
||
if len(addresses) == 0 {
|
||
return nil, BadAuthRequest("外部服务域名没有可用地址")
|
||
}
|
||
for _, ip := range addresses {
|
||
if ip == nil || !ip.IsLoopback() {
|
||
return nil, BadAuthRequest("不允许访问本机、内网或链路本地地址")
|
||
}
|
||
}
|
||
return addresses, nil
|
||
}
|
||
|
||
func outboundHTTPClientWithPolicy(timeout time.Duration, policy OutboundPolicy) *http.Client {
|
||
return &http.Client{
|
||
Transport: newOutboundTransportWithPolicy(policy),
|
||
Timeout: timeout,
|
||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||
return errors.New("本机渠道不允许重定向")
|
||
},
|
||
}
|
||
}
|
||
|
||
func newOutboundTransportWithPolicy(policy OutboundPolicy) *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 := resolveDesktopLoopbackHost(ctx, host, policy.lookup)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var lastErr error
|
||
for _, ip := range addresses {
|
||
connection, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
|
||
if dialErr == nil {
|
||
return connection, nil
|
||
}
|
||
lastErr = dialErr
|
||
if ctx.Err() != nil {
|
||
return nil, ctx.Err()
|
||
}
|
||
}
|
||
return nil, lastErr
|
||
},
|
||
ForceAttemptHTTP2: true,
|
||
MaxIdleConns: 20,
|
||
MaxIdleConnsPerHost: 10,
|
||
IdleConnTimeout: 90 * time.Second,
|
||
TLSHandshakeTimeout: 15 * time.Second,
|
||
ExpectContinueTimeout: time.Second,
|
||
}
|
||
}
|
||
|
||
func isExactDesktopLoopbackHost(host string) bool {
|
||
return host == "127.0.0.1" || strings.EqualFold(host, "localhost")
|
||
}
|
||
|
||
func ValidateCustomRelayChannelURL(rawURL string, configuredBaseURL string, requestedAllowLocal bool, desktopLocalChannelsEnabled bool) (*url.URL, error) {
|
||
svc := &Service{runtimeCapabilities: RuntimeCapabilities{desktopLocalChannels: desktopLocalChannelsEnabled}}
|
||
target, err := svc.validateChannelOutboundURL(rawURL, requestedAllowLocal, true)
|
||
if err != nil || !requestedAllowLocal || !desktopLocalChannelsEnabled || target == nil || !isExactDesktopLoopbackHost(target.Hostname()) {
|
||
return target, err
|
||
}
|
||
base, err := svc.validateChannelOutboundURL(configuredBaseURL, true, true)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if !sameOutboundOrigin(base, target) {
|
||
return nil, BadAuthRequest("本机渠道请求必须与配置的 Base URL 同源")
|
||
}
|
||
return target, nil
|
||
}
|
||
|
||
func sameOutboundOrigin(left *url.URL, right *url.URL) bool {
|
||
if left == nil || right == nil {
|
||
return false
|
||
}
|
||
return strings.EqualFold(left.Scheme, right.Scheme) && strings.EqualFold(left.Host, right.Host)
|
||
}
|
||
|
||
func CustomRelayHTTPClientForChannel(timeout time.Duration, target *url.URL, requestedAllowLocal bool, desktopLocalChannelsEnabled bool) *http.Client {
|
||
if requestedAllowLocal && desktopLocalChannelsEnabled && target != nil && isExactDesktopLoopbackHost(target.Hostname()) {
|
||
return outboundHTTPClientWithPolicy(timeout, desktopLoopbackOutboundPolicy(nil))
|
||
}
|
||
return CustomRelayHTTPClient(timeout)
|
||
}
|
||
|
||
func (s *Service) ValidateChannelOutboundURL(rawURL string, requestedAllowLocal bool, customRelay bool) (*url.URL, error) {
|
||
return s.validateChannelOutboundURL(rawURL, requestedAllowLocal, customRelay)
|
||
}
|
||
|
||
func (s *Service) OutboundHTTPClientForChannel(timeout time.Duration, target *url.URL, requestedAllowLocal bool) *http.Client {
|
||
if s.effectiveAllowLocalChannel(requestedAllowLocal) && target != nil && isExactDesktopLoopbackHost(target.Hostname()) {
|
||
return outboundHTTPClientWithPolicy(timeout, desktopLoopbackOutboundPolicy(nil))
|
||
}
|
||
return OutboundHTTPClient(timeout)
|
||
}
|
||
|
||
func (s *Service) validateChannelOutboundURL(rawURL string, requestedAllowLocal bool, customRelay bool) (*url.URL, error) {
|
||
parsed, err := url.Parse(strings.TrimSpace(rawURL))
|
||
if requestedAllowLocal {
|
||
if !s.effectiveAllowLocalChannel(true) {
|
||
return nil, BadAuthRequest("不允许访问本机、内网或链路本地地址")
|
||
}
|
||
return validateOutboundURLWithPolicy(rawURL, desktopLoopbackOutboundPolicy(nil))
|
||
}
|
||
if err == nil && isExactDesktopLoopbackHost(parsed.Hostname()) {
|
||
return nil, BadAuthRequest("不允许访问本机、内网或链路本地地址")
|
||
}
|
||
if customRelay {
|
||
return ValidateCustomRelayURL(rawURL)
|
||
}
|
||
return ValidateOutboundURL(rawURL)
|
||
}
|