Monorepo for Tangled
0

Configure Feed

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

knotmirror: apply ssrf protection on knotproxy

Vulnerability reported by @tolik518.tngl.sh

Co-authored-by: tolik518 <function@returnnull.de>
Signed-off-by: Seongmin Lee <git@boltless.me>

author
Seongmin Lee
co-author
tolik518
committer
Tangled
date (May 13, 2026, 11:24 AM +0300) commit c144db76 parent ba18ec20 change-id pzyontry
+54 -14
+1 -1
knotmirror/knotstream/knotstream.go
··· 24 24 return &KnotStream{ 25 25 logger: l, 26 26 db: db, 27 - slurper: NewKnotSlurper(l, db, cfg.Slurper), 27 + slurper: NewKnotSlurper(l, db, cfg), 28 28 } 29 29 } 30 30
+5 -3
knotmirror/knotstream/slurper.go
··· 25 25 logger *slog.Logger 26 26 db *sql.DB 27 27 cfg config.SlurperConfig 28 + ssrf bool 28 29 29 30 subsLk sync.Mutex 30 31 subs map[string]*subscription 31 32 } 32 33 33 - func NewKnotSlurper(l *slog.Logger, db *sql.DB, cfg config.SlurperConfig) *KnotSlurper { 34 + func NewKnotSlurper(l *slog.Logger, db *sql.DB, cfg *config.Config) *KnotSlurper { 34 35 return &KnotSlurper{ 35 36 logger: log.SubLogger(l, "slurper"), 36 37 db: db, 37 - cfg: cfg, 38 + cfg: cfg.Slurper, 39 + ssrf: cfg.KnotSSRF, 38 40 subs: make(map[string]*subscription), 39 41 } 40 42 } ··· 132 134 } 133 135 134 136 // if this isn't a localhost / private connection, then we should enable SSRF protections 135 - if !host.NoSSL { 137 + if !host.NoSSL || s.ssrf { 136 138 netDialer := ssrf.PublicOnlyDialer() 137 139 dialer.NetDialContext = netDialer.DialContext 138 140 }
+33
knotmirror/xrpc/proxy.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "errors" 5 6 "fmt" 6 7 "io" 7 8 "net/http" ··· 45 46 repoIdentifier string 46 47 } 47 48 49 + // validateKnotURL ensures a knot base URL is safe to proxy to. 50 + // It rejects URLs with path components, query strings, or fragments 51 + // that could be used for path injection. 52 + func validateKnotURL(raw string) (string, error) { 53 + u, err := url.Parse(raw) 54 + if err != nil { 55 + return "", fmt.Errorf("invalid knot URL: %w", err) 56 + } 57 + if u.Scheme != "http" && u.Scheme != "https" { 58 + return "", errors.New("knot URL must use http or https scheme") 59 + } 60 + if u.Path != "" && u.Path != "/" { 61 + return "", fmt.Errorf("knot URL must not contain a path: %q", raw) 62 + } 63 + if u.RawQuery != "" || u.Fragment != "" { 64 + return "", fmt.Errorf("knot URL must not contain query or fragment: %q", raw) 65 + } 66 + if u.User != nil { 67 + return "", fmt.Errorf("knot URL must not contain userinfo: %q", raw) 68 + } 69 + // Strip trailing slash for consistent formatting 70 + return strings.TrimRight(u.String(), "/"), nil 71 + } 72 + 48 73 func (x *Xrpc) resolveKnot(ctx context.Context, repoAt syntax.ATURI) (*knotInfo, error) { 49 74 repo, err := db.GetRepoByAtUri(ctx, x.db, repoAt) 50 75 if err == nil && repo != nil { ··· 60 85 knotURL = "http://" + knotURL 61 86 } 62 87 } 88 + } 89 + knotURL, err = validateKnotURL(knotURL) 90 + if err != nil { 91 + return nil, err 63 92 } 64 93 return &knotInfo{baseURL: knotURL, repoIdentifier: repo.RepoIdentifier()}, nil 65 94 } ··· 111 140 } 112 141 }() 113 142 143 + knotURL, err = validateKnotURL(knotURL) 144 + if err != nil { 145 + return nil, err 146 + } 114 147 return &knotInfo{ 115 148 baseURL: knotURL, 116 149 repoIdentifier: repoDid.String(),
+15 -10
knotmirror/xrpc/xrpc.go
··· 9 9 "time" 10 10 11 11 "github.com/bluesky-social/indigo/atproto/atclient" 12 + "github.com/bluesky-social/indigo/util/ssrf" 12 13 "github.com/go-chi/chi/v5" 13 14 "github.com/redis/go-redis/v9" 14 15 "tangled.org/core/api/tangled" ··· 30 31 } 31 32 32 33 func New(logger *slog.Logger, cfg *config.Config, db *sql.DB, rdb *redis.Client, resolver *idresolver.Resolver, ks *knotstream.KnotStream) *Xrpc { 34 + httpClient := &http.Client{ 35 + Timeout: 30 * time.Second, 36 + } 37 + if cfg.KnotSSRF { 38 + httpClient.Transport = ssrf.PublicOnlyTransport() 39 + } 33 40 return &Xrpc{ 34 - cfg: cfg, 35 - db: db, 36 - rdb: rdb, 37 - resolver: resolver, 38 - ks: ks, 39 - logger: log.SubLogger(logger, "xrpc"), 40 - httpClient: &http.Client{ 41 - Timeout: 30 * time.Second, 42 - }, 43 - inflight: newInflightTracker(), 41 + cfg: cfg, 42 + db: db, 43 + rdb: rdb, 44 + resolver: resolver, 45 + ks: ks, 46 + logger: log.SubLogger(logger, "xrpc"), 47 + httpClient: httpClient, 48 + inflight: newInflightTracker(), 44 49 } 45 50 } 46 51