eleven / ocho Public

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>
Paul Campbell e5f8792c3b6bb9ab918bef5cb308a8b907122cb4 parent a75d6bd
4 files changed, +171 −13
  • main.go +7 −0
  • push.go +55 −0
  • push_ssrf_test.go +88 −0
  • unfurl.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,