mirror of https://github.com/usememos/memos.git
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
This commit is contained in:
parent
f43965de00
commit
150371d211
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue