server: guard Web Push endpoints against SSRF (#94)
Member-supplied push endpoints were stored with no validation and sent via webpush-go's default http.Client, bypassing the unfurl SSRF guard: any member could register an internal URL (cloud metadata, an internal host:port) as their push endpoint and have the server dial it on every message to their thread — a blind SSRF / port scanner. - validatePushEndpoint rejects non-https / hostless endpoints at subscribe time. - pushClient routes Web Push through a dialer that checks the resolved IP of every connection (guardDialControl, factored out of ogClient), so stored endpoints and redirect hops are re-validated too (defeats DNS rebinding). - APNs is unaffected (fixed Apple hosts, device token only in the path).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+171
−13
main.go+7 −0push.go+55 −0push_ssrf_test.go+88 −0unfurl.go+21 −13
diff --git a/main.go b/main.go| index a57eed6..721de0c 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -1659,6 +1659,13 @@ func (s *Server) subscribePush(w http.ResponseWriter, r *http.Request) { |
| httpError(w, http.StatusBadRequest, "missing subscription") |
| return |
| } |
| + // The endpoint is attacker-controlled and the server POSTs to it; reject |
| + // anything that isn't a plain https URL so it can't be aimed at our own |
| + // network (SSRF). The pushClient dialer enforces the resolved-IP check. |
| + if err := validatePushEndpoint(body.Subscription.Endpoint); err != nil { |
| + httpError(w, http.StatusBadRequest, "bad subscription endpoint") |
| + return |
| + } |
| uid, _ := s.db.deviceTokenUser(body.Token) // "" for a legacy (non-device) token |
| if err := s.db.addSubscription(PushSub{ |
| Endpoint: body.Subscription.Endpoint, |
diff --git a/push.go b/push.go| index 55043d4..1f7e2c6 100644 |
| --- a/push.go |
| +++ b/push.go |
| @@ -2,9 +2,12 @@ package main |
| import ( |
| "encoding/json" |
| + "errors" |
| "io" |
| "log" |
| + "net" |
| "net/http" |
| + "net/url" |
| "sync" |
| "sync/atomic" |
| "time" |
| @@ -12,6 +15,55 @@ import ( |
| webpush "github.com/SherClockHolmes/webpush-go" |
| ) |
| +// pushClient sends Web Push POSTs. The endpoint is attacker-controlled data (any |
| +// member registers one at subscribe time), so — like ogClient — its dialer |
| +// validates the resolved IP of every connection to stop the server being pointed |
| +// at its own network (SSRF), and re-validates each redirect hop (defeating DNS |
| +// rebinding). webpush-go otherwise uses http.DefaultClient, which has no guard. |
| +var pushClient = &http.Client{ |
| + Timeout: 30 * time.Second, |
| + Transport: &http.Transport{ |
| + DialContext: (&net.Dialer{ |
| + Timeout: 10 * time.Second, |
| + Control: guardDialControl, |
| + }).DialContext, |
| + TLSHandshakeTimeout: 10 * time.Second, |
| + ResponseHeaderTimeout: 20 * time.Second, |
| + DisableKeepAlives: true, |
| + }, |
| + CheckRedirect: func(req *http.Request, via []*http.Request) error { |
| + if len(via) >= 5 { |
| + return errors.New("too many redirects") |
| + } |
| + if req.URL.Scheme != "https" { |
| + return errors.New("non-https redirect") |
| + } |
| + return nil |
| + }, |
| +} |
| + |
| +// validatePushEndpoint is the first line of the SSRF guard: it rejects anything |
| +// that isn't a plain https URL with a host before we ever store it. Real Web Push |
| +// services (FCM, Mozilla, Apple, Windows) are always https. The resolved-IP check |
| +// that actually blocks internal targets lives in pushClient's dialer above, so a |
| +// stale stored endpoint is still guarded when we send. |
| +func validatePushEndpoint(endpoint string) error { |
| + if len(endpoint) > 2048 { |
| + return errors.New("endpoint too long") |
| + } |
| + u, err := url.Parse(endpoint) |
| + if err != nil { |
| + return err |
| + } |
| + if u.Scheme != "https" { |
| + return errors.New("endpoint must be https") |
| + } |
| + if u.Hostname() == "" { |
| + return errors.New("endpoint has no host") |
| + } |
| + return nil |
| +} |
| + |
| // PushSub is a stored Web Push subscription, tied to the invite token that made it. |
| type PushSub struct { |
| Endpoint string |
| @@ -200,6 +252,9 @@ func (s *Server) sendOne(payload []byte, kind string, sub PushSub) bool { |
| Endpoint: sub.Endpoint, |
| Keys: webpush.Keys{P256dh: sub.P256dh, Auth: sub.Auth}, |
| }, &webpush.Options{ |
| + // Route through the SSRF-guarded client, not webpush-go's default |
| + // http.DefaultClient — the endpoint is attacker-controlled. |
| + HTTPClient: pushClient, |
| // Pass the bare e-mail: webpush-go prepends "mailto:" itself. Passing |
| // "mailto:…" yields "mailto:mailto:…", which Apple rejects (BadJwtToken). |
| Subscriber: "admin@luan.chat", |
diff --git a/push_ssrf_test.go b/push_ssrf_test.go| new file mode 100644 |
| index 0000000..d7b072a |
| --- /dev/null |
| +++ b/push_ssrf_test.go |
| @@ -0,0 +1,88 @@ |
| +package main |
| + |
| +import ( |
| + "fmt" |
| + "net/http" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// validatePushEndpoint is the ingest guard: only plain https URLs with a host. |
| +func TestValidatePushEndpoint(t *testing.T) { |
| + bad := []string{ |
| + "", // empty |
| + "http://fcm.googleapis.com/x", // not https |
| + "http://169.254.169.254/latest", // http + cloud metadata |
| + "https://", // no host |
| + "ftp://example.com/x", // wrong scheme |
| + "file:///etc/passwd", // wrong scheme |
| + "http://[::1]/x", // not https (loopback) |
| + } |
| + for _, e := range bad { |
| + if err := validatePushEndpoint(e); err == nil { |
| + t.Errorf("validatePushEndpoint(%q) = nil, want error", e) |
| + } |
| + } |
| + good := []string{ |
| + "https://fcm.googleapis.com/fcm/send/abc123", |
| + "https://updates.push.services.mozilla.com/wpush/v2/xyz", |
| + "https://web.push.apple.com/QABC", |
| + } |
| + for _, e := range good { |
| + if err := validatePushEndpoint(e); err != nil { |
| + t.Errorf("validatePushEndpoint(%q) = %v, want nil", e, err) |
| + } |
| + } |
| +} |
| + |
| +// guardDialControl is the enforcement layer: it blocks the resolved IP at dial |
| +// time, so even a stored endpoint (and any redirect hop) can't reach the |
| +// server's own network. This is what actually stops the SSRF. |
| +func TestGuardDialControlBlocksNonPublic(t *testing.T) { |
| + saved := allowPrivatePreviewHosts |
| + allowPrivatePreviewHosts = false |
| + defer func() { allowPrivatePreviewHosts = saved }() |
| + |
| + blocked := []string{ |
| + "127.0.0.1:443", // loopback |
| + "169.254.169.254:443", // cloud metadata (link-local) |
| + "10.0.0.5:6379", // private (internal Redis, etc.) |
| + "192.168.1.1:443", // private |
| + "[::1]:443", // IPv6 loopback |
| + "100.64.0.1:443", // CGNAT |
| + "0.0.0.0:443", // unspecified |
| + } |
| + for _, a := range blocked { |
| + if err := guardDialControl("tcp", a, nil); err == nil { |
| + t.Errorf("guardDialControl(%q) = nil, want blocked", a) |
| + } |
| + } |
| + if err := guardDialControl("tcp", "8.8.8.8:443", nil); err != nil { |
| + t.Errorf("guardDialControl(public) = %v, want nil", err) |
| + } |
| +} |
| + |
| +// End to end through the handler: a valid participant token does NOT let a member |
| +// register an internal/non-https push endpoint (proves the validator is wired in). |
| +func TestSubscribePushRejectsBadEndpoint(t *testing.T) { |
| + ts, srv := newTestServer(t) |
| + inv, err := srv.db.createInvite("push-test") |
| + if err != nil { |
| + t.Fatalf("createInvite: %v", err) |
| + } |
| + post := func(endpoint string) int { |
| + body := fmt.Sprintf(`{"token":%q,"subscription":{"endpoint":%q,"keys":{"p256dh":"p","auth":"a"}}}`, inv.Token, endpoint) |
| + resp, err := http.Post(ts.URL+"/api/push/subscribe", "application/json", strings.NewReader(body)) |
| + if err != nil { |
| + t.Fatalf("post: %v", err) |
| + } |
| + resp.Body.Close() |
| + return resp.StatusCode |
| + } |
| + if got := post("http://169.254.169.254/latest/meta-data/"); got != http.StatusBadRequest { |
| + t.Fatalf("http metadata endpoint with valid token: want 400, got %d", got) |
| + } |
| + if got := post("https://fcm.googleapis.com/fcm/send/abc123"); got != http.StatusOK { |
| + t.Fatalf("valid https endpoint: want 200, got %d", got) |
| + } |
| +} |
diff --git a/unfurl.go b/unfurl.go| index 71dfe39..56f39a3 100644 |
| --- a/unfurl.go |
| +++ b/unfurl.go |
| @@ -57,6 +57,26 @@ func isPublicIP(ip net.IP) bool { |
| return true |
| } |
| +// guardDialControl blocks any connection whose *resolved* IP is not public, so a |
| +// server-side fetch can't be pointed at the server's own network (SSRF). Shared |
| +// by the unfurl fetcher and the Web Push sender — both dial attacker-influenced |
| +// hosts. Checking at dial time (not on the pre-resolution hostname) also defeats |
| +// DNS rebinding: every connection and redirect hop is re-validated. Honors |
| +// allowPrivatePreviewHosts so tests can reach a loopback httptest server. |
| +func guardDialControl(network, address string, _ syscall.RawConn) error { |
| + if allowPrivatePreviewHosts { |
| + return nil |
| + } |
| + host, _, err := net.SplitHostPort(address) |
| + if err != nil { |
| + return err |
| + } |
| + if !isPublicIP(net.ParseIP(host)) { |
| + return fmt.Errorf("blocked non-public address %s", address) |
| + } |
| + return nil |
| +} |
| + |
| // ogClient fetches preview pages. Its dialer checks the *resolved* IP of every |
| // connection, which also defeats DNS-rebinding (each hop is re-validated). |
| var ogClient = &http.Client{ |
| @@ -64,19 +84,7 @@ var ogClient = &http.Client{ |
| Transport: &http.Transport{ |
| DialContext: (&net.Dialer{ |
| Timeout: previewTimeout, |
| - Control: func(network, address string, _ syscall.RawConn) error { |
| - if allowPrivatePreviewHosts { |
| - return nil |
| - } |
| - host, _, err := net.SplitHostPort(address) |
| - if err != nil { |
| - return err |
| - } |
| - if !isPublicIP(net.ParseIP(host)) { |
| - return fmt.Errorf("blocked non-public address %s", address) |
| - } |
| - return nil |
| - }, |
| + Control: guardDialControl, |
| }).DialContext, |
| TLSHandshakeTimeout: previewTimeout, |
| ResponseHeaderTimeout: previewTimeout, |