Repository navigation
Expand file tree
/
Copy pathproxy.go
More file actions
508 lines (487 loc) · 18.5 KB
/
Copy pathproxy.go
File metadata and controls
508 lines (487 loc) · 18.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
// Package proxy implements the /api/proxy endpoint that forwards a
// single browser request to RealDebrid's API. Solves the CORS problem
// — RD's API doesn't return Access-Control-Allow-Origin headers, so
// a browser-only SPA can't talk to it directly.
//
// Security posture mirrors DMM's anticors.ts:
//
// - Hostname allowlist. The browser supplies a target URL via the
// `?url=` query param; the proxy refuses any host that isn't in
// allowedHosts. Without this an attacker could use the proxy as
// an SSRF springboard against internal services.
//
// - Authorization passthrough. The browser sets the Authorization
// header to `Bearer <user's RD token>`; the proxy forwards it
// to RD verbatim. The token is never logged, persisted, or
// touched server-side beyond byte-level forwarding.
//
// - No state. No session, no cookie, no in-memory token cache.
// The user's localStorage is the single source of truth; the
// proxy is purely a CORS bypass.
//
// Multi-replica safety: every request is fully self-contained — any
// replica can serve any request.
package proxy
import (
"bufio"
"bytes"
"context"
"crypto/tls"
"encoding/base64"
"encoding/json"
"fmt"
"hash/fnv"
"io"
"log/slog"
"math/rand"
"net"
"net/http"
"net/url"
"strings"
"time"
utls "github.com/refraction-networking/utls"
)
// allowedHosts is the set of hostnames the proxy will forward to.
// RD's two are present: `api.real-debrid.com` for the REST API and
// `api.real-debrid.com` (same host) for the OAuth device-code
// endpoints. Adding new hosts is a deliberate code change — never
// trust a client-supplied "I want to talk to X" without explicit
// allowlist entry.
var allowedHosts = map[string]bool{
"api.real-debrid.com": true,
}
// parseTLSFingerprint maps an env-var string to a utls ClientHelloID.
// Defaults to HelloRandomized when the value is empty or unknown.
//
// RD's WAF has demonstrably catalogued the well-known desktop browser
// uTLS fingerprints — Chrome_Auto, Chrome_120, Chrome_100,
// Firefox_120, Safari_16_0 all 451 on the CONNECT-tunneled path while
// iOS_14 and Randomized pass. Empirical A/B as of 2026-05-14. We
// default to Randomized so each connection gets a unique-enough
// ClientHello that RD can't match against a fixed pattern; an env
// var override lets us pivot quickly when RD inevitably adapts.
func parseTLSFingerprint(s string) utls.ClientHelloID {
switch strings.ToLower(strings.TrimSpace(s)) {
case "", "randomized", "random":
return utls.HelloRandomized
case "ios", "ios_14":
return utls.HelloIOS_14
case "chrome", "chrome_auto":
return utls.HelloChrome_Auto
case "chrome_120":
return utls.HelloChrome_120
case "chrome_100":
return utls.HelloChrome_100
case "firefox", "firefox_120":
return utls.HelloFirefox_120
case "safari", "safari_16":
return utls.HelloSafari_16_0
default:
return utls.HelloRandomized
}
}
// makeUTLSDialTLS returns a DialTLSContext for http.Transport that
// performs the TLS handshake using a uTLS-mimicked ClientHello
// instead of Go's distinctive crypto/tls fingerprint. RD's WAF
// distinguishes Go's TLS fingerprint from a real browser's — and
// has further fingerprinted all the obvious uTLS "look like a
// browser" profiles. We use HelloRandomized by default (each
// connection gets a different fingerprint, no fixed pattern to
// blocklist). Operator can pin a specific profile via the
// OUTBOUND_TLS_FINGERPRINT env var.
//
// If proxyURL is non-nil, the dialer tunnels through it: TCP-dial
// the proxy, send a CONNECT for the target host:port, then perform
// the uTLS handshake over the established tunnel. We do CONNECT
// ourselves rather than rely on http.Transport.Proxy because the
// standard transport's CONNECT path uses crypto/tls directly and
// bypasses DialTLSContext, so uTLS would never run.
//
// HTTP/2 is disabled at the Transport level (TLSNextProto empty)
// because Go's http2 package requires *tls.Conn, not utls.UConn.
// We also patch the ALPN extension to advertise only http/1.1.
// RD's REST API works fine over HTTP/1.1.
func makeUTLSDialTLS(proxyURL *url.URL, helloID utls.ClientHelloID) func(ctx context.Context, network, addr string) (net.Conn, error) {
return func(ctx context.Context, network, addr string) (net.Conn, error) {
var d net.Dialer
var rawConn net.Conn
var err error
if proxyURL == nil {
rawConn, err = d.DialContext(ctx, network, addr)
if err != nil {
return nil, err
}
} else {
// Dial the proxy.
proxyHost := proxyURL.Host
if proxyURL.Port() == "" {
if proxyURL.Scheme == "https" {
proxyHost += ":443"
} else {
proxyHost += ":80"
}
}
rawConn, err = d.DialContext(ctx, "tcp", proxyHost)
if err != nil {
return nil, err
}
// Build CONNECT request for the eventual target.
connectReq := fmt.Sprintf("CONNECT %s HTTP/1.1\r\nHost: %s\r\n", addr, addr)
if u := proxyURL.User; u != nil {
password, _ := u.Password()
auth := base64.StdEncoding.EncodeToString([]byte(u.Username() + ":" + password))
connectReq += "Proxy-Authorization: Basic " + auth + "\r\n"
}
connectReq += "\r\n"
if _, err := rawConn.Write([]byte(connectReq)); err != nil {
rawConn.Close()
return nil, err
}
br := bufio.NewReader(rawConn)
resp, err := http.ReadResponse(br, &http.Request{Method: "CONNECT"})
if err != nil {
rawConn.Close()
return nil, err
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
rawConn.Close()
return nil, fmt.Errorf("proxy CONNECT %s: %s", addr, resp.Status)
}
}
host, _, _ := net.SplitHostPort(addr)
uConn := utls.UClient(rawConn, &utls.Config{ServerName: host}, helloID)
if err := uConn.BuildHandshakeState(); err != nil {
rawConn.Close()
return nil, err
}
for _, ext := range uConn.Extensions {
if alpn, ok := ext.(*utls.ALPNExtension); ok {
alpn.AlpnProtocols = []string{"http/1.1"}
}
}
if err := uConn.HandshakeContext(ctx); err != nil {
rawConn.Close()
return nil, err
}
return uConn, nil
}
}
// upstreamTimeout caps each forwarded request. 30s gives RD's slower
// endpoints (/torrents pagination, /torrents/delete in bulk) room to
// respond while keeping a misbehaving upstream from pinning the
// goroutine indefinitely. The browser is doing its own
// rate-limited request stream, so this only fires on genuine slowness.
const upstreamTimeout = 30 * time.Second
// Handler is the http.Handler for /api/proxy. Constructed once at
// server init and reused for every request — every dependency is
// read-only.
type Handler struct {
log *slog.Logger
clients []*http.Client // 1 entry when no outbound proxies; N when configured
overrideUserAgent string // when non-empty, replaces the browser's UA on every outbound
}
// New constructs the proxy handler. outboundProxies is a list of
// upstream-proxy URLs (e.g. "http://user:pass@residential-rotator:8080");
// each is wired into its own http.Client and the handler picks one
// at random per request to spread the load across multiple egress
// IPs. RD rate-limits per source IP, so with all litterbox replicas
// going out from the same k8s egress, concurrent sign-ins hit 429
// quickly; outbound proxies dilute that. Empty/nil list = direct.
//
// User-Agent forwarding: by default the proxy forwards whatever UA
// the browser sent (the most honest representation — we're CORS-
// bypassing a browser request). overrideUserAgent, when non-empty,
// replaces it on every outbound request. Useful when RD's WAF
// extends from app-fingerprint matching ("litterbox/X.Y.Z" → 451) to
// broader UA filtering — operator can flip to "curl/8.7.1" or
// similar (StremThru's recent move) without a rebuild.
func New(log *slog.Logger, outboundProxies []string, overrideUserAgent string, tlsFingerprint string) *Handler {
clients := make([]*http.Client, 0, max(1, len(outboundProxies)))
helloID := parseTLSFingerprint(tlsFingerprint)
// Common transport options: HTTP/2 disabled because Go's http2 needs
// *tls.Conn and our DialTLSContext returns *utls.UConn. RD handles
// HTTP/1.1 fine.
newTransport := func(proxyURL *url.URL) *http.Transport {
return &http.Transport{
DialTLSContext: makeUTLSDialTLS(proxyURL, helloID),
ForceAttemptHTTP2: false,
TLSNextProto: map[string]func(string, *tls.Conn) http.RoundTripper{},
}
}
if len(outboundProxies) == 0 {
// Direct uTLS-enabled client.
clients = append(clients, &http.Client{Transport: newTransport(nil)})
} else {
for _, raw := range outboundProxies {
raw = strings.TrimSpace(raw)
if raw == "" {
continue
}
pu, err := url.Parse(raw)
if err != nil {
log.Warn("proxy: skipping invalid outbound proxy URL", "raw", raw, "err", err)
continue
}
// Each proxied client does its own CONNECT + uTLS handshake
// via makeUTLSDialTLS; we deliberately do NOT set
// Transport.Proxy because that path uses crypto/tls and
// bypasses DialTLSContext.
clients = append(clients, &http.Client{Transport: newTransport(pu)})
}
if len(clients) == 0 {
log.Warn("proxy: OUTBOUND_PROXIES set but no usable URLs; falling back to direct")
clients = append(clients, &http.Client{})
}
}
log.Info("proxy: outbound transport configured",
"client_count", len(clients),
"user_agent_override", overrideUserAgent != "",
"tls_fingerprint", helloID.Str())
return &Handler{log: log, clients: clients, overrideUserAgent: overrideUserAgent}
}
// pickClient returns the outbound client for this request. When an
// Authorization header is present, the choice is *deterministic*:
// hash(authHeader) % N. Same token → same egress, every time.
//
// Rationale: RD's anti-abuse layer flags tokens used from many IPs
// in a short window — looks like credential theft to them. Random
// per-request egress selection (the previous behavior) accelerated
// that flagging, because a single user's sign-in + dashboard would
// touch N different proxybase IPs per minute. Sticky-per-token
// gives RD a consistent IP per user-token, which is what they
// expect for normal traffic. Load still spreads because different
// users' tokens hash to different egresses.
//
// Anonymous requests (no Authorization header) fall back to random
// pick — there's no token to anchor the hash to and nothing to
// burn either.
func (h *Handler) pickClient(authHeader string) *http.Client {
if len(h.clients) == 1 {
return h.clients[0]
}
if authHeader == "" {
return h.clients[rand.Intn(len(h.clients))]
}
hash := fnv.New32a()
_, _ = hash.Write([]byte(authHeader))
return h.clients[hash.Sum32()%uint32(len(h.clients))]
}
// ServeHTTP forwards one browser request to RD.
//
// Request shape: the browser issues `<METHOD> /api/proxy?url=<encoded
// upstream URL>` with the desired Authorization header and (for
// POST/DELETE) a request body. The proxy:
//
// 1. Validates the target URL parses and its hostname is allowlisted.
// 2. Builds an upstream request with the same method, body, and
// headers (Authorization + Content-Type only — other headers
// are dropped to avoid passing browser-specific noise like
// Cookie / Referer).
// 3. Forwards. Copies the response status + body + a minimal set
// of headers back to the browser.
//
// Failures are surfaced as JSON-shaped 4xx/5xx with a short reason
// the frontend can render directly.
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
targetRaw := r.URL.Query().Get("url")
if targetRaw == "" {
http.Error(w, `{"error":"missing url parameter"}`, http.StatusBadRequest)
return
}
target, err := url.Parse(targetRaw)
if err != nil || target.Scheme != "https" || target.Host == "" {
http.Error(w, `{"error":"invalid target url (must be absolute https)"}`, http.StatusBadRequest)
return
}
if !allowedHosts[strings.ToLower(target.Host)] {
// Don't echo the hostname back — keeps the proxy from
// signaling probe attempts. Log it for the operator.
h.log.Warn("proxy: refused unlisted host", "host", target.Host, "remote", r.RemoteAddr)
http.Error(w, `{"error":"target host not allowed"}`, http.StatusForbidden)
return
}
ctx, cancel := context.WithTimeout(r.Context(), upstreamTimeout)
defer cancel()
// Buffer the request body upfront so we can replay it on retry.
// 16MB cap mirrors the response-side limit; RD's POST bodies are
// tiny (form-encoded auth, magnet add, etc).
var reqBody []byte
if r.Body != nil {
reqBody, _ = io.ReadAll(io.LimitReader(r.Body, 16<<20))
r.Body.Close()
}
// Retry only on transport errors (proxy hiccups, dropped TCP).
// Do NOT retry on HTTP 451: that's RD's anti-abuse signal, and
// retrying with a different egress IP makes the same token
// authenticate from N different IPs in seconds — which is
// exactly the credential-theft pattern RD's heuristic flags.
// We were burning user tokens within one sign-in attempt that
// way. Empirical confirmation: a single sign-in with retry=3
// took a freshly-regenerated RD API token from "200 OK from any
// IP" to "451 from every IP" in under 10 seconds.
maxAttempts := 1
if len(h.clients) > 1 {
maxAttempts = 3
}
authHeader := r.Header.Get("Authorization")
var resp *http.Response
for attempt := 1; attempt <= maxAttempts; attempt++ {
var bodyReader io.Reader
if len(reqBody) > 0 {
bodyReader = bytes.NewReader(reqBody)
}
upstream, buildErr := http.NewRequestWithContext(ctx, r.Method, target.String(), bodyReader)
if buildErr != nil {
h.log.Warn("proxy: build upstream request failed", "err", buildErr)
http.Error(w, `{"error":"upstream request build failed"}`, http.StatusInternalServerError)
return
}
// Forward only the headers RD actually needs. Cookie / Referer /
// Origin etc. would just confuse RD's auth path and leak browser
// state we don't need to expose.
if auth := r.Header.Get("Authorization"); auth != "" {
upstream.Header.Set("Authorization", auth)
}
if ct := r.Header.Get("Content-Type"); ct != "" {
upstream.Header.Set("Content-Type", ct)
}
// User-Agent: default is to forward the browser's verbatim (most
// honest representation — we're CORS-bypassing a browser request).
// If overrideUserAgent is set (OUTBOUND_USER_AGENT env), replace it
// on every outbound. Operator-rotatable hedge against RD's WAF
// extending to broader UA filtering.
switch {
case h.overrideUserAgent != "":
upstream.Header.Set("User-Agent", h.overrideUserAgent)
default:
if ua := r.Header.Get("User-Agent"); ua != "" {
upstream.Header.Set("User-Agent", ua)
}
}
resp, err = h.pickClient(authHeader).Do(upstream)
if attempt >= maxAttempts {
break
}
if err != nil {
h.log.Info("proxy: transport error, retrying with different egress",
"attempt", attempt, "host", target.Host, "path", target.Path, "err", err)
continue
}
break
}
if err != nil {
h.log.Warn("proxy: upstream call failed", "host", target.Host, "err", err)
http.Error(w, `{"error":"upstream call failed"}`, http.StatusBadGateway)
return
}
defer resp.Body.Close()
// Read the body before forwarding so we can both log it on
// non-2xx (small RD error envelopes) AND propagate it intact.
// Bounded at 16MB to defend against a misbehaving upstream
// returning a giant body. The double-buffer cost is irrelevant
// for RD payloads (all JSON, all small).
body, _ := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
// Log upstream non-success responses. Filter out the noisy-but-
// expected cases:
// - 404 anywhere (normal not-found; e.g. /torrents/info/{id}
// after a delete races with a poll)
// - 451 on /unrestrict/link (the documented infringing_file
// signal — fires for every filtered torrent during a deep
// probe, so it's high-volume + already exposed to the user
// via the discovery results)
// Everything else is interesting:
// - 451 on /oauth/v2/* or /rest/1.0/* = WAF/IP-reputation
// blocking us, not a user error
// - 401/403 = token problem, useful when debugging
// - 429 = rate-limited, useful for capacity planning
// - 5xx = upstream problem
// Path-only (no query) keeps tokens out of the log.
noise := resp.StatusCode == http.StatusNotFound ||
(resp.StatusCode == 451 && target.Path == "/rest/1.0/unrestrict/link")
if !noise && resp.StatusCode >= 400 {
snippet := body
if len(snippet) > 256 {
snippet = snippet[:256]
}
h.log.Warn("proxy: upstream non-2xx",
"host", target.Host,
"path", target.Path,
"method", r.Method,
"status", resp.StatusCode,
"body_preview", string(snippet))
}
// For certain API requests RD returns 451 and prepends an error
// JSON before the real response body:
// {"error":"permission_denied","error_code":9}{"id":...}
// Strip the prefix and return 200 when the second JSON token is a
// clean success (no "error" key) or an array. Genuine 451s from
// /unrestrict/link (error_code 35, infringing-file filter) contain
// only one JSON object and are left untouched. Belt-and-braces
// against RD's WAF — the UA-forward fix should mostly prevent the
// double-JSON case, but this handles the residual.
// Contributed by @andesco in PR #7.
statusCode := resp.StatusCode
if statusCode == 451 {
if real, ok := extractRealBody(body); ok {
body = real
statusCode = http.StatusOK
}
}
// Propagate the response.
for _, h := range []string{"Content-Type", "Content-Length", "Retry-After"} {
if v := resp.Header.Get(h); v != "" {
w.Header().Set(h, v)
}
}
// Content-Length may no longer match after stripping the WAF prefix.
if statusCode != resp.StatusCode {
w.Header().Del("Content-Length")
}
w.WriteHeader(statusCode)
_, _ = w.Write(body)
}
// extractRealBody detects RD's double-JSON pattern and returns the
// second token when it is a success object (no "error" key) or an
// array. Walks the first JSON object with brace counting + escape
// handling so it's robust to strings containing braces.
func extractRealBody(body []byte) ([]byte, bool) {
depth, inStr, escaped := 0, false, false
for i, b := range body {
switch {
case escaped:
escaped = false
case b == '\\' && inStr:
escaped = true
case b == '"':
inStr = !inStr
case inStr:
// inside a string literal — ignore braces
case b == '{':
depth++
case b == '}':
depth--
if depth == 0 {
rest := bytes.TrimSpace(body[i+1:])
if len(rest) == 0 {
return nil, false
}
if rest[0] == '[' {
return rest, true
}
if rest[0] == '{' {
var check map[string]json.RawMessage
if err := json.Unmarshal(rest, &check); err != nil {
return nil, false
}
if _, hasErr := check["error"]; hasErr {
return nil, false
}
return rest, true
}
return nil, false
}
}
}
return nil, false
}