mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
568 lines
16 KiB
Go
568 lines
16 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package validate
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
)
|
|
|
|
const (
|
|
defaultDownloadMaxRedirects = 5
|
|
)
|
|
|
|
// DownloadHTTPClientOptions controls redirect and scheme behavior for
|
|
// untrusted-source downloads.
|
|
type DownloadHTTPClientOptions struct {
|
|
// AllowHTTP controls whether plain HTTP URLs are permitted.
|
|
// If false, any HTTP URL (initial or redirect target) is rejected.
|
|
AllowHTTP bool
|
|
// MaxRedirects limits follow-up redirects. Zero or negative uses default.
|
|
MaxRedirects int
|
|
}
|
|
|
|
func isRestrictedDownloadIP(ip net.IP) bool {
|
|
if ip == nil {
|
|
return true
|
|
}
|
|
if ip.IsLoopback() || ip.IsUnspecified() || ip.IsMulticast() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
|
|
return true
|
|
}
|
|
if v4 := ip.To4(); v4 != nil {
|
|
if v4[0] == 0 { // RFC 1122 "this network"
|
|
return true
|
|
}
|
|
if v4[0] == 10 || v4[0] == 127 {
|
|
return true
|
|
}
|
|
if v4[0] == 169 && v4[1] == 254 {
|
|
return true
|
|
}
|
|
if v4[0] == 172 && v4[1] >= 16 && v4[1] <= 31 {
|
|
return true
|
|
}
|
|
if v4[0] == 192 && v4[1] == 168 {
|
|
return true
|
|
}
|
|
if v4[0] == 100 && v4[1] >= 64 && v4[1] <= 127 { // RFC6598 CGNAT
|
|
return true
|
|
}
|
|
if v4[0] == 198 && (v4[1] == 18 || v4[1] == 19) { // RFC2544 benchmarking
|
|
return true
|
|
}
|
|
if v4[0] >= 240 {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
if ip.IsPrivate() {
|
|
return true
|
|
}
|
|
ip16 := ip.To16()
|
|
if ip16 == nil {
|
|
return true
|
|
}
|
|
if ip16[0]&0xfe == 0xfc { // fc00::/7 unique local address
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ValidateDownloadSourceURL validates a download URL and blocks local/internal targets.
|
|
func ValidateDownloadSourceURL(ctx context.Context, rawURL string) error {
|
|
u, err := url.Parse(rawURL)
|
|
if err != nil || u == nil {
|
|
return fmt.Errorf("invalid URL")
|
|
}
|
|
if u.Scheme != "http" && u.Scheme != "https" {
|
|
return fmt.Errorf("only http/https URLs are supported")
|
|
}
|
|
_, err = resolveDownloadHost(ctx, u.Hostname(), net.DefaultResolver.LookupIP)
|
|
return err
|
|
}
|
|
|
|
type downloadLookupIPFunc func(context.Context, string, string) ([]net.IP, error)
|
|
|
|
func resolveDownloadHost(ctx context.Context, rawHost string, lookupIP downloadLookupIPFunc) ([]net.IP, error) {
|
|
host := strings.TrimSpace(strings.ToLower(rawHost))
|
|
if host == "" {
|
|
return nil, fmt.Errorf("URL host is required")
|
|
}
|
|
if host == "localhost" || strings.HasSuffix(host, ".localhost") {
|
|
return nil, fmt.Errorf("local/internal host is not allowed")
|
|
}
|
|
if ip := net.ParseIP(host); ip != nil {
|
|
if isRestrictedDownloadIP(ip) {
|
|
return nil, fmt.Errorf("local/internal host is not allowed")
|
|
}
|
|
return []net.IP{ip}, nil
|
|
}
|
|
if lookupIP == nil {
|
|
lookupIP = net.DefaultResolver.LookupIP
|
|
}
|
|
ips, err := lookupIP(ctx, "ip", host)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to resolve host")
|
|
}
|
|
if len(ips) == 0 {
|
|
return nil, fmt.Errorf("failed to resolve host")
|
|
}
|
|
for _, ip := range ips {
|
|
if isRestrictedDownloadIP(ip) {
|
|
return nil, fmt.Errorf("local/internal host is not allowed")
|
|
}
|
|
}
|
|
return ips, nil
|
|
}
|
|
|
|
// NewDownloadHTTPClient clones base client and enforces download-safe redirect
|
|
// and connection rules for untrusted URLs.
|
|
func NewDownloadHTTPClient(base *http.Client, opts DownloadHTTPClientOptions) *http.Client {
|
|
if base == nil {
|
|
base = &http.Client{}
|
|
}
|
|
if opts.MaxRedirects <= 0 {
|
|
opts.MaxRedirects = defaultDownloadMaxRedirects
|
|
}
|
|
|
|
cloned := *base
|
|
cloned.Transport = &downloadSchemeTransport{
|
|
base: cloneDownloadTransport(base.Transport),
|
|
allowHTTP: opts.AllowHTTP,
|
|
}
|
|
cloned.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
|
if len(via) >= opts.MaxRedirects {
|
|
return fmt.Errorf("too many redirects")
|
|
}
|
|
if len(via) > 0 {
|
|
prev := via[len(via)-1]
|
|
if strings.EqualFold(prev.URL.Scheme, "https") && strings.EqualFold(req.URL.Scheme, "http") {
|
|
return fmt.Errorf("redirect from https to http is not allowed")
|
|
}
|
|
}
|
|
if !opts.AllowHTTP && !strings.EqualFold(req.URL.Scheme, "https") {
|
|
return fmt.Errorf("only https URLs are supported")
|
|
}
|
|
if err := ValidateDownloadSourceURL(req.Context(), req.URL.String()); err != nil {
|
|
return fmt.Errorf("blocked redirect target: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
return &cloned
|
|
}
|
|
|
|
type downloadSchemeTransport struct {
|
|
base http.RoundTripper
|
|
allowHTTP bool
|
|
}
|
|
|
|
func (t *downloadSchemeTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
if req == nil || req.URL == nil {
|
|
return nil, errs.NewInternalError(
|
|
errs.SubtypeUnknown,
|
|
"download transport received a nil request",
|
|
)
|
|
}
|
|
switch {
|
|
case strings.EqualFold(req.URL.Scheme, "https"):
|
|
case t.allowHTTP && strings.EqualFold(req.URL.Scheme, "http"):
|
|
default:
|
|
return nil, errs.NewSecurityPolicyError(
|
|
errs.SubtypeAccessDenied,
|
|
"only https URLs are supported",
|
|
)
|
|
}
|
|
return t.base.RoundTrip(req)
|
|
}
|
|
|
|
type selectedDownloadProxyKey struct{}
|
|
|
|
type proxyAwareDownloadTransport struct {
|
|
selectProxy func(*http.Request) (*url.URL, error)
|
|
direct http.RoundTripper
|
|
proxied *http.Transport
|
|
lookupIP downloadLookupIPFunc
|
|
|
|
mu sync.Mutex
|
|
proxiedByTLSServer map[string]*http.Transport
|
|
}
|
|
|
|
func (t *proxyAwareDownloadTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
if req == nil || req.URL == nil {
|
|
return nil, fmt.Errorf("download transport received a nil request")
|
|
}
|
|
proxyURL, err := t.selectProxy(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if proxyURL == nil {
|
|
return t.direct.RoundTrip(req)
|
|
}
|
|
|
|
targetIPs, err := resolveDownloadHost(req.Context(), req.URL.Hostname(), t.lookupIP)
|
|
if err != nil {
|
|
return nil, errs.NewSecurityPolicyError(
|
|
errs.SubtypeAccessDenied,
|
|
"blocked download target: %v",
|
|
err,
|
|
).WithCause(err)
|
|
}
|
|
if strings.EqualFold(req.URL.Scheme, "http") && net.ParseIP(req.URL.Hostname()) == nil {
|
|
// HTTP proxies cannot pin the target IP separately from the Host header.
|
|
return nil, errs.NewSecurityPolicyError(
|
|
errs.SubtypeAccessDenied,
|
|
"plain HTTP hostname downloads through a proxy are not allowed",
|
|
).WithHint("use HTTPS or a literal public IP")
|
|
}
|
|
|
|
selected := *proxyURL
|
|
proxied := t.proxied
|
|
if strings.EqualFold(req.URL.Scheme, "https") {
|
|
proxied = t.proxiedTransportForTLSServer(req.URL.Hostname())
|
|
}
|
|
|
|
var lastErr error
|
|
for index, targetIP := range targetIPs {
|
|
proxiedReq, pinErr := pinDownloadRequestTargetToIP(req, targetIP)
|
|
if pinErr != nil {
|
|
return nil, pinErr
|
|
}
|
|
ctx := context.WithValue(proxiedReq.Context(), selectedDownloadProxyKey{}, &selected)
|
|
proxiedReq = proxiedReq.WithContext(ctx)
|
|
|
|
resp, roundTripErr := proxied.RoundTrip(proxiedReq)
|
|
if roundTripErr == nil {
|
|
if resp != nil {
|
|
// Hide the internal pinned URL from redirect handling.
|
|
resp.Request = req
|
|
}
|
|
return resp, nil
|
|
}
|
|
lastErr = roundTripErr
|
|
if resp != nil && resp.Body != nil {
|
|
resp.Body.Close()
|
|
}
|
|
if req.Context().Err() != nil {
|
|
break
|
|
}
|
|
if index+1 < len(targetIPs) && !canRetryDownloadTarget(req) {
|
|
break
|
|
}
|
|
}
|
|
return nil, lastErr
|
|
}
|
|
|
|
func (t *proxyAwareDownloadTransport) CloseIdleConnections() {
|
|
if closer, ok := t.direct.(interface{ CloseIdleConnections() }); ok {
|
|
closer.CloseIdleConnections()
|
|
}
|
|
t.proxied.CloseIdleConnections()
|
|
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
for _, transport := range t.proxiedByTLSServer {
|
|
transport.CloseIdleConnections()
|
|
}
|
|
}
|
|
|
|
func (t *proxyAwareDownloadTransport) proxiedTransportForTLSServer(serverName string) *http.Transport {
|
|
if configured := t.proxied.TLSClientConfig; configured != nil && configured.ServerName != "" {
|
|
serverName = configured.ServerName
|
|
}
|
|
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
if transport := t.proxiedByTLSServer[serverName]; transport != nil {
|
|
return transport
|
|
}
|
|
|
|
transport := t.proxied.Clone()
|
|
targetTLSConfig := cloneDownloadTLSConfig(transport.TLSClientConfig)
|
|
targetTLSConfig.ServerName = serverName
|
|
transport.TLSClientConfig = targetTLSConfig
|
|
configureHTTPSProxyTLSDialer(transport, t.proxied)
|
|
if t.proxiedByTLSServer == nil {
|
|
t.proxiedByTLSServer = make(map[string]*http.Transport)
|
|
}
|
|
t.proxiedByTLSServer[serverName] = transport
|
|
return transport
|
|
}
|
|
|
|
type blockedDownloadTransport struct {
|
|
err error
|
|
}
|
|
|
|
func (t *blockedDownloadTransport) RoundTrip(*http.Request) (*http.Response, error) {
|
|
return nil, t.err
|
|
}
|
|
|
|
func cloneDownloadTransport(base http.RoundTripper) http.RoundTripper {
|
|
if base == nil {
|
|
base = http.DefaultTransport
|
|
}
|
|
if source, ok := base.(interface {
|
|
TransformHTTPTransport(func(*http.Transport) (http.RoundTripper, bool)) (http.RoundTripper, bool)
|
|
}); ok {
|
|
rebuilt, transformed := source.TransformHTTPTransport(newDownloadTransportLeaf)
|
|
if transformed && rebuilt != nil {
|
|
return rebuilt
|
|
}
|
|
}
|
|
if source, ok := base.(*http.Transport); ok && source != nil {
|
|
rebuilt, transformed := newDownloadTransportLeaf(source)
|
|
if transformed && rebuilt != nil {
|
|
return rebuilt
|
|
}
|
|
}
|
|
return &blockedDownloadTransport{err: errs.NewInternalError(
|
|
errs.SubtypeUnknown,
|
|
"cannot safely clone download transport %T",
|
|
base,
|
|
)}
|
|
}
|
|
|
|
func newDownloadTransportLeaf(source *http.Transport) (http.RoundTripper, bool) {
|
|
return newDownloadTransportLeafWithResolver(source, net.DefaultResolver.LookupIP)
|
|
}
|
|
|
|
func newDownloadTransportLeafWithResolver(source *http.Transport, lookupIP downloadLookupIPFunc) (http.RoundTripper, bool) {
|
|
if source == nil {
|
|
return nil, false
|
|
}
|
|
|
|
selectProxy := source.Proxy
|
|
direct := cloneDownloadHTTPTransport(source)
|
|
direct.Proxy = nil
|
|
configureDirectDownloadTransport(direct)
|
|
if selectProxy == nil {
|
|
return direct, true
|
|
}
|
|
|
|
// The proxied branch validates the requested URL before construction and
|
|
// on every redirect. Its TCP peer is the selected proxy, so applying the
|
|
// direct-origin IP guard there would incorrectly reject trusted loopback or
|
|
// private-network proxies. Freeze the selected proxy in request context so
|
|
// a stateful selector cannot switch the second lookup to direct egress.
|
|
proxied := cloneDownloadHTTPTransport(source)
|
|
proxied.Proxy = func(req *http.Request) (*url.URL, error) {
|
|
selected, ok := req.Context().Value(selectedDownloadProxyKey{}).(*url.URL)
|
|
if !ok || selected == nil {
|
|
return nil, fmt.Errorf("download proxy selection is missing")
|
|
}
|
|
cloned := *selected
|
|
return &cloned, nil
|
|
}
|
|
return &proxyAwareDownloadTransport{
|
|
selectProxy: selectProxy,
|
|
direct: direct,
|
|
proxied: proxied,
|
|
lookupIP: lookupIP,
|
|
proxiedByTLSServer: make(map[string]*http.Transport),
|
|
}, true
|
|
}
|
|
|
|
func cloneDownloadHTTPTransport(source *http.Transport) *http.Transport {
|
|
cloned := source.Clone()
|
|
if cloned.TLSNextProto == nil {
|
|
if _, ok := source.TLSNextProto["h2"]; ok {
|
|
cloned.ForceAttemptHTTP2 = true
|
|
}
|
|
}
|
|
return cloned
|
|
}
|
|
|
|
func pinDownloadRequestTargetToIP(req *http.Request, targetIP net.IP) (*http.Request, error) {
|
|
if req == nil || req.URL == nil {
|
|
return nil, fmt.Errorf("download request URL is missing")
|
|
}
|
|
if targetIP == nil || isRestrictedDownloadIP(targetIP) {
|
|
return nil, fmt.Errorf("blocked download target: local/internal host is not allowed")
|
|
}
|
|
|
|
originalHost := req.URL.Host
|
|
pinnedHost := targetIP.String()
|
|
if port := req.URL.Port(); port != "" {
|
|
pinnedHost = net.JoinHostPort(pinnedHost, port)
|
|
} else if strings.Contains(pinnedHost, ":") {
|
|
pinnedHost = "[" + pinnedHost + "]"
|
|
}
|
|
|
|
pinned := req.Clone(req.Context())
|
|
pinnedURL := *req.URL
|
|
pinnedURL.Host = pinnedHost
|
|
pinned.URL = &pinnedURL
|
|
pinned.Host = originalHost
|
|
return pinned, nil
|
|
}
|
|
|
|
func canRetryDownloadTarget(req *http.Request) bool {
|
|
if req == nil || req.Body != nil {
|
|
return false
|
|
}
|
|
return req.Method == http.MethodGet || req.Method == http.MethodHead
|
|
}
|
|
|
|
func cloneDownloadTLSConfig(config *tls.Config) *tls.Config {
|
|
if config == nil {
|
|
return &tls.Config{MinVersion: tls.VersionTLS12}
|
|
}
|
|
return config.Clone()
|
|
}
|
|
|
|
func configureHTTPSProxyTLSDialer(transport, source *http.Transport) {
|
|
if transport.DialTLSContext != nil || transport.DialTLS != nil {
|
|
return
|
|
}
|
|
|
|
proxyTLSConfig := cloneDownloadTLSConfig(source.TLSClientConfig)
|
|
transport.DialTLSContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
rawConn, err := dialDownloadProxy(ctx, source, network, addr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
config := proxyTLSConfig.Clone()
|
|
serverName, _, splitErr := net.SplitHostPort(addr)
|
|
if splitErr != nil {
|
|
rawConn.Close()
|
|
return nil, fmt.Errorf("invalid HTTPS proxy address: %w", splitErr)
|
|
}
|
|
config.ServerName = serverName
|
|
tlsConn := tls.Client(rawConn, config)
|
|
handshakeCtx := ctx
|
|
cancel := func() {}
|
|
if source.TLSHandshakeTimeout > 0 {
|
|
handshakeCtx, cancel = context.WithTimeout(ctx, source.TLSHandshakeTimeout)
|
|
}
|
|
defer cancel()
|
|
if err := tlsConn.HandshakeContext(handshakeCtx); err != nil {
|
|
rawConn.Close()
|
|
return nil, err
|
|
}
|
|
return tlsConn, nil
|
|
}
|
|
}
|
|
|
|
func dialDownloadProxy(ctx context.Context, source *http.Transport, network, addr string) (net.Conn, error) {
|
|
if source.DialContext != nil {
|
|
return source.DialContext(ctx, network, addr)
|
|
}
|
|
if source.Dial != nil {
|
|
return source.Dial(network, addr)
|
|
}
|
|
var dialer net.Dialer
|
|
return dialer.DialContext(ctx, network, addr)
|
|
}
|
|
|
|
func configureDirectDownloadTransport(cloned *http.Transport) {
|
|
origDial := cloned.DialContext
|
|
cloned.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
conn, err := dialConn(ctx, origDial, network, addr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validateConnRemoteIP(conn); err != nil {
|
|
conn.Close()
|
|
return nil, downloadTargetPolicyError(err)
|
|
}
|
|
return conn, nil
|
|
}
|
|
|
|
if cloned.DialTLSContext != nil {
|
|
origDialTLS := cloned.DialTLSContext
|
|
cloned.DialTLSContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
conn, err := dialConn(ctx, origDialTLS, network, addr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validateConnRemoteIP(conn); err != nil {
|
|
conn.Close()
|
|
return nil, downloadTargetPolicyError(err)
|
|
}
|
|
return conn, nil
|
|
}
|
|
}
|
|
if cloned.DialTLS != nil {
|
|
origDialTLS := cloned.DialTLS
|
|
cloned.DialTLS = func(network, addr string) (net.Conn, error) {
|
|
conn, err := origDialTLS(network, addr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validateConnRemoteIP(conn); err != nil {
|
|
conn.Close()
|
|
return nil, downloadTargetPolicyError(err)
|
|
}
|
|
return conn, nil
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
// DialContextFunc is the signature for DialContext / DialTLSContext.
|
|
type DialContextFunc func(ctx context.Context, network, addr string) (net.Conn, error)
|
|
|
|
// WrapDialContextWithIPCheck wraps a DialContext function to validate the
|
|
// remote IP after connection, rejecting local/internal addresses (SSRF protection).
|
|
func WrapDialContextWithIPCheck(origDial DialContextFunc) DialContextFunc {
|
|
return func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
conn, err := dialConn(ctx, origDial, network, addr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validateConnRemoteIP(conn); err != nil {
|
|
conn.Close()
|
|
return nil, downloadTargetPolicyError(err)
|
|
}
|
|
return conn, nil
|
|
}
|
|
}
|
|
|
|
func dialConn(ctx context.Context, dialFn func(context.Context, string, string) (net.Conn, error), network, addr string) (net.Conn, error) {
|
|
if dialFn != nil {
|
|
return dialFn(ctx, network, addr)
|
|
}
|
|
var d net.Dialer
|
|
return d.DialContext(ctx, network, addr)
|
|
}
|
|
|
|
func downloadTargetPolicyError(err error) error {
|
|
return errs.NewSecurityPolicyError(
|
|
errs.SubtypeAccessDenied,
|
|
"blocked download target: %v",
|
|
err,
|
|
).WithCause(err)
|
|
}
|
|
|
|
func validateConnRemoteIP(conn net.Conn) error {
|
|
if conn == nil {
|
|
return fmt.Errorf("nil connection")
|
|
}
|
|
raddr := conn.RemoteAddr()
|
|
if raddr == nil {
|
|
return fmt.Errorf("missing remote address")
|
|
}
|
|
host, _, err := net.SplitHostPort(raddr.String())
|
|
if err != nil {
|
|
host = raddr.String()
|
|
}
|
|
ip := net.ParseIP(strings.Trim(host, "[]"))
|
|
if ip == nil {
|
|
return fmt.Errorf("invalid remote IP")
|
|
}
|
|
if isRestrictedDownloadIP(ip) {
|
|
return fmt.Errorf("local/internal host is not allowed")
|
|
}
|
|
return nil
|
|
}
|