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.
223 lines
6.8 KiB
Go
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
|
|
}
|