From 150371d2111dfedd21483a21c49183079604d322 Mon Sep 17 00:00:00 2001 From: Steven Date: Mon, 23 Feb 2026 10:14:24 +0800 Subject: [PATCH] fix(webhook): remediate SSRF vulnerability in webhook dispatcher - Add plugin/webhook/validate.go as single source of truth for SSRF protection: reserved CIDR list parsed once at init(), isReservedIP(), and exported ValidateURL() used at registration/update time - Replace unguarded http.Client in webhook.go with safeClient whose Transport uses a custom DialContext that re-resolves hostnames at dial time, defeating DNS rebinding attacks - Call webhook.ValidateURL() in CreateUserWebhook and both UpdateUserWebhook paths to reject non-http/https schemes and reserved/private IP targets before persisting - Strip internal service response body from non-2xx error log messages to prevent data leakage via application logs --- plugin/webhook/validate.go | 75 ++++++++++++++++++++++++++++ plugin/webhook/webhook.go | 42 +++++++++++++--- server/router/api/v1/user_service.go | 16 +++++- 3 files changed, 125 insertions(+), 8 deletions(-) create mode 100644 plugin/webhook/validate.go diff --git a/plugin/webhook/validate.go b/plugin/webhook/validate.go new file mode 100644 index 000000000..3e5e82c9f --- /dev/null +++ b/plugin/webhook/validate.go @@ -0,0 +1,75 @@ +package webhook + +import ( + "net" + "net/url" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// reservedCIDRs lists IP ranges that must never be targeted by outbound webhook requests. +// Covers loopback, RFC-1918 private, link-local (including cloud IMDS at 169.254.169.254), +// and their IPv6 equivalents. +var reservedCIDRs = []string{ + "127.0.0.0/8", // IPv4 loopback + "10.0.0.0/8", // RFC-1918 class A + "172.16.0.0/12", // RFC-1918 class B + "192.168.0.0/16", // RFC-1918 class C + "169.254.0.0/16", // Link-local / cloud IMDS + "::1/128", // IPv6 loopback + "fc00::/7", // IPv6 unique local + "fe80::/10", // IPv6 link-local +} + +// reservedNetworks is the parsed form of reservedCIDRs, built once at startup. +var reservedNetworks []*net.IPNet + +func init() { + for _, cidr := range reservedCIDRs { + _, network, err := net.ParseCIDR(cidr) + if err != nil { + panic("webhook: invalid reserved CIDR " + cidr + ": " + err.Error()) + } + reservedNetworks = append(reservedNetworks, network) + } +} + +// isReservedIP reports whether ip falls within any reserved/private range. +func isReservedIP(ip net.IP) bool { + for _, network := range reservedNetworks { + if network.Contains(ip) { + return true + } + } + return false +} + +// ValidateURL checks that rawURL: +// 1. Parses as a valid absolute URL. +// 2. Uses the http or https scheme. +// 3. Does not resolve to a reserved/private IP address. +// +// It returns a gRPC InvalidArgument status error so callers can return it directly. +func ValidateURL(rawURL string) error { + u, err := url.ParseRequestURI(rawURL) + if err != nil { + return status.Errorf(codes.InvalidArgument, "invalid webhook URL: %v", err) + } + if u.Scheme != "http" && u.Scheme != "https" { + return status.Errorf(codes.InvalidArgument, "webhook URL must use http or https scheme, got %q", u.Scheme) + } + + ips, err := net.LookupHost(u.Hostname()) + if err != nil { + return status.Errorf(codes.InvalidArgument, "webhook URL hostname could not be resolved: %v", err) + } + + for _, ipStr := range ips { + ip := net.ParseIP(ipStr) + if ip != nil && isReservedIP(ip) { + return status.Errorf(codes.InvalidArgument, "webhook URL must not resolve to a reserved or private IP address") + } + } + return nil +} diff --git a/plugin/webhook/webhook.go b/plugin/webhook/webhook.go index c87019d26..421a9b74a 100644 --- a/plugin/webhook/webhook.go +++ b/plugin/webhook/webhook.go @@ -2,9 +2,11 @@ package webhook import ( "bytes" + "context" "encoding/json" "io" "log/slog" + "net" "net/http" "time" @@ -16,8 +18,40 @@ import ( var ( // timeout is the timeout for webhook request. Default to 30 seconds. timeout = 30 * time.Second + + // safeClient is the shared HTTP client used for all webhook dispatches. + // Its Transport guards against SSRF by blocking connections to reserved/private + // IP addresses at dial time, which also defeats DNS rebinding attacks. + safeClient = &http.Client{ + Timeout: timeout, + Transport: &http.Transport{ + DialContext: safeDialContext, + }, + } ) +// safeDialContext is a net.Dialer.DialContext replacement that resolves the target +// hostname and rejects any address that falls within a reserved/private IP range. +func safeDialContext(ctx context.Context, network, addr string) (net.Conn, error) { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return nil, errors.Errorf("webhook: invalid address %q", addr) + } + + ips, err := net.DefaultResolver.LookupHost(ctx, host) + if err != nil { + return nil, errors.Wrapf(err, "webhook: failed to resolve host %q", host) + } + + for _, ipStr := range ips { + if ip := net.ParseIP(ipStr); ip != nil && isReservedIP(ip) { + return nil, errors.Errorf("webhook: connection to reserved/private IP address is not allowed") + } + } + + return (&net.Dialer{}).DialContext(ctx, network, net.JoinHostPort(host, port)) +} + type WebhookRequestPayload struct { // The target URL for the webhook request. URL string `json:"url"` @@ -42,10 +76,7 @@ func Post(requestPayload *WebhookRequestPayload) error { } req.Header.Set("Content-Type", "application/json") - client := &http.Client{ - Timeout: timeout, - } - resp, err := client.Do(req) + resp, err := safeClient.Do(req) if err != nil { return errors.Wrapf(err, "failed to post webhook to %s", requestPayload.URL) } @@ -57,7 +88,7 @@ func Post(requestPayload *WebhookRequestPayload) error { } if resp.StatusCode < 200 || resp.StatusCode > 299 { - return errors.Errorf("failed to post webhook %s, status code: %d, response body: %s", requestPayload.URL, resp.StatusCode, b) + return errors.Errorf("failed to post webhook %s, status code: %d", requestPayload.URL, resp.StatusCode) } response := &struct { @@ -80,7 +111,6 @@ func Post(requestPayload *WebhookRequestPayload) error { func PostAsync(requestPayload *WebhookRequestPayload) { go func() { if err := Post(requestPayload); err != nil { - // Since we're in a goroutine, we can only log the error slog.Warn("Failed to dispatch webhook asynchronously", slog.String("url", requestPayload.URL), slog.String("activityType", requestPayload.ActivityType), diff --git a/server/router/api/v1/user_service.go b/server/router/api/v1/user_service.go index 0956000e3..e718a8b15 100644 --- a/server/router/api/v1/user_service.go +++ b/server/router/api/v1/user_service.go @@ -21,6 +21,7 @@ import ( "github.com/usememos/memos/internal/base" "github.com/usememos/memos/internal/util" + "github.com/usememos/memos/plugin/webhook" v1pb "github.com/usememos/memos/proto/gen/api/v1" storepb "github.com/usememos/memos/proto/gen/store" "github.com/usememos/memos/server/auth" @@ -729,6 +730,9 @@ func (s *APIV1Service) CreateUserWebhook(ctx context.Context, request *v1pb.Crea if request.Webhook.Url == "" { return nil, status.Errorf(codes.InvalidArgument, "webhook URL is required") } + if err := webhook.ValidateURL(strings.TrimSpace(request.Webhook.Url)); err != nil { + return nil, err + } webhookID := generateUserWebhookID() webhook := &storepb.WebhooksUserSetting_Webhook{ @@ -797,7 +801,11 @@ func (s *APIV1Service) UpdateUserWebhook(ctx context.Context, request *v1pb.Upda switch path { case "url": if request.Webhook.Url != "" { - updatedWebhook.Url = strings.TrimSpace(request.Webhook.Url) + trimmed := strings.TrimSpace(request.Webhook.Url) + if err := webhook.ValidateURL(trimmed); err != nil { + return nil, err + } + updatedWebhook.Url = trimmed } case "display_name": updatedWebhook.Title = request.Webhook.DisplayName @@ -808,7 +816,11 @@ func (s *APIV1Service) UpdateUserWebhook(ctx context.Context, request *v1pb.Upda } else { // If no update mask is provided, update all fields if request.Webhook.Url != "" { - updatedWebhook.Url = strings.TrimSpace(request.Webhook.Url) + trimmed := strings.TrimSpace(request.Webhook.Url) + if err := webhook.ValidateURL(trimmed); err != nil { + return nil, err + } + updatedWebhook.Url = trimmed } updatedWebhook.Title = request.Webhook.DisplayName }