Smail/backend/utils/credential/credential.go
smail 857491c6a6
Some checks are pending
Security Checks / security (push) Waiting to run
修复Telegram回调与沙箱推送未使用本机代理
2026-10-03 20:42:13 +08:00

316 lines
10 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}