fix(notification): harden webhook request handling

Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
This commit is contained in:
naiba
2026-05-17 10:24:19 +08:00
co-authored by naiba/CloudCode
parent 3e889dc3d9
commit c4bea1ffd3
2 changed files with 211 additions and 9 deletions
+143 -9
View File
@@ -1,10 +1,14 @@
package model package model
import ( import (
"context"
"crypto/tls"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"net"
"net/http" "net/http"
"net/netip"
"net/url" "net/url"
"strings" "strings"
"time" "time"
@@ -25,6 +29,35 @@ const (
NotificationRequestMethodPOST NotificationRequestMethodPOST
) )
var errNotificationURLNotAllowed = errors.New("notification URL target is not allowed")
var notificationBlockedCIDRs = mustParseNotificationCIDRs([]string{
"0.0.0.0/8",
"10.0.0.0/8",
"100.64.0.0/10",
"127.0.0.0/8",
"169.254.0.0/16",
"172.16.0.0/12",
"192.0.0.0/24",
"192.0.2.0/24",
"192.168.0.0/16",
"198.18.0.0/15",
"198.51.100.0/24",
"203.0.113.0/24",
"224.0.0.0/4",
"240.0.0.0/4",
"::/128",
"::1/128",
"::ffff:0:0/96",
"64:ff9b::/96",
"100::/64",
"2001::/23",
"2001:db8::/32",
"fc00::/7",
"fe80::/10",
"ff00::/8",
})
type NotificationServerBundle struct { type NotificationServerBundle struct {
Notification *Notification Notification *Notification
Server *Server Server *Server
@@ -111,13 +144,8 @@ func (n *Notification) setRequestHeader(req *http.Request) error {
} }
func (ns *NotificationServerBundle) Send(message string) error { func (ns *NotificationServerBundle) Send(message string) error {
var client *http.Client
n := ns.Notification n := ns.Notification
if n.VerifyTLS != nil && *n.VerifyTLS { verifyTLS := n.VerifyTLS != nil && *n.VerifyTLS
client = utils.HttpClient
} else {
client = utils.HttpClientSkipTlsVerify
}
reqBody, err := ns.reqBody(message) reqBody, err := ns.reqBody(message)
if err != nil { if err != nil {
@@ -129,7 +157,13 @@ func (ns *NotificationServerBundle) Send(message string) error {
return err return err
} }
req, err := http.NewRequest(reqMethod, ns.reqURL(message), strings.NewReader(reqBody)) reqURL := ns.reqURL(message)
client, err := newNotificationHTTPClient(reqURL, verifyTLS)
if err != nil {
return err
}
req, err := http.NewRequest(reqMethod, reqURL, strings.NewReader(reqBody))
if err != nil { if err != nil {
return err return err
} }
@@ -149,8 +183,7 @@ func (ns *NotificationServerBundle) Send(message string) error {
}() }()
if resp.StatusCode < 200 || resp.StatusCode > 299 { if resp.StatusCode < 200 || resp.StatusCode > 299 {
body, _ := io.ReadAll(resp.Body) return notificationResponseError(resp)
return fmt.Errorf("%d@%s %s", resp.StatusCode, resp.Status, string(body))
} else { } else {
_, _ = io.Copy(io.Discard, resp.Body) _, _ = io.Copy(io.Discard, resp.Body)
} }
@@ -158,6 +191,107 @@ func (ns *NotificationServerBundle) Send(message string) error {
return nil return nil
} }
func newNotificationHTTPClient(rawURL string, verifyTLS bool) (*http.Client, error) {
parsedURL, ip, err := resolveNotificationTarget(rawURL)
if err != nil {
return nil, err
}
port := parsedURL.Port()
if port == "" {
if parsedURL.Scheme == "https" {
port = "443"
} else {
port = "80"
}
}
targetAddress := net.JoinHostPort(ip.String(), port)
dialer := &net.Dialer{}
return &http.Client{
Transport: &http.Transport{
DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
return dialer.DialContext(ctx, network, targetAddress)
},
TLSClientConfig: &tls.Config{InsecureSkipVerify: !verifyTLS, ServerName: parsedURL.Hostname()},
},
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
Timeout: time.Minute * 10,
}, nil
}
func notificationResponseError(resp *http.Response) error {
_, _ = io.CopyN(io.Discard, resp.Body, 4096)
return fmt.Errorf("%d@%s", resp.StatusCode, resp.Status)
}
func resolveNotificationTarget(rawURL string) (*url.URL, net.IP, error) {
parsedURL, err := url.Parse(rawURL)
if err != nil {
return nil, nil, err
}
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
return nil, nil, errNotificationURLNotAllowed
}
host := parsedURL.Hostname()
if host == "" {
return nil, nil, errNotificationURLNotAllowed
}
if ip := net.ParseIP(host); ip != nil {
if !notificationIPAllowed(ip) {
return nil, nil, errNotificationURLNotAllowed
}
return parsedURL, ip, nil
}
ips, err := net.LookupIP(host)
if err != nil {
return nil, nil, err
}
if len(ips) == 0 {
return nil, nil, errNotificationURLNotAllowed
}
for _, ip := range ips {
if !notificationIPAllowed(ip) {
return nil, nil, errNotificationURLNotAllowed
}
}
return parsedURL, ips[0], nil
}
func notificationIPAllowed(ip net.IP) bool {
parsedIP, ok := netipFromIP(ip)
if !ok {
return false
}
for _, cidr := range notificationBlockedCIDRs {
if cidr.Contains(parsedIP) {
return false
}
}
return parsedIP.IsGlobalUnicast()
}
func netipFromIP(ip net.IP) (netip.Addr, bool) {
parsedIP, ok := netip.AddrFromSlice(ip)
if !ok {
return netip.Addr{}, false
}
return parsedIP.Unmap(), true
}
func mustParseNotificationCIDRs(cidrs []string) []netip.Prefix {
prefixes := make([]netip.Prefix, 0, len(cidrs))
for _, cidr := range cidrs {
prefixes = append(prefixes, netip.MustParsePrefix(cidr))
}
return prefixes
}
// replaceParamInString 替换字符串中的占位符 // replaceParamInString 替换字符串中的占位符
func (ns *NotificationServerBundle) replaceParamsInString(str string, message string, mod func(string) string) string { func (ns *NotificationServerBundle) replaceParamsInString(str string, message string, mod func(string) string) string {
if mod == nil { if mod == nil {
+68
View File
@@ -1,6 +1,7 @@
package model package model
import ( import (
"io"
"net/http" "net/http"
"strings" "strings"
"testing" "testing"
@@ -234,3 +235,70 @@ func TestNotification(t *testing.T) {
execCase(t, c) execCase(t, c)
} }
} }
func TestNotificationResponseErrorDoesNotReflectNonSuccessResponseBody(t *testing.T) {
const internalResponseBody = "internal service says token=secret"
resp := &http.Response{
StatusCode: http.StatusTeapot,
Status: "418 I'm a teapot",
Body: io.NopCloser(strings.NewReader(internalResponseBody)),
}
err := notificationResponseError(resp)
if strings.Contains(err.Error(), internalResponseBody) {
t.Fatalf("expected upstream response body to be hidden from error, got %q", err.Error())
}
}
func TestNotificationSendRejectsLoopbackTarget(t *testing.T) {
verifyTLS := true
notification := &Notification{
URL: "http://127.0.0.1/internal",
RequestMethod: NotificationRequestMethodGET,
VerifyTLS: &verifyTLS,
}
bundle := NotificationServerBundle{
Notification: notification,
Loc: time.Local,
}
err := bundle.Send("probe")
if err == nil {
t.Fatal("expected loopback notification URL to be rejected")
}
if !strings.Contains(err.Error(), "not allowed") {
t.Fatalf("expected not allowed error, got %q", err.Error())
}
}
func TestNotificationTargetRejectsSpecialUseAddresses(t *testing.T) {
cases := []string{
"http://100.64.0.1/", // CGNAT
"http://192.0.2.1/", // documentation range
"http://[fc00::1]/", // IPv6 unique local
"http://[2001:db8::1]/", // IPv6 documentation range
"http://[::ffff:127.0.0.1]/", // IPv4-mapped loopback
}
for _, rawURL := range cases {
if _, _, err := resolveNotificationTarget(rawURL); err == nil {
t.Fatalf("expected %s to be rejected", rawURL)
}
}
}
func TestNotificationHTTPClientPreservesTLSServerName(t *testing.T) {
client, err := newNotificationHTTPClient("https://example.com/webhook", true)
if err != nil {
t.Fatalf("expected public HTTPS URL to create client: %v", err)
}
transport, ok := client.Transport.(*http.Transport)
if !ok {
t.Fatalf("expected http.Transport, got %T", client.Transport)
}
if transport.TLSClientConfig == nil || transport.TLSClientConfig.ServerName != "example.com" {
t.Fatalf("expected TLS ServerName example.com, got %#v", transport.TLSClientConfig)
}
}