diff --git a/appview/serververify/verify.go b/appview/serververify/verify.go --- a/appview/serververify/verify.go +++ b/appview/serververify/verify.go @@ -4,6 +4,10 @@ "context" "errors" "fmt" + "net" + "net/http" + "syscall" + "time" indigoxrpc "github.com/bluesky-social/indigo/xrpc" "tangled.org/core/api/tangled" @@ -17,6 +21,8 @@ FetchError = errors.New("failed to fetch owner") ) +const verifyTimeout = 10 * time.Second + // fetchOwner fetches the owner DID from a server's /owner endpoint func fetchOwner(ctx context.Context, domain string, dev bool) (string, error) { scheme := "https" @@ -25,13 +31,26 @@ } host := fmt.Sprintf("%s://%s", scheme, domain) + transport := &http.Transport{ + DialContext: safeDialer(dev).DialContext, + } xrpcc := &indigoxrpc.Client{ Host: host, + Client: &http.Client{ + Timeout: verifyTimeout, + Transport: transport, + }, } res, err := tangled.Owner(ctx, xrpcc) - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { - return "", xrpcerr + if err != nil { + var xrpcerr *indigoxrpc.Error + if !errors.As(err, &xrpcerr) { + return "", err + } + if handled := xrpcclient.HandleXrpcErr(err); handled != nil { + return "", handled + } } return res.Owner, nil @@ -156,4 +175,29 @@ committed = true return nil +} +func safeDialer(dev bool) *net.Dialer { + d := &net.Dialer{ + Timeout: 5 * time.Second, + KeepAlive: 30 * time.Second, + } + if dev { + return d + } + d.Control = func(network, address string, _ syscall.RawConn) error { + host, _, err := net.SplitHostPort(address) + if err != nil { + return fmt.Errorf("invalid dial address %q: %w", address, err) + } + ip := net.ParseIP(host) + if ip == nil { + return fmt.Errorf("dial address %q did not resolve to IP", address) + } + if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || + ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified() { + return fmt.Errorf("refusing to dial %s: reserved or private address", ip) + } + return nil + } + return d } diff --git a/appview/serververify/verify_test.go b/appview/serververify/verify_test.go new file mode 100644 --- /dev/null +++ b/appview/serververify/verify_test.go @@ -0,0 +1,108 @@ +package serververify + +import ( + "context" + "fmt" + "net" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" +) + +const ssrfExpectedOwner = "did:plc:ssrfguardexpectedowner" + +func TestRunVerificationRejectsNonPublicDestinationsInProd(t *testing.T) { + loopbackDomain, loopbackHits := localOwnerEndpoint(t, "127.0.0.1") + + cases := []struct { + name string + domain string + hits *atomic.Int32 + }{ + { + name: "loopback address with a real owner endpoint", + domain: loopbackDomain, + hits: loopbackHits, + }, + { + name: "private address", + domain: "10.0.0.1:80", + }, + { + name: "link-local metadata address", + domain: "169.254.169.254:80", + }, + { + name: "reserved unspecified address", + domain: "0.0.0.0:80", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if tc.hits != nil { + tc.hits.Store(0) + } + + ctx, cancel := context.WithTimeout(context.Background(), 750*time.Millisecond) + defer cancel() + + started := time.Now() + err := RunVerification(ctx, tc.domain, ssrfExpectedOwner, false) + elapsed := time.Since(started) + + if err == nil { + t.Fatalf("RunVerification(%q, dev=false) succeeded; non-public destinations must be refused", tc.domain) + } + if elapsed > 250*time.Millisecond { + t.Fatalf("RunVerification(%q, dev=false) took %s; want an immediate SSRF refusal, not network IO until timeout", tc.domain, elapsed) + } + if tc.hits != nil && tc.hits.Load() != 0 { + t.Fatalf("RunVerification(%q, dev=false) reached the owner endpoint %d time(s); guard must refuse before normal network IO", tc.domain, tc.hits.Load()) + } + }) + } +} + +func TestRunVerificationAllowsNonPublicDestinationsInDev(t *testing.T) { + loopbackDomain, loopbackHits := localOwnerEndpoint(t, "127.0.0.1") + + ctx, cancel := context.WithTimeout(context.Background(), 750*time.Millisecond) + defer cancel() + + err := RunVerification(ctx, loopbackDomain, ssrfExpectedOwner, true) + if err != nil { + t.Fatalf("RunVerification(%q, dev=true) failed: %v", loopbackDomain, err) + } + + if loopbackHits.Load() != 1 { + t.Fatalf("RunVerification(%q, dev=true) did not reach owner endpoint", loopbackDomain) + } +} + +func localOwnerEndpoint(t *testing.T, host string) (string, *atomic.Int32) { + t.Helper() + + ln, err := net.Listen("tcp", net.JoinHostPort(host, "0")) + if err != nil { + t.Fatalf("listen on %s: %v", host, err) + } + + var hits atomic.Int32 + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + if r.URL.Path != "/xrpc/sh.tangled.owner" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"owner":%q}`, ssrfExpectedOwner) + })) + server.Listener = ln + server.Start() + t.Cleanup(server.Close) + + return ln.Addr().String(), &hits +}