316 lines
10 KiB
Go
316 lines
10 KiB
Go
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
|
||
}
|