Files
1Panel/agent/utils/webhook_sender/request.go

768 lines
21 KiB
Go

package webhook_sender
import (
"bufio"
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/json"
"errors"
"io"
"net"
"net/http"
"net/netip"
"net/url"
"sort"
"strings"
"time"
"unicode/utf8"
)
const (
RequestTimeout = 10 * time.Second
MaxResponseBodyBytes = 64 * 1024
MaxCapturedResponseBytes = 2 * 1024
)
var loadWebhookSystemCertPool = x509.SystemCertPool
type Preset string
const (
PresetGeneric Preset = "generic"
PresetSlack Preset = "slack"
PresetDiscord Preset = "discord"
PresetTeams Preset = "teams"
)
type BodyFormat string
const (
BodyJSON BodyFormat = "json"
BodyForm BodyFormat = "form"
BodyText BodyFormat = "text"
)
type Request struct {
URL string
Preset Preset
Format BodyFormat
Body []byte
Headers map[string]string
Transport *http.Transport
Resolver IPResolver
CaptureResponse bool
}
type IPResolver interface {
LookupIPAddr(ctx context.Context, host string) ([]net.IPAddr, error)
}
type Result struct {
StatusCode int
ResponseSize int
Duration time.Duration
Response string
}
func Execute(ctx context.Context, input Request) (Result, error) {
if ctx == nil {
ctx = context.Background()
}
contentType, err := validateRenderedBody(input.Format, input.Body)
if err != nil {
return Result{}, err
}
if err := validateHeaders(input.Headers); err != nil {
return Result{}, err
}
requestContext, cancel := context.WithTimeout(ctx, RequestTimeout)
defer cancel()
target, err := prepareTarget(requestContext, input.URL, input.Preset, input.Resolver)
if err != nil {
return Result{}, errors.New("invalid webhook request URL")
}
req, err := http.NewRequestWithContext(requestContext, http.MethodPost, target.URL, bytes.NewReader(input.Body))
if err != nil {
return Result{}, errors.New("create webhook request failed")
}
req.Host = target.HostHeader
for name, value := range input.Headers {
req.Header.Set(name, value)
}
req.Header.Set("Content-Type", contentType)
transport := systemTLSTransport(input.Transport, target.ServerName, target.OriginalURL, target.Addresses)
var roundTripper http.RoundTripper = transport
if target.OriginalURL != nil && target.OriginalURL.Scheme == "http" {
roundTripper = &pinnedHTTPProxyRoundTripper{transport: transport, originalHost: target.HostHeader}
}
client := &http.Client{
Timeout: RequestTimeout,
Transport: roundTripper,
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
},
}
startedAt := time.Now()
resp, err := client.Do(req)
duration := time.Since(startedAt)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return Result{Duration: duration}, errors.New("webhook request timed out")
}
if errors.Is(err, context.Canceled) {
return Result{Duration: duration}, errors.New("webhook request canceled")
}
return Result{Duration: duration}, errors.New("webhook request failed")
}
defer resp.Body.Close()
responseBody, err := io.ReadAll(io.LimitReader(resp.Body, MaxResponseBodyBytes+1))
duration = time.Since(startedAt)
result := Result{
StatusCode: resp.StatusCode,
ResponseSize: len(responseBody),
Duration: duration,
}
if input.CaptureResponse {
result.Response = captureResponse(responseBody, input, target)
}
if err != nil {
return result, errors.New("read webhook response failed")
}
if len(responseBody) > MaxResponseBodyBytes {
return result, errors.New("webhook response exceeded size limit")
}
if err := validateResponse(input.Preset, resp.StatusCode, responseBody); err != nil {
return result, err
}
return result, nil
}
type pinnedHTTPProxyRoundTripper struct {
transport *http.Transport
originalHost string
}
func (p *pinnedHTTPProxyRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) {
if p.transport.Proxy == nil {
return p.transport.RoundTrip(request)
}
proxyURL, err := p.transport.Proxy(request)
if err != nil {
return nil, errors.New("select webhook proxy failed")
}
if proxyURL == nil {
direct := p.transport.Clone()
direct.Proxy = nil
return direct.RoundTrip(request)
}
return p.roundTripProxy(request, proxyURL)
}
func (p *pinnedHTTPProxyRoundTripper) roundTripProxy(request *http.Request, proxyURL *url.URL) (*http.Response, error) {
proxyAddress, err := proxyDialAddress(proxyURL)
if err != nil {
return nil, err
}
dialContext := p.transport.DialContext
if dialContext == nil {
dialContext = (&net.Dialer{}).DialContext
}
connection, err := dialContext(request.Context(), "tcp", proxyAddress)
if err != nil {
return nil, errors.New("connect webhook proxy failed")
}
closeConnection := true
defer func() {
if closeConnection {
_ = connection.Close()
}
}()
if deadline, ok := request.Context().Deadline(); ok {
_ = connection.SetDeadline(deadline)
}
if proxyURL.Scheme == "https" {
proxyTLSConfig := &tls.Config{ServerName: proxyURL.Hostname()}
if roots, rootsErr := loadWebhookSystemCertPool(); rootsErr == nil {
proxyTLSConfig.RootCAs = roots
}
tlsConnection := tls.Client(connection, proxyTLSConfig)
if err := tlsConnection.HandshakeContext(request.Context()); err != nil {
return nil, errors.New("connect webhook proxy failed")
}
connection = tlsConnection
}
connectAuthority, err := pinnedHTTPConnectAuthority(request.URL)
if err != nil {
return nil, err
}
connectRequest := &http.Request{
Method: http.MethodConnect,
URL: &url.URL{Opaque: connectAuthority},
Host: connectAuthority,
Header: make(http.Header),
}
if err := p.addProxyHeaders(connectRequest, proxyURL); err != nil {
return nil, err
}
if err := connectRequest.Write(connection); err != nil {
return nil, errors.New("connect webhook proxy failed")
}
reader := bufio.NewReader(connection)
connectResponse, err := http.ReadResponse(reader, connectRequest)
if err != nil {
return nil, errors.New("connect webhook proxy failed")
}
if p.transport.OnProxyConnectResponse != nil {
if err := p.transport.OnProxyConnectResponse(request.Context(), proxyURL, connectRequest, connectResponse); err != nil {
return nil, errors.New("connect webhook proxy failed")
}
}
if connectResponse.StatusCode != http.StatusOK {
return nil, errors.New("connect webhook proxy failed")
}
_ = connectResponse.Body.Close()
wireRequest := request.Clone(request.Context())
wireRequest.URL = cloneURL(request.URL)
wireRequest.Host = p.originalHost
wireRequest.Close = true
wireRequest.Header = request.Header.Clone()
if request.GetBody != nil {
body, bodyErr := request.GetBody()
if bodyErr != nil {
return nil, errors.New("prepare webhook proxy request failed")
}
wireRequest.Body = body
defer body.Close()
}
if err := wireRequest.Write(connection); err != nil {
return nil, errors.New("send webhook proxy request failed")
}
response, err := http.ReadResponse(reader, request)
if err != nil {
return nil, errors.New("read webhook proxy response failed")
}
response.Body = &proxyConnectionBody{ReadCloser: response.Body, connection: connection}
closeConnection = false
return response, nil
}
func (p *pinnedHTTPProxyRoundTripper) addProxyHeaders(request *http.Request, proxyURL *url.URL) error {
for name, values := range p.transport.ProxyConnectHeader {
request.Header[name] = append([]string(nil), values...)
}
if p.transport.GetProxyConnectHeader != nil {
target := request.URL.Host
if target == "" {
target = request.Host
}
headers, err := p.transport.GetProxyConnectHeader(request.Context(), proxyURL, target)
if err != nil {
return errors.New("prepare webhook proxy request failed")
}
for name, values := range headers {
request.Header[name] = append([]string(nil), values...)
}
}
if proxyURL.User != nil && request.Header.Get("Proxy-Authorization") == "" {
password, _ := proxyURL.User.Password()
credential := base64.StdEncoding.EncodeToString([]byte(proxyURL.User.Username() + ":" + password))
request.Header.Set("Proxy-Authorization", "Basic "+credential)
}
return nil
}
func pinnedHTTPConnectAuthority(target *url.URL) (string, error) {
if target == nil || target.Hostname() == "" {
return "", errors.New("prepare webhook proxy request failed")
}
port := target.Port()
if port == "" {
port = "80"
}
return net.JoinHostPort(target.Hostname(), port), nil
}
func proxyDialAddress(proxyURL *url.URL) (string, error) {
if proxyURL == nil || (proxyURL.Scheme != "http" && proxyURL.Scheme != "https") || proxyURL.Hostname() == "" {
return "", errors.New("unsupported webhook proxy")
}
port := proxyURL.Port()
if port == "" {
if proxyURL.Scheme == "https" {
port = "443"
} else {
port = "80"
}
}
return net.JoinHostPort(proxyURL.Hostname(), port), nil
}
func cloneURL(source *url.URL) *url.URL {
if source == nil {
return &url.URL{}
}
cloned := *source
return &cloned
}
type proxyConnectionBody struct {
io.ReadCloser
connection net.Conn
}
func (b *proxyConnectionBody) Close() error {
bodyErr := b.ReadCloser.Close()
connectionErr := b.connection.Close()
if bodyErr != nil {
return bodyErr
}
return connectionErr
}
func captureResponse(body []byte, input Request, target preparedTarget) string {
response := strings.ToValidUTF8(string(body), "\uFFFD")
redactions := make([]string, 0, len(input.Headers)+8)
redactions = append(redactions, urlRedactionValues(input.URL)...)
redactions = append(redactions, urlRedactionValues(target.URL)...)
if target.OriginalURL != nil {
redactions = append(redactions, urlRedactionValues(target.OriginalURL.String())...)
}
for name, value := range input.Headers {
if value != "" {
redactions = append(redactions, value)
}
redactions = append(redactions, headerRedactionValues(name, value)...)
}
sort.SliceStable(redactions, func(i, j int) bool {
return len(redactions[i]) > len(redactions[j])
})
for _, value := range redactions {
if value != "" {
response = strings.ReplaceAll(response, value, "[REDACTED]")
}
}
return truncateUTF8(response, MaxCapturedResponseBytes)
}
func urlRedactionValues(rawURL string) []string {
trimmed := strings.TrimSpace(rawURL)
values := []string{trimmed}
parsed, err := url.Parse(trimmed)
if err != nil {
return values
}
values = append(values, parsed.String())
for _, segment := range strings.Split(strings.Trim(parsed.EscapedPath(), "/"), "/") {
if segment == "" {
continue
}
values = append(values, segment)
if decoded, err := url.PathUnescape(segment); err == nil && decoded != segment {
values = append(values, decoded)
}
}
for _, pair := range strings.Split(parsed.RawQuery, "&") {
if pair == "" {
continue
}
encodedName, encodedValue, found := strings.Cut(pair, "=")
if !found {
values = append(values, encodedName)
if decoded, err := url.QueryUnescape(encodedName); err == nil && decoded != encodedName {
values = append(values, decoded)
}
continue
}
if encodedValue == "" {
continue
}
values = append(values, encodedValue)
if decoded, err := url.QueryUnescape(encodedValue); err == nil && decoded != encodedValue {
values = append(values, decoded)
}
}
return values
}
func headerRedactionValues(name, value string) []string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return nil
}
var values []string
switch http.CanonicalHeaderKey(name) {
case "Authorization", "Proxy-Authorization":
if _, credential, found := strings.Cut(trimmed, " "); found {
credential = strings.TrimSpace(credential)
if credential != "" {
values = append(values, credential)
}
}
case "Cookie":
for _, cookie := range strings.Split(trimmed, ";") {
_, cookieValue, found := strings.Cut(cookie, "=")
if !found {
continue
}
cookieValue = strings.Trim(strings.TrimSpace(cookieValue), `"`)
if cookieValue != "" {
values = append(values, cookieValue)
}
}
}
return values
}
func truncateUTF8(value string, maxBytes int) string {
if len(value) <= maxBytes {
return value
}
value = value[:maxBytes]
for !utf8.ValidString(value) {
value = value[:len(value)-1]
}
return value
}
type preparedTarget struct {
URL string
HostHeader string
ServerName string
OriginalURL *url.URL
Addresses []net.IP
}
func prepareTarget(ctx context.Context, rawURL string, preset Preset, resolver IPResolver) (preparedTarget, error) {
parsed, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil || parsed.Scheme == "" || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" {
return preparedTarget{}, errors.New("invalid URL")
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return preparedTarget{}, errors.New("unsupported URL scheme")
}
if preset == PresetDiscord {
query := parsed.Query()
query.Set("wait", "true")
parsed.RawQuery = query.Encode()
}
hostname := strings.TrimSuffix(parsed.Hostname(), ".")
if hostname == "" || strings.Contains(hostname, "%") {
return preparedTarget{}, errors.New("invalid URL host")
}
addresses, err := resolveAddresses(ctx, hostname, resolver)
if err != nil || len(addresses) == 0 {
return preparedTarget{}, errors.New("resolve webhook URL failed")
}
for _, address := range addresses {
if !publicWebhookIP(address) {
return preparedTarget{}, errors.New("webhook URL resolved to a blocked address")
}
}
originalHost := parsed.Host
originalURL := *parsed
parsed.Host = pinnedHost(addresses[0], parsed.Port())
return preparedTarget{
URL: parsed.String(),
HostHeader: originalHost,
ServerName: hostname,
OriginalURL: &originalURL,
Addresses: addresses,
}, nil
}
func resolveAddresses(ctx context.Context, hostname string, resolver IPResolver) ([]net.IP, error) {
if literal := net.ParseIP(hostname); literal != nil {
return []net.IP{literal}, nil
}
if resolver == nil {
resolver = net.DefaultResolver
}
resolved, err := resolver.LookupIPAddr(ctx, hostname)
if err != nil {
return nil, err
}
addresses := make([]net.IP, 0, len(resolved))
for _, item := range resolved {
if item.IP != nil {
addresses = append(addresses, item.IP)
}
}
return addresses, nil
}
func publicWebhookIP(ip net.IP) bool {
if ip == nil {
return false
}
if ipv4 := ip.To4(); ipv4 != nil {
ip = ipv4
}
if !ip.IsGlobalUnicast() || ip.IsUnspecified() || ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() || ip.IsInterfaceLocalMulticast() || ip.IsMulticast() {
return false
}
address, ok := netip.AddrFromSlice(ip)
if !ok {
return false
}
address = address.Unmap()
if wellKnownNAT64Prefix.Contains(address) {
value := address.As16()
return publicWebhookIP(net.IPv4(value[12], value[13], value[14], value[15]))
}
for _, prefix := range globallyReachableSpecialPrefixes {
if prefix.Contains(address) {
return true
}
}
for _, prefix := range blockedWebhookPrefixes {
if prefix.Contains(address) {
return false
}
}
return true
}
var wellKnownNAT64Prefix = netip.MustParsePrefix("64:ff9b::/96")
var globallyReachableSpecialPrefixes = []netip.Prefix{
netip.MustParsePrefix("192.0.0.9/32"),
netip.MustParsePrefix("192.0.0.10/32"),
netip.MustParsePrefix("2001:1::1/128"),
netip.MustParsePrefix("2001:1::2/128"),
netip.MustParsePrefix("2001:1::3/128"),
netip.MustParsePrefix("2001:3::/32"),
netip.MustParsePrefix("2001:4:112::/48"),
netip.MustParsePrefix("2001:20::/28"),
netip.MustParsePrefix("2001:30::/28"),
}
var blockedWebhookPrefixes = []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("64:ff9b:1::/48"),
netip.MustParsePrefix("100::/64"),
netip.MustParsePrefix("100:0:0:1::/64"),
netip.MustParsePrefix("2001::/23"),
netip.MustParsePrefix("2001:db8::/32"),
netip.MustParsePrefix("2002::/16"),
netip.MustParsePrefix("3fff::/20"),
netip.MustParsePrefix("5f00::/16"),
netip.MustParsePrefix("fec0::/10"),
}
func pinnedHost(ip net.IP, port string) string {
host := ip.String()
if port != "" {
return net.JoinHostPort(host, port)
}
if ip.To4() == nil {
return "[" + host + "]"
}
return host
}
func validateRenderedBody(format BodyFormat, body []byte) (string, error) {
if format == "" {
format = BodyJSON
}
switch format {
case BodyJSON:
if !json.Valid(body) {
return "", errors.New("rendered webhook JSON body is invalid")
}
return "application/json; charset=utf-8", nil
case BodyForm:
return "application/x-www-form-urlencoded", nil
case BodyText:
return "text/plain; charset=utf-8", nil
default:
return "", errors.New("unsupported webhook body format")
}
}
func validateHeaders(headers map[string]string) error {
for name, value := range headers {
if !validHeaderName(name) || strings.ContainsAny(value, "\r\n") || reservedHeader(name) {
return errors.New("invalid webhook request header")
}
}
return nil
}
func validHeaderName(name string) bool {
if name == "" {
return false
}
for i := 0; i < len(name); i++ {
if !headerTokenByte(name[i]) {
return false
}
}
return true
}
func headerTokenByte(b byte) bool {
if b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b >= '0' && b <= '9' {
return true
}
return strings.ContainsRune("!#$%&'*+-.^_`|~", rune(b))
}
func reservedHeader(name string) bool {
switch http.CanonicalHeaderKey(name) {
case "Connection", "Content-Length", "Content-Type", "Host", "Proxy-Authorization", "Proxy-Connection", "Te", "Trailer", "Transfer-Encoding", "Upgrade":
return true
default:
return false
}
}
func systemTLSTransport(input *http.Transport, serverName string, originalURL *url.URL, addresses []net.IP) *http.Transport {
var transport *http.Transport
if input != nil {
transport = input.Clone()
} else if defaultTransport, ok := http.DefaultTransport.(*http.Transport); ok {
transport = defaultTransport.Clone()
} else {
transport = &http.Transport{Proxy: http.ProxyFromEnvironment}
}
tlsConfig := &tls.Config{}
if transport.TLSClientConfig != nil {
tlsConfig = transport.TLSClientConfig.Clone()
}
tlsConfig.InsecureSkipVerify = false
if roots, err := loadWebhookSystemCertPool(); err == nil {
tlsConfig.RootCAs = roots
} else {
tlsConfig.RootCAs = nil
}
tlsConfig.Certificates = nil
tlsConfig.GetClientCertificate = nil
tlsConfig.ServerName = serverName
transport.TLSClientConfig = tlsConfig
transport.DialTLS = nil
transport.DialTLSContext = nil
if transport.Proxy != nil && originalURL != nil {
proxySelector := transport.Proxy
selectionURL := *originalURL
transport.Proxy = func(request *http.Request) (*url.URL, error) {
selectionRequest := request.Clone(request.Context())
selectionRequest.URL = &selectionURL
selectionRequest.Host = selectionURL.Host
return proxySelector(selectionRequest)
}
}
if len(addresses) > 1 {
firstAddress := addresses[0]
baseDial := transport.DialContext
if baseDial == nil {
baseDial = (&net.Dialer{}).DialContext
}
transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
dialIP := net.ParseIP(host)
if err != nil || dialIP == nil || !dialIP.Equal(firstAddress) {
return baseDial(ctx, network, address)
}
var lastErr error
for _, candidate := range addresses {
connection, dialErr := baseDial(ctx, network, net.JoinHostPort(candidate.String(), port))
if dialErr == nil {
return connection, nil
}
lastErr = dialErr
}
return nil, lastErr
}
}
secureDial := transport.DialContext
if secureDial == nil {
secureDial = (&net.Dialer{}).DialContext
}
targetPort := "443"
if originalURL != nil && originalURL.Port() != "" {
targetPort = originalURL.Port()
}
transport.DialTLSContext = func(ctx context.Context, network, address string) (net.Conn, error) {
connection, err := secureDial(ctx, network, address)
if err != nil {
return nil, err
}
host, port, splitErr := net.SplitHostPort(address)
if splitErr != nil {
_ = connection.Close()
return nil, splitErr
}
firstHopConfig := tlsConfig.Clone()
if port == targetPort && isPinnedWebhookAddress(host, addresses) {
firstHopConfig.ServerName = serverName
} else {
firstHopConfig.ServerName = strings.TrimSuffix(host, ".")
firstHopConfig.NextProtos = []string{"http/1.1"}
}
tlsConnection := tls.Client(connection, firstHopConfig)
if err := tlsConnection.HandshakeContext(ctx); err != nil {
_ = connection.Close()
return nil, err
}
return tlsConnection, nil
}
return transport
}
func isPinnedWebhookAddress(host string, addresses []net.IP) bool {
dialIP := net.ParseIP(host)
if dialIP == nil {
return false
}
for _, address := range addresses {
if dialIP.Equal(address) {
return true
}
}
return false
}
func validateResponse(preset Preset, statusCode int, body []byte) error {
if preset == "" {
preset = PresetGeneric
}
switch preset {
case PresetSlack:
if statusCode != http.StatusOK || strings.TrimSpace(string(body)) != "ok" {
return errors.New("webhook provider rejected response")
}
return nil
case PresetGeneric, PresetDiscord, PresetTeams:
if statusCode < http.StatusOK || statusCode >= http.StatusMultipleChoices {
return errors.New("webhook provider rejected response")
}
return nil
default:
return errors.New("unsupported webhook preset")
}
}