From 0b2263086f5dde1bef09f444dc4b51db4a59f519 Mon Sep 17 00:00:00 2001 From: Awuqing <3184394176@qq.com> Date: Sat, 25 Apr 2026 21:50:20 +0800 Subject: [PATCH] fix: harden sms webhook target validation --- server/internal/notify/sms.go | 117 +++++++++++++++++- server/internal/notify/sms_test.go | 35 ++++++ .../components/notifications/field-config.ts | 2 +- 3 files changed, 149 insertions(+), 5 deletions(-) create mode 100644 server/internal/notify/sms_test.go diff --git a/server/internal/notify/sms.go b/server/internal/notify/sms.go index 34c3700..517f011 100644 --- a/server/internal/notify/sms.go +++ b/server/internal/notify/sms.go @@ -5,7 +5,9 @@ import ( "context" "encoding/json" "fmt" + "net" "net/http" + "net/url" "strings" "time" ) @@ -15,15 +17,19 @@ type SMSWebhookNotifier struct { } func NewSMSWebhookNotifier() *SMSWebhookNotifier { - return &SMSWebhookNotifier{client: &http.Client{Timeout: 10 * time.Second}} + return &SMSWebhookNotifier{client: &http.Client{ + Timeout: 10 * time.Second, + Transport: newSMSWebhookTransport(), + CheckRedirect: validateSMSWebhookRedirect, + }} } func (n *SMSWebhookNotifier) Type() string { return "sms" } func (n *SMSWebhookNotifier) SensitiveFields() []string { return []string{"secret"} } func (n *SMSWebhookNotifier) Validate(config map[string]any) error { - if strings.TrimSpace(asString(config["url"])) == "" { - return fmt.Errorf("sms webhook url is required") + if _, err := validateSMSWebhookURL(asString(config["url"])); err != nil { + return err } return nil } @@ -44,7 +50,13 @@ func (n *SMSWebhookNotifier) Send(ctx context.Context, config map[string]any, me if err != nil { return fmt.Errorf("marshal sms webhook payload: %w", err) } - request, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimSpace(asString(config["url"])), bytes.NewReader(body)) + endpoint, err := validateSMSWebhookURL(asString(config["url"])) + if err != nil { + return err + } + + // codeql[go/request-forgery]: SMS webhook URLs are admin-configured and validated by validateSMSWebhookURL before use. + request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) if err != nil { return fmt.Errorf("create sms webhook request: %w", err) } @@ -62,3 +74,100 @@ func (n *SMSWebhookNotifier) Send(ctx context.Context, config map[string]any, me } return nil } + +func validateSMSWebhookRedirect(request *http.Request, _ []*http.Request) error { + _, err := validateSMSWebhookURL(request.URL.String()) + return err +} + +func newSMSWebhookTransport() *http.Transport { + return &http.Transport{ + DialContext: dialSMSWebhookContext, + ForceAttemptHTTP2: true, + TLSHandshakeTimeout: 10 * time.Second, + ResponseHeaderTimeout: 10 * time.Second, + IdleConnTimeout: 30 * time.Second, + } +} + +func dialSMSWebhookContext(ctx context.Context, network string, address string) (net.Conn, error) { + host, port, err := net.SplitHostPort(address) + if err != nil { + return nil, err + } + ip, err := resolvePublicSMSWebhookIP(ctx, host) + if err != nil { + return nil, err + } + dialer := &net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second} + return dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port)) +} + +func resolvePublicSMSWebhookIP(ctx context.Context, hostname string) (net.IP, error) { + host := strings.TrimSpace(hostname) + if ip := net.ParseIP(host); ip != nil { + if !isPublicSMSWebhookIP(ip) { + return nil, fmt.Errorf("sms webhook host must resolve to a public address") + } + return ip, nil + } + addresses, err := net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil { + return nil, fmt.Errorf("resolve sms webhook host: %w", err) + } + for _, address := range addresses { + if !isPublicSMSWebhookIP(address.IP) { + return nil, fmt.Errorf("sms webhook host must resolve to a public address") + } + } + if len(addresses) == 0 { + return nil, fmt.Errorf("sms webhook host did not resolve") + } + return addresses[0].IP, nil +} + +func validateSMSWebhookURL(raw string) (string, error) { + endpoint := strings.TrimSpace(raw) + if endpoint == "" { + return "", fmt.Errorf("sms webhook url is required") + } + parsed, err := url.Parse(endpoint) + if err != nil { + return "", fmt.Errorf("sms webhook url is invalid: %w", err) + } + if !strings.EqualFold(parsed.Scheme, "https") { + return "", fmt.Errorf("sms webhook url must use https") + } + if parsed.User != nil { + return "", fmt.Errorf("sms webhook url must not include user info") + } + if parsed.Hostname() == "" { + return "", fmt.Errorf("sms webhook host is required") + } + if err := validateSMSWebhookHost(parsed.Hostname()); err != nil { + return "", err + } + parsed.Fragment = "" + return parsed.String(), nil +} + +func validateSMSWebhookHost(hostname string) error { + host := strings.Trim(strings.ToLower(strings.TrimSpace(hostname)), ".") + if host == "" || host == "localhost" || strings.HasSuffix(host, ".localhost") { + return fmt.Errorf("sms webhook host is not allowed") + } + if ip := net.ParseIP(host); ip != nil && !isPublicSMSWebhookIP(ip) { + return fmt.Errorf("sms webhook host must resolve to a public address") + } + return nil +} + +func isPublicSMSWebhookIP(ip net.IP) bool { + return ip.IsGlobalUnicast() && + !ip.IsPrivate() && + !ip.IsLoopback() && + !ip.IsLinkLocalUnicast() && + !ip.IsLinkLocalMulticast() && + !ip.IsMulticast() && + !ip.IsUnspecified() +} diff --git a/server/internal/notify/sms_test.go b/server/internal/notify/sms_test.go new file mode 100644 index 0000000..163722c --- /dev/null +++ b/server/internal/notify/sms_test.go @@ -0,0 +1,35 @@ +package notify + +import "testing" + +func TestValidateSMSWebhookURL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + raw string + wantErr bool + }{ + {name: "valid https endpoint", raw: "https://sms.example.com/send?channel=otp"}, + {name: "reject empty", raw: "", wantErr: true}, + {name: "reject http", raw: "http://sms.example.com/send", wantErr: true}, + {name: "reject user info", raw: "https://user:pass@sms.example.com/send", wantErr: true}, + {name: "reject localhost", raw: "https://localhost/send", wantErr: true}, + {name: "reject private ipv4", raw: "https://192.168.1.10/send", wantErr: true}, + {name: "reject loopback ipv6", raw: "https://[::1]/send", wantErr: true}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, err := validateSMSWebhookURL(tt.raw) + if tt.wantErr && err == nil { + t.Fatalf("expected error") + } + if !tt.wantErr && err != nil { + t.Fatalf("expected no error, got %v", err) + } + }) + } +} diff --git a/web/src/components/notifications/field-config.ts b/web/src/components/notifications/field-config.ts index 995587c..a963082 100644 --- a/web/src/components/notifications/field-config.ts +++ b/web/src/components/notifications/field-config.ts @@ -14,7 +14,7 @@ const FIELD_CONFIG_MAP: Record = { { key: 'secret', label: '共享密钥', type: 'password', placeholder: '可选', sensitive: true }, ], sms: [ - { key: 'url', label: 'SMS Webhook URL', type: 'input', required: true, placeholder: 'https://sms-gateway.example.com/send' }, + { key: 'url', label: 'SMS Webhook URL', type: 'input', required: true, placeholder: 'https://sms-gateway.example.com/send', description: '仅允许 HTTPS 公网地址。' }, { key: 'secret', label: '共享密钥', type: 'password', placeholder: '可选', sensitive: true }, ], telegram: [