Monorepo for Tangled
0

Configure Feed

Select the types of activity you want to include in your feed.

appview/serververify: use SSRF-safe transport like repoverify

Signed-off-by: dawn <dawn@tangled.org>

dawn (Jul 9, 2026, 5:39 PM +0300) 564a54bb 7f67a8bb

+154 -2
+46 -2
appview/serververify/verify.go
··· 4 4 "context" 5 5 "errors" 6 6 "fmt" 7 + "net" 8 + "net/http" 9 + "syscall" 10 + "time" 7 11 8 12 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 9 13 "tangled.org/core/api/tangled" ··· 16 20 var ( 17 21 FetchError = errors.New("failed to fetch owner") 18 22 ) 23 + 24 + const verifyTimeout = 10 * time.Second 19 25 20 26 // fetchOwner fetches the owner DID from a server's /owner endpoint 21 27 func fetchOwner(ctx context.Context, domain string, dev bool) (string, error) { ··· 25 31 } 26 32 27 33 host := fmt.Sprintf("%s://%s", scheme, domain) 34 + transport := &http.Transport{ 35 + DialContext: safeDialer(dev).DialContext, 36 + } 28 37 xrpcc := &indigoxrpc.Client{ 29 38 Host: host, 39 + Client: &http.Client{ 40 + Timeout: verifyTimeout, 41 + Transport: transport, 42 + }, 30 43 } 31 44 32 45 res, err := tangled.Owner(ctx, xrpcc) 33 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 34 - return "", xrpcerr 46 + if err != nil { 47 + var xrpcerr *indigoxrpc.Error 48 + if !errors.As(err, &xrpcerr) { 49 + return "", err 50 + } 51 + if handled := xrpcclient.HandleXrpcErr(err); handled != nil { 52 + return "", handled 53 + } 35 54 } 36 55 37 56 return res.Owner, nil ··· 157 176 158 177 return nil 159 178 } 179 + func safeDialer(dev bool) *net.Dialer { 180 + d := &net.Dialer{ 181 + Timeout: 5 * time.Second, 182 + KeepAlive: 30 * time.Second, 183 + } 184 + if dev { 185 + return d 186 + } 187 + d.Control = func(network, address string, _ syscall.RawConn) error { 188 + host, _, err := net.SplitHostPort(address) 189 + if err != nil { 190 + return fmt.Errorf("invalid dial address %q: %w", address, err) 191 + } 192 + ip := net.ParseIP(host) 193 + if ip == nil { 194 + return fmt.Errorf("dial address %q did not resolve to IP", address) 195 + } 196 + if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || 197 + ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified() { 198 + return fmt.Errorf("refusing to dial %s: reserved or private address", ip) 199 + } 200 + return nil 201 + } 202 + return d 203 + }
+108
appview/serververify/verify_test.go
··· 1 + package serververify 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "net" 7 + "net/http" 8 + "net/http/httptest" 9 + "sync/atomic" 10 + "testing" 11 + "time" 12 + ) 13 + 14 + const ssrfExpectedOwner = "did:plc:ssrfguardexpectedowner" 15 + 16 + func TestRunVerificationRejectsNonPublicDestinationsInProd(t *testing.T) { 17 + loopbackDomain, loopbackHits := localOwnerEndpoint(t, "127.0.0.1") 18 + 19 + cases := []struct { 20 + name string 21 + domain string 22 + hits *atomic.Int32 23 + }{ 24 + { 25 + name: "loopback address with a real owner endpoint", 26 + domain: loopbackDomain, 27 + hits: loopbackHits, 28 + }, 29 + { 30 + name: "private address", 31 + domain: "10.0.0.1:80", 32 + }, 33 + { 34 + name: "link-local metadata address", 35 + domain: "169.254.169.254:80", 36 + }, 37 + { 38 + name: "reserved unspecified address", 39 + domain: "0.0.0.0:80", 40 + }, 41 + } 42 + 43 + for _, tc := range cases { 44 + t.Run(tc.name, func(t *testing.T) { 45 + if tc.hits != nil { 46 + tc.hits.Store(0) 47 + } 48 + 49 + ctx, cancel := context.WithTimeout(context.Background(), 750*time.Millisecond) 50 + defer cancel() 51 + 52 + started := time.Now() 53 + err := RunVerification(ctx, tc.domain, ssrfExpectedOwner, false) 54 + elapsed := time.Since(started) 55 + 56 + if err == nil { 57 + t.Fatalf("RunVerification(%q, dev=false) succeeded; non-public destinations must be refused", tc.domain) 58 + } 59 + if elapsed > 250*time.Millisecond { 60 + t.Fatalf("RunVerification(%q, dev=false) took %s; want an immediate SSRF refusal, not network IO until timeout", tc.domain, elapsed) 61 + } 62 + if tc.hits != nil && tc.hits.Load() != 0 { 63 + 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()) 64 + } 65 + }) 66 + } 67 + } 68 + 69 + func TestRunVerificationAllowsNonPublicDestinationsInDev(t *testing.T) { 70 + loopbackDomain, loopbackHits := localOwnerEndpoint(t, "127.0.0.1") 71 + 72 + ctx, cancel := context.WithTimeout(context.Background(), 750*time.Millisecond) 73 + defer cancel() 74 + 75 + err := RunVerification(ctx, loopbackDomain, ssrfExpectedOwner, true) 76 + if err != nil { 77 + t.Fatalf("RunVerification(%q, dev=true) failed: %v", loopbackDomain, err) 78 + } 79 + 80 + if loopbackHits.Load() != 1 { 81 + t.Fatalf("RunVerification(%q, dev=true) did not reach owner endpoint", loopbackDomain) 82 + } 83 + } 84 + 85 + func localOwnerEndpoint(t *testing.T, host string) (string, *atomic.Int32) { 86 + t.Helper() 87 + 88 + ln, err := net.Listen("tcp", net.JoinHostPort(host, "0")) 89 + if err != nil { 90 + t.Fatalf("listen on %s: %v", host, err) 91 + } 92 + 93 + var hits atomic.Int32 94 + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 95 + hits.Add(1) 96 + if r.URL.Path != "/xrpc/sh.tangled.owner" { 97 + http.NotFound(w, r) 98 + return 99 + } 100 + w.Header().Set("Content-Type", "application/json") 101 + fmt.Fprintf(w, `{"owner":%q}`, ssrfExpectedOwner) 102 + })) 103 + server.Listener = ln 104 + server.Start() 105 + t.Cleanup(server.Close) 106 + 107 + return ln.Addr().String(), &hits 108 + }