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 6, 2026, 7:01 PM +0300) df9832eb 7f67a8bb

+109 -2
+17 -2
appview/serververify/verify.go
··· 4 4 "context" 5 5 "errors" 6 6 "fmt" 7 + "net/http" 8 + "time" 7 9 10 + "github.com/bluesky-social/indigo/util/ssrf" 8 11 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 9 12 "tangled.org/core/api/tangled" 10 13 "tangled.org/core/appview/db" ··· 16 19 var ( 17 20 FetchError = errors.New("failed to fetch owner") 18 21 ) 22 + 23 + const verifyTimeout = 10 * time.Second 19 24 20 25 // fetchOwner fetches the owner DID from a server's /owner endpoint 21 26 func fetchOwner(ctx context.Context, domain string, dev bool) (string, error) { ··· 27 32 host := fmt.Sprintf("%s://%s", scheme, domain) 28 33 xrpcc := &indigoxrpc.Client{ 29 34 Host: host, 35 + Client: &http.Client{ 36 + Timeout: verifyTimeout, 37 + Transport: ssrf.PublicOnlyTransport(), 38 + }, 30 39 } 31 40 32 41 res, err := tangled.Owner(ctx, xrpcc) 33 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 34 - return "", xrpcerr 42 + if err != nil { 43 + var xrpcerr *indigoxrpc.Error 44 + if !errors.As(err, &xrpcerr) { 45 + return "", err 46 + } 47 + if handled := xrpcclient.HandleXrpcErr(err); handled != nil { 48 + return "", handled 49 + } 35 50 } 36 51 37 52 return res.Owner, nil
+92
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 TestRunVerificationRejectsNonPublicDestinationsInDev(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, true) 54 + elapsed := time.Since(started) 55 + 56 + if err == nil { 57 + t.Fatalf("RunVerification(%q, dev=true) succeeded; non-public destinations must be refused", tc.domain) 58 + } 59 + if elapsed > 250*time.Millisecond { 60 + t.Fatalf("RunVerification(%q, dev=true) 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=true) 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 localOwnerEndpoint(t *testing.T, host string) (string, *atomic.Int32) { 70 + t.Helper() 71 + 72 + ln, err := net.Listen("tcp", net.JoinHostPort(host, "0")) 73 + if err != nil { 74 + t.Fatalf("listen on %s: %v", host, err) 75 + } 76 + 77 + var hits atomic.Int32 78 + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 79 + hits.Add(1) 80 + if r.URL.Path != "/xrpc/sh.tangled.owner" { 81 + http.NotFound(w, r) 82 + return 83 + } 84 + w.Header().Set("Content-Type", "application/json") 85 + fmt.Fprintf(w, `{"owner":%q}`, ssrfExpectedOwner) 86 + })) 87 + server.Listener = ln 88 + server.Start() 89 + t.Cleanup(server.Close) 90 + 91 + return ln.Addr().String(), &hits 92 + }