Skip to content

Commit 5533a78

Browse files
vintikzzzclaude
andcommitted
Reject requests whose client IP doesn't match the JWT remoteAddress
Abusers were sharing a single session across many IPs (download mirrors, scraper farms), evading the per-(session, path) limiter by spreading requests across distinct paths while driving seeder load. The JWT already carries remoteAddress from issuance — compare it to the request's X-Forwarded-For IP with a /24 (v4) or /64 (v6) mask to tolerate ISP-local and mobile-carrier churn. Fails open on unparseable claims and is gated behind ENFORCE_SESSION_IP for quick disable. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 99f0dae commit 5533a78

1 file changed

Lines changed: 66 additions & 15 deletions

File tree

‎services/web.go‎

Lines changed: 66 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -24,19 +24,20 @@ const (
2424
)
2525

2626
type Web struct {
27-
host string
28-
port int
29-
ln net.Listener
30-
r *Resolver
31-
pr *HTTPProxy
32-
parser *URLParser
33-
bucket *HybridBucketPool
34-
clickHouse *ClickHouse
35-
baseURL string
36-
claims *Claims
37-
ah *AccessHistory
38-
bandwidthLimit bool
39-
sl *SessionLimiter
27+
host string
28+
port int
29+
ln net.Listener
30+
r *Resolver
31+
pr *HTTPProxy
32+
parser *URLParser
33+
bucket *HybridBucketPool
34+
clickHouse *ClickHouse
35+
baseURL string
36+
claims *Claims
37+
ah *AccessHistory
38+
bandwidthLimit bool
39+
sl *SessionLimiter
40+
enforceSessionIP bool
4041
}
4142

4243
const (
@@ -45,6 +46,7 @@ const (
4546
torrentHTTPProxyHostFlag = "torrent-http-proxy-host"
4647
torrentHTTPProxyPortFlag = "torrent-http-proxy-port"
4748
useBandwidthLimitFlag = "use-bandwidth-limit"
49+
enforceSessionIPFlag = "enforce-session-ip"
4850
)
4951

5052
var (
@@ -90,8 +92,9 @@ func NewWeb(c *cli.Context, parser *URLParser, r *Resolver, pr *HTTPProxy, claim
9092
bucket: bp,
9193
clickHouse: ch,
9294
ah: ah,
93-
bandwidthLimit: c.Bool(useBandwidthLimitFlag),
94-
sl: sl,
95+
bandwidthLimit: c.Bool(useBandwidthLimitFlag),
96+
sl: sl,
97+
enforceSessionIP: c.Bool(enforceSessionIPFlag),
9598
}
9699
}
97100

@@ -125,9 +128,40 @@ func RegisterWebFlags(f []cli.Flag) []cli.Flag {
125128
Usage: "use bandwidth limit",
126129
EnvVar: "USE_BANDWIDTH_LIMIT",
127130
},
131+
cli.BoolTFlag{
132+
Name: enforceSessionIPFlag,
133+
Usage: "reject requests whose client IP doesn't match the remoteAddress claim in the JWT (normalized to /24 for v4, /64 for v6). Disable to unblock mobile users if false positives appear.",
134+
EnvVar: "ENFORCE_SESSION_IP",
135+
},
128136
)
129137
}
130138

139+
// sameSubnet reports whether two IP strings share the same subnet prefix
140+
// (/24 for IPv4, /64 for IPv6). Returns true when either side is unparseable
141+
// so we fail open rather than 429 a legitimate user on a malformed claim.
142+
func sameSubnet(a, b string) bool {
143+
ipA := parseClientIP(a)
144+
ipB := parseClientIP(b)
145+
if ipA == nil || ipB == nil {
146+
return true
147+
}
148+
if v4a, v4b := ipA.To4(), ipB.To4(); v4a != nil && v4b != nil {
149+
return v4a.Mask(net.CIDRMask(24, 32)).Equal(v4b.Mask(net.CIDRMask(24, 32)))
150+
}
151+
if ipA.To4() != nil || ipB.To4() != nil {
152+
return false // one is v4, other is v6
153+
}
154+
return ipA.Mask(net.CIDRMask(64, 128)).Equal(ipB.Mask(net.CIDRMask(64, 128)))
155+
}
156+
157+
func parseClientIP(s string) net.IP {
158+
s = strings.TrimSpace(s)
159+
if host, _, err := net.SplitHostPort(s); err == nil {
160+
s = host
161+
}
162+
return net.ParseIP(s)
163+
}
164+
131165
func (s *Web) getIP(r *http.Request) string {
132166
forwarded := r.Header.Get("X-FORWARDED-FOR")
133167
if forwarded != "" {
@@ -171,6 +205,23 @@ func (s *Web) proxyHTTP(w http.ResponseWriter, r *http.Request, src *Source, log
171205
sessionID = sid
172206
}
173207

208+
if s.enforceSessionIP && source == External && sessionID != "" {
209+
if bound, ok := claims["remoteAddress"].(string); ok && bound != "" {
210+
reqIP := s.getIP(r)
211+
if !sameSubnet(bound, reqIP) {
212+
logger.WithFields(logrus.Fields{
213+
"session_id": sessionID,
214+
"session_ip": bound,
215+
"request_ip": reqIP,
216+
"infohash": src.InfoHash,
217+
"path": src.Path,
218+
}).Warn("session IP mismatch")
219+
w.WriteHeader(http.StatusTooManyRequests)
220+
return
221+
}
222+
}
223+
}
224+
174225
if s.sl != nil && s.sl.Enabled() && source == External {
175226
release := s.sl.Acquire(sessionID, src.InfoHash, src.Path)
176227
if release == nil {

0 commit comments

Comments
 (0)