package credential import ( "context" "errors" "fmt" "net" "net/http" "net/netip" "net/url" "os" "strconv" "strings" "time" "unicode" ) const ( defaultTimeoutMS = 3000 minTimeoutMS = 1000 maxTimeoutMS = 30000 ) func ValidateSecret(value string, maxLength int) (string, error) { value = strings.TrimSpace(value) if value == "" || len(value) > maxLength || strings.IndexFunc(value, unicode.IsControl) >= 0 { return "", errors.New("invalid credential") } return value, nil } func NormalizeHTTPSURL(raw string) (string, error) { raw = strings.TrimSpace(raw) if raw == "" || len(raw) > 2048 || strings.Contains(raw, "#") { return "", errors.New("invalid endpoint") } endpoint, err := url.ParseRequestURI(raw) if err != nil || endpoint.Scheme != "https" || endpoint.Hostname() == "" || endpoint.User != nil || endpoint.Fragment != "" { return "", errors.New("invalid endpoint") } if port := endpoint.Port(); port != "" { value, err := strconv.Atoi(port) if err != nil || value < 1 || value > 65535 { return "", errors.New("invalid endpoint") } } host := strings.TrimSuffix(strings.ToLower(endpoint.Hostname()), ".") if host == "localhost" || strings.HasSuffix(host, ".localhost") { return "", errors.New("invalid endpoint") } if address, err := netip.ParseAddr(host); err == nil && !IsPublicRemoteIP(address) { return "", errors.New("invalid endpoint") } return endpoint.String(), nil } func IsPublicRemoteIP(address netip.Addr) bool { address = address.Unmap() if !address.IsValid() || !address.IsGlobalUnicast() || address.IsPrivate() || address.IsLoopback() || address.IsLinkLocalUnicast() || address.IsLinkLocalMulticast() || address.IsMulticast() || address.IsUnspecified() { return false } if address.Is6() && !globalIPv6UnicastPrefix.Contains(address) { return false } for _, prefix := range restrictedRemotePrefixes { if prefix.Contains(address) { return false } } return true } var globalIPv6UnicastPrefix = netip.MustParsePrefix("2000::/3") var restrictedRemotePrefixes = []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("192.88.99.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("2001::/23"), netip.MustParsePrefix("2001:db8::/32"), netip.MustParsePrefix("2002::/16"), netip.MustParsePrefix("3fff::/20"), } func NewRestrictedHTTPClient(timeoutMS int) *http.Client { return NewRestrictedHTTPClientForTargets(timeoutMS, nil) } // NetworkTarget 描述插件允许访问的网络目标。 type NetworkTarget struct { Scheme string Host string Port int } // NewRestrictedHTTPClientForTargets 创建仅允许访问授权目标的 HTTP 客户端。 func NewRestrictedHTTPClientForTargets(timeoutMS int, targets []NetworkTarget) *http.Client { return newRestrictedHTTPClientForTargets(timeoutMS, targets, os.Getenv("SMAIL_PLUGIN_SANDBOX") == "1") } // NormalizeTelegramProxyURL 仅接受超管指定的本机 HTTP 代理,不解析代理域名。 func NormalizeTelegramProxyURL(raw string) (string, error) { raw = strings.TrimSpace(raw) if raw == "" { return "", nil } endpoint, err := url.Parse(raw) if err != nil || endpoint.Scheme != "http" || endpoint.User != nil || endpoint.Path != "" || endpoint.RawQuery != "" || endpoint.ForceQuery || strings.ContainsRune(raw, '#') || len(raw) > 256 { return "", errors.New("Telegram 代理仅支持带端口的本机 HTTP 地址,如 http://127.0.0.1:9000") } address, addressErr := netip.ParseAddr(endpoint.Hostname()) port, portErr := strconv.Atoi(endpoint.Port()) if addressErr != nil || !address.IsLoopback() || address.Zone() != "" || portErr != nil || port < 1 || port > 65535 { return "", errors.New("Telegram 代理仅支持带端口的本机 HTTP 地址,如 http://127.0.0.1:9000") } return "http://" + net.JoinHostPort(address.String(), strconv.Itoa(port)), nil } // NewTelegramHTTPClient 将代理权限限定为 Telegram;沙箱始终由主进程代为出站。 func NewTelegramHTTPClient(timeoutMS int, proxyURL string) (*http.Client, error) { return newTelegramHTTPClient(timeoutMS, proxyURL, os.Getenv("SMAIL_PLUGIN_SANDBOX") == "1") } func newTelegramHTTPClient(timeoutMS int, proxyURL string, sandbox bool) (*http.Client, error) { proxyURL, err := NormalizeTelegramProxyURL(proxyURL) if err != nil { return nil, err } targets := []NetworkTarget{{Scheme: "https", Host: "api.telegram.org", Port: 443}} client := newRestrictedHTTPClientForTargets(timeoutMS, targets, sandbox) if proxyURL == "" || sandbox { return client, nil } endpoint, _ := url.Parse(proxyURL) transport := client.Transport.(restrictedRoundTripper).transport.(*http.Transport) transport.Proxy = http.ProxyURL(endpoint) transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) { if address != endpoint.Host { return nil, errors.New("Telegram 代理连接目标不匹配") } return (&net.Dialer{Timeout: 5 * time.Second}).DialContext(ctx, network, endpoint.Host) } // HTTPS CONNECT 后仍验证 Telegram 的系统可信证书,不接受其他目标或明文请求。 return client, nil } func newRestrictedHTTPClientForTargets(timeoutMS int, targets []NetworkTarget, sandbox bool) *http.Client { timeout := time.Duration(NormalizeTimeoutMS(timeoutMS)) * time.Millisecond allowedTargets := normalizeNetworkTargets(targets) transport := &http.Transport{ Proxy: nil, DialContext: func(ctx context.Context, network, address string) (net.Conn, error) { return restrictedDialContext(ctx, network, address, allowedTargets) }, ForceAttemptHTTP2: true, TLSHandshakeTimeout: timeout, ResponseHeaderTimeout: timeout, IdleConnTimeout: 30 * time.Second, } var outbound http.RoundTripper = transport if sandbox { outbound = newPluginEgressTransport(timeout) } return &http.Client{ Timeout: timeout, Transport: restrictedRoundTripper{transport: outbound, targets: allowedTargets}, CheckRedirect: func(request *http.Request, via []*http.Request) error { if len(via) >= 10 { return errors.New("too many redirects") } return validateRestrictedRequest(request, allowedTargets, "redirect target is not authorized") }, } } type restrictedRoundTripper struct { transport http.RoundTripper targets []NetworkTarget } func (r restrictedRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) { if err := validateRestrictedRequest(request, r.targets, "remote target is not authorized"); err != nil { closeRequestBody(request) return nil, err } return r.transport.RoundTrip(request) } func (r restrictedRoundTripper) CloseIdleConnections() { if transport, ok := r.transport.(interface{ CloseIdleConnections() }); ok { transport.CloseIdleConnections() } } func closeRequestBody(request *http.Request) { if request != nil && request.Body != nil { _ = request.Body.Close() } } func validateRestrictedRequest(request *http.Request, targets []NetworkTarget, targetError string) error { if request == nil || request.URL == nil { return errors.New("invalid endpoint") } if _, err := NormalizeHTTPSURL(request.URL.String()); err != nil { return err } if !matchesNetworkTarget(request.URL, targets) { return errors.New(targetError) } return nil } func normalizeNetworkTargets(targets []NetworkTarget) []NetworkTarget { result := make([]NetworkTarget, 0, len(targets)) for _, target := range targets { scheme := strings.ToLower(strings.TrimSpace(target.Scheme)) host := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(target.Host)), ".") port := target.Port if port == 0 { if scheme == "https" { port = 443 } else { port = 80 } } if (scheme != "https" && scheme != "http") || host == "" || port < 1 || port > 65535 { continue } result = append(result, NetworkTarget{Scheme: scheme, Host: host, Port: port}) } return result } func matchesNetworkTarget(endpoint *url.URL, targets []NetworkTarget) bool { if len(targets) == 0 { return true } port := 443 if strings.EqualFold(endpoint.Scheme, "http") { port = 80 } if endpoint.Port() != "" { port, _ = strconv.Atoi(endpoint.Port()) } host := strings.TrimSuffix(strings.ToLower(endpoint.Hostname()), ".") scheme := strings.ToLower(endpoint.Scheme) for _, target := range targets { if target.Scheme == scheme && target.Host == host && target.Port == port { return true } } return false } func restrictedDialContext(ctx context.Context, network, address string, targets []NetworkTarget) (net.Conn, error) { host, port, err := net.SplitHostPort(address) if err != nil { return nil, errors.New("invalid remote address") } if _, err := strconv.Atoi(port); err != nil || (len(targets) > 0 && !matchesNetworkTarget(&url.URL{Scheme: "https", Host: net.JoinHostPort(host, port)}, targets) && !matchesNetworkTarget(&url.URL{Scheme: "http", Host: net.JoinHostPort(host, port)}, targets)) { return nil, errors.New("remote target is not authorized") } resolved, err := net.DefaultResolver.LookupIPAddr(ctx, host) if err != nil || len(resolved) == 0 { return nil, errors.New("remote address resolution failed") } approved := make([]netip.Addr, 0, len(resolved)) for _, item := range resolved { candidate, ok := netip.AddrFromSlice(item.IP) if !ok || !IsPublicRemoteIP(candidate) { return nil, errors.New("remote address is not public") } candidate = candidate.Unmap() if network == "tcp4" && !candidate.Is4() || network == "tcp6" && !candidate.Is6() { continue } approved = append(approved, candidate) } if len(approved) == 0 { return nil, errors.New("remote address is unavailable") } dialer := &net.Dialer{Timeout: 5 * time.Second, KeepAlive: 30 * time.Second} var lastErr error for _, candidate := range approved { connection, err := dialer.DialContext(ctx, network, net.JoinHostPort(candidate.String(), port)) if err == nil { return connection, nil } lastErr = err } return nil, fmt.Errorf("connect to remote endpoint: %w", lastErr) } func NormalizeTimeoutMS(value int) int { if value <= 0 { return defaultTimeoutMS } if value < minTimeoutMS { return minTimeoutMS } if value > maxTimeoutMS { return maxTimeoutMS } return value }