unarr/internal/agent/mirror_client.go
Deivid Soto 060a3e48db fix(security): CORS allowlist, URL scheme guard, state perms, ZIP slip, mirror docs
Phase 3 security audit follow-up. Medium and low-severity hardenings
plus a deferred-work plan for the cross-repo stream-token rollout.

Stream server CORS: replace the wildcard Access-Control-Allow-Origin
with an allowlist that echoes back only torrentclaw.com,
app.torrentclaw.com, the local Next dev port (3030 — matches the web
repo package.json) and any extras the operator adds via the new
downloads.cors_extra_origins TOML key. A Vary: Origin header is now
emitted whenever the request carries an Origin header so an
intermediate cache cannot serve a stale ACAO to a different origin.

URL scheme guard: openBrowser and OpenPlayer refuse any URL that is
not http(s). Combined with passing the URL after "--" wherever the
launched helper supports it (open, mpv, vlc, cvlc), this stops a
leading "-" from being parsed as a switch by the spawned process.

State file permissions: WriteState now writes 0o600 so the agent ID,
PID and counters cannot be enumerated by another local user on a
shared host. Matches the existing config file mode.

ZIP slip defense-in-depth: extractZip extracts the safety check into
safeZipPath, which canonicalises the entry name (normalising
backslashes to "/"), rejects "..", "../" prefix and "/../" interior
components, and verifies the final destination stays inside destDir
before opening any file.

Mirror fallback: documented the design for multi-provider
mirrors.json hosting in the comment block on DefaultStaticFallbackURLs
and added a follow-up note about signing it with the same ed25519
release key. The list is kept at one provider until the second host
is provisioned and added to torrentclaw-web's STATIC_FALLBACKS.

Deferred work: a new plan document Docs/plans/security-stream-token.md
covers the per-task stream token (Phase 2.2 of the original audit)
which requires coordinated web + CLI work and ships separately.
2026-05-15 18:48:59 +02:00

223 lines
6.8 KiB
Go

package agent
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// MirrorEntry mirrors the shape of /api/v1/mirrors items on the server.
type MirrorEntry struct {
URL string `json:"url"`
Label string `json:"label"`
Kind string `json:"kind"` // "clearnet" | "tor"
Primary bool `json:"primary"`
}
// MirrorChannel is an out-of-band status channel (Telegram, status page, etc.)
type MirrorChannel struct {
URL string `json:"url"`
Label string `json:"label"`
}
// MirrorsResponse is the JSON document served by /api/v1/mirrors and
// /api/mirrors.
type MirrorsResponse struct {
Revision int `json:"revision"`
Mirrors []MirrorEntry `json:"mirrors"`
Tor *MirrorEntry `json:"tor"`
Channels []MirrorChannel `json:"channels"`
UpdatedAt string `json:"updatedAt"`
}
// DefaultStaticFallbackURLs lists off-domain JSON copies of the mirror list.
// Hard-coded here (not loaded from config) because the whole point is to
// have something to consult when config-driven URLs all fail.
//
// Today there is one provider (GitHub Pages). The slice is intentionally
// shaped to take more — a second independent host (Cloudflare Pages,
// IPFS-Fleek, etc.) should be added as soon as it is provisioned. Keep
// any addition in sync with `STATIC_FALLBACKS` in
// `torrentclaw-web/src/lib/mirrors-config.ts` and `Docs/plans/security-stream-token.md`.
//
// Future hardening: sign mirrors.json with the same ed25519 release key
// (or a sibling) so a hijack of any single static host cannot serve a
// malicious mirror list. Today the only signal is "agreement between
// independent providers" via cross-checking, which we leave to the
// operator.
var DefaultStaticFallbackURLs = []string{
"https://torrentclaw.github.io/mirrors/mirrors.json",
}
// FetchMirrorsWithFallback pulls the mirror list using FetchMirrors against
// `candidates` first; if every candidate fails, it falls back to the static
// JSON copies on off-domain hosts (GitHub Pages, Cloudflare Pages, …).
//
// This is the function `unarr mirrors update` should call when it wants the
// strongest "give me a working mirror list no matter what" guarantee.
func FetchMirrorsWithFallback(ctx context.Context, candidates []string, userAgent string) (*MirrorsResponse, error) {
resp, err := FetchMirrors(ctx, candidates, userAgent)
if err == nil {
return resp, nil
}
if len(DefaultStaticFallbackURLs) == 0 {
return nil, err
}
// Try the static JSON files directly. They follow the same wire shape so
// we can reuse the same parser — but the URLs already include the JSON
// suffix so we hit them with `fetchMirrorsJSON` instead of FetchMirrors
// (which appends /api/v1/mirrors).
staticResp, staticErr := fetchMirrorsJSON(ctx, DefaultStaticFallbackURLs, userAgent)
if staticErr == nil {
return staticResp, nil
}
return nil, fmt.Errorf("primary failed (%v) and static fallback failed (%v)", err, staticErr)
}
// fetchMirrorsJSON pulls a MirrorsResponse from already-fully-qualified URLs
// (e.g. https://torrentclaw.github.io/mirrors/mirrors.json). Each candidate
// is tried in order; the first success wins.
func fetchMirrorsJSON(ctx context.Context, urls []string, userAgent string) (*MirrorsResponse, error) {
if len(urls) == 0 {
return nil, fmt.Errorf("no static fallback URLs configured")
}
hc := &http.Client{Timeout: 15 * time.Second}
var lastErr error
for _, url := range urls {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
lastErr = err
continue
}
if userAgent != "" {
req.Header.Set("User-Agent", userAgent)
}
req.Header.Set("Accept", "application/json")
resp, err := hc.Do(req)
if err != nil {
lastErr = err
continue
}
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
if readErr != nil {
lastErr = readErr
continue
}
if resp.StatusCode >= 400 {
lastErr = fmt.Errorf("%s returned HTTP %d", url, resp.StatusCode)
continue
}
var out MirrorsResponse
if err := json.Unmarshal(body, &out); err != nil {
lastErr = fmt.Errorf("%s: invalid JSON: %w", url, err)
continue
}
if len(out.Mirrors) == 0 {
lastErr = fmt.Errorf("%s returned empty mirror list", url)
continue
}
return &out, nil
}
if lastErr == nil {
lastErr = fmt.Errorf("no reachable static fallback")
}
return nil, lastErr
}
// FetchMirrors pulls the latest mirror list from the server.
//
// The endpoint is intentionally public and unauthenticated: the whole point
// of mirror discovery is that it must work even when the user's API key
// is invalid, expired, or the auth path is unreachable. The function tries
// each candidate base URL in order so a takedown of the primary doesn't
// also kill mirror discovery.
func FetchMirrors(ctx context.Context, candidates []string, userAgent string) (*MirrorsResponse, error) {
if len(candidates) == 0 {
return nil, fmt.Errorf("no mirror discovery URLs configured")
}
hc := &http.Client{Timeout: 15 * time.Second}
var lastErr error
for _, base := range candidates {
if base == "" {
continue
}
url := base + "/api/v1/mirrors"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
lastErr = err
continue
}
if userAgent != "" {
req.Header.Set("User-Agent", userAgent)
}
req.Header.Set("Accept", "application/json")
resp, err := hc.Do(req)
if err != nil {
lastErr = err
continue
}
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
if readErr != nil {
lastErr = readErr
continue
}
if resp.StatusCode >= 400 {
lastErr = fmt.Errorf("%s returned HTTP %d", base, resp.StatusCode)
continue
}
var out MirrorsResponse
if err := json.Unmarshal(body, &out); err != nil {
lastErr = fmt.Errorf("%s: invalid JSON: %w", base, err)
continue
}
if len(out.Mirrors) == 0 {
lastErr = fmt.Errorf("%s returned empty mirror list", base)
continue
}
return &out, nil
}
if lastErr == nil {
lastErr = fmt.Errorf("no reachable mirror discovery endpoint")
}
return nil, fmt.Errorf("fetch mirrors: %w", lastErr)
}
// ToConfig splits a MirrorsResponse into (primary, extras) suitable for
// rebuilding a MirrorPool or persisting back into config.toml.
//
// The "primary" returned here is whichever entry has primary=true. If none
// are flagged, the first one wins.
func (m *MirrorsResponse) ToConfig() (primary string, extras []string) {
if m == nil {
return "", nil
}
var picked *MirrorEntry
for i := range m.Mirrors {
if m.Mirrors[i].Primary {
picked = &m.Mirrors[i]
break
}
}
if picked == nil && len(m.Mirrors) > 0 {
picked = &m.Mirrors[0]
}
if picked != nil {
primary = picked.URL
}
for _, e := range m.Mirrors {
if e.URL == primary {
continue
}
extras = append(extras, e.URL)
}
return primary, extras
}