Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
a8aea3c
chore: ignore local worktrees
jahvari Aug 19, 2026
628f51f
security: classify outbound proxy destinations
jahvari Aug 19, 2026
8acd5ac
security: pin validated proxy DNS answers
jahvari Aug 19, 2026
c11b562
security: protect proxy policy settings
jahvari Aug 19, 2026
714af82
security: harden proxy dispatch and streaming
jahvari Aug 19, 2026
b03feb4
test: cover proxy SSRF protections end to end
jahvari Aug 19, 2026
7b43bc7
docs: expose secure proxy source controls
jahvari Aug 19, 2026
cc18dbd
security: close proxy SSRF audit gaps
jahvari Aug 19, 2026
7d96b6e
security: enforce raw proxy input limit
jahvari Aug 19, 2026
22897cd
security: preserve obsolete IPv6 wrapper denials
jahvari Aug 19, 2026
9c26067
security: preserve native IPv6 deny precedence
jahvari Aug 19, 2026
ab136d3
security: close proxy audit validation gaps
jahvari Aug 20, 2026
9e39812
security: parse proxy targets from raw request URIs
jahvari Aug 20, 2026
75b97a0
security: isolate proxy responses and routing headers
jahvari Aug 20, 2026
11fc81d
security: enforce proxy redirect and cache boundaries
jahvari Aug 20, 2026
2280e58
security: preserve safe HLS proxy options
jahvari Aug 20, 2026
2488eaa
security: enforce per-peer proxy admission
jahvari Aug 20, 2026
325fc73
security: reclaim stalled proxy producers
jahvari Aug 20, 2026
a8a9c7e
security: close proxy audit gaps
jahvari Aug 21, 2026
74c5227
security: preserve proxy settings integrity
jahvari Aug 21, 2026
29fa3ce
security: bound proxy playlist lifecycles
jahvari Aug 21, 2026
64bc84e
security: complete proxy destination loop defenses
jahvari Aug 21, 2026
bf3b1c1
security: persist redacted proxy request traces
jahvari Aug 21, 2026
1546dad
test: close proxy security delivery gaps
jahvari Aug 21, 2026
0a4d362
test: serialize embedded proxy servers
jahvari Aug 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@
/vcpkg
/vcpkg_installed
/memory/
/.worktrees/
err.txt
/System.Collections.Hashtable.Root/
5 changes: 5 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ Stream Server is a **fully open-source** replacement for Stremio's proprietary `
- **🌐 Network Info**: `/network-info` endpoint for interface discovery
- **💓 Heartbeat**: `/heartbeat` for health checks
- **⚙️ Settings**: Runtime-configurable via `/settings`
- **🔒 BitTorrent Privacy Controls**: DHT, PeX, LSD, encryption, interface binding, ports, and proxy settings. See [BitTorrent Settings](docs/bittorrent-settings.md).
- **🔒 Privacy Controls**: Safe `/proxy` network-source defaults plus BitTorrent DHT, PeX, LSD, encryption, interface binding, ports, and proxy settings. See [Network Source Security](docs/network-source-security.md) and [BitTorrent Settings](docs/bittorrent-settings.md).

---

Expand Down
205 changes: 205 additions & 0 deletions docs/network-source-security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
# Network source security

Stream Server protects the `/proxy` endpoint from server-side request forgery (SSRF). By default,
`/proxy` can contact public HTTP and HTTPS destinations only, and HTTPS certificates must be valid.
These defaults prevent a webpage or LAN client that can reach Stream Server from using it to probe
services on your computer, local network, or cloud metadata endpoints.

> **Important:** both security exceptions described below are global policies for every `/proxy`
> request while enabled. They are not per-site allowlists. Any browser or LAN client that can reach
> the Stream Server listener may ask `/proxy` to contact an otherwise eligible private destination.
> Enable an exception only when required, restrict listener access with host firewall and network
> controls, and disable it afterward. Stream Server does not automatically add or change Windows
> Firewall rules.

## Scope of this protection

This policy applies only to `/proxy` in this release. The same destination validator does not yet
protect subtitle downloads, archives or local paths, FTP/curl inputs, NZB/NNTP, non-proxy HLS,
casting or FFmpeg inputs, remote torrent or tracker inputs, BitTorrent-backend fetches, or updater
inputs. Treat those as separate trust boundaries until later security work covers them.

`BitTorrent SSRF mitigation` is also separate. It maps to `btSsrfMitigation`, remains enabled by
default, and controls libtorrent behavior rather than `/proxy`.

## Configure the options in the settings app

Open **Settings > Privacy**. Two protected controls are available:

- **Allow private/LAN proxy sources** lets every `/proxy` request reach eligible loopback, private,
carrier-grade NAT, IPv6 ULA, IPv4 link-local, and directly connected sources. Known metadata and
other always-blocked addresses remain denied.
- **Allow invalid proxy TLS certificates** disables certificate verification for every `/proxy`
request. Enable it only when a required source uses a certificate you have independently trusted.

The standalone settings app enables these controls only when it connects to an IP-literal loopback
address and can read the local `settings-control.token` file. The embedded tray settings window is
trusted directly. A remote settings connection can still read settings and change ordinary options,
but cannot change either protected option.

The invalid-certificate option does not broaden the address policy. A self-signed private source
requires both exceptions. Prefer a valid certificate whenever possible.

## Configure the local HTTP API

Protected changes require all of the following:

1. Connect directly from loopback (`127.0.0.1` or `::1`).
2. Read the per-install token from the configuration directory without printing it.
3. Send it in `x-stream-server-settings-token`.
4. Send JSON booleans for the protected options.

On Windows PowerShell:

```powershell
$tokenPath = Join-Path ([Environment]::GetFolderPath('ApplicationData')) 'stremio-server\settings-control.token'
$settingsToken = (Get-Content -Raw -LiteralPath $tokenPath).TrimEnd("`r", "`n")
$headers = @{ 'x-stream-server-settings-token' = $settingsToken }
$body = @{ allowPrivateNetworkSources = $true; allowInvalidProxyTlsCertificates = $false } | ConvertTo-Json -Compress
Invoke-RestMethod -Method Post -Uri 'http://127.0.0.1:11470/settings' -Headers $headers -ContentType 'application/json' -Body $body
Remove-Variable settingsToken, headers, body
```

On Linux with `curl`:

```sh
token_file="${XDG_CONFIG_HOME:-$HOME/.config}/stremio-server/settings-control.token"
settings_token="$(tr -d '\r\n' < "$token_file")"
curl --fail-with-body --request POST 'http://127.0.0.1:11470/settings' \
--header "x-stream-server-settings-token: ${settings_token}" \
--header 'content-type: application/json' \
--data '{"allowPrivateNetworkSources":true,"allowInvalidProxyTlsCertificates":false}'
unset settings_token
```

On macOS with `curl` (the quoted path normally contains a space):

```sh
token_file="$HOME/Library/Application Support/stremio-server/settings-control.token"
settings_token="$(tr -d '\r\n' < "$token_file")"
curl --fail-with-body --request POST 'http://127.0.0.1:11470/settings' \
--header "x-stream-server-settings-token: ${settings_token}" \
--header 'content-type: application/json' \
--data '{"allowPrivateNetworkSources":true,"allowInvalidProxyTlsCertificates":false}'
unset settings_token
```

The token is not returned by the settings API or included in diagnostics exports. Treat the token
file as a local secret; do not paste it into logs, issue reports, or configuration files.

## Configure files or environment variables

The equivalent `settings.json` keys are:

```json
{
"allowPrivateNetworkSources": false,
"allowInvalidProxyTlsCertificates": false
}
```

Stop Stream Server before editing `settings.json`, then restart it. The server also accepts these
environment variables:

- `STREMIO_ALLOW_PRIVATE_NETWORK_SOURCES`
- `STREMIO_ALLOW_INVALID_PROXY_TLS_CERTIFICATES`

Accepted values are `1`, `true`, `yes`, or `on`, and `0`, `false`, `no`, or `off`, without leading
or trailing whitespace and case-insensitively. Environment values override the persisted file at
startup. A runtime GUI/API change can affect the current process, but the environment value wins
again after the next restart. Environment-only values are not copied into `settings.json` by
ordinary settings changes, tracker-cache updates, or background saves; removing the environment
variable therefore restores the persisted value on the next restart.

## Destination policy

| Destination class | Default | With private/LAN opt-in |
| --- | --- | --- |
| Public HTTP/HTTPS address | Allowed | Allowed |
| Loopback and private address | Blocked | Allowed |
| CGNAT, IPv6 ULA, IPv4 link-local, and current connected network | Blocked | Allowed |
| Stream Server's registered HTTP/HTTPS listeners | Blocked | Blocked |
| Known cloud, container, and platform metadata addresses | Blocked | Blocked |
| Unspecified, multicast, broadcast, documentation, benchmark, reserved, or future-use address | Blocked | Blocked |

Every DNS answer must pass the policy; one unsafe answer blocks the destination. Validated socket
addresses are pinned into a fresh outbound client that ignores system HTTP proxies. Every redirect
is resolved, revalidated, and pinned again, and HTTPS-to-HTTP downgrades are blocked.

IPv4 link-local sources require the private/LAN exception, but known metadata addresses remain
blocked even with that exception. Resolver-supplied IPv6 link-local addresses require a nonzero
interface scope and retain that scope when pinned. Scoped IPv6 URL literals are not supported.
Meaningless scope and flow identifiers on non-link-local IPv6 addresses are normalized away.

The only NAT64 prefix interpreted without network discovery is the well-known `64:ff9b::/96`
prefix. Network-specific prefixes are accepted only after strict discovery through the absolute DNS
name `ipv4only.arpa.` and recognition of both required `192.0.0.170` and `192.0.0.171` embeddings.
The full `64:ff9b:1::/48` reservation is not treated as an embedding rule without that discovery.
Successful and failed discovery results are cached briefly and bound to the current per-address
network-interface identity; an interface or address assignment change invalidates the cache. A
global or eligible ULA IPv6 destination that requires discovery fails closed while discovery is
unavailable. The well-known prefix remains independently classifiable.

Exact current interface addresses and all registered listener sockets are checked in their native,
IPv4-mapped, and discovered NAT64 forms. Applications that call `build_router` but serve the router
on additional sockets must instead call `build_router_with_listeners` and provide every actual
listener address; otherwise the validator cannot identify those caller-owned sockets.

Each server runtime creates a random sensitive hop marker and overwrites that internal header on
every outbound proxy hop. A matching marker returning to the application is rejected before `/proxy`
or non-proxy route handlers run. An outer CORS `OPTIONS` preflight remains an empty local response: it
does not dispatch upstream or consume proxy capacity. A reverse proxy that strips the hop marker, or
a separately constructed router with an independent runtime marker, remains a loop risk unless the
registered listener identity also blocks it.

## Capacity and timeout limits

- Active proxy requests: 64 globally and 16 per normalized client address.
- Playlist transformations: 8 globally and 4 per normalized client address, in addition to the
active-request limit.
- Upstream response headers and read-idle periods: 30 seconds per hop/period.
- A full downstream handoff slot: 120 seconds without consumption.
- Playlist collection and delivery each have fixed 120-second lifecycle deadlines; bounded blocking
rewrite work observes cooperative cancellation.
- A capacity rejection returns `503 Proxy capacity is exhausted` with `Retry-After: 1`.

Ordinary media streams do not have a fixed total lifetime: continued downstream progress permits
long playback. Dropped, cancelled, idle, or stalled bodies release their producer-owned permits.
Playlist input and output are separately bounded, and rewrite work runs off the asynchronous runtime.

## Redirects, headers, playlists, and browser isolation

On a cross-origin redirect, Stream Server clears caller-supplied request headers, URL userinfo, and
`If-Range`; `Range` is retained for media/CDN compatibility. Each new origin still undergoes the full
destination and TLS policy. Credential-bearing or rewritten responses are forced to
`Cache-Control: private, no-store`.

All proxy success and error responses receive route-owned active-content isolation headers,
including a restrictive sandboxed Content Security Policy, `nosniff`, `no-referrer`, and frame
denial. Custom response headers are narrowly validated and cannot replace these controls.

Full `200` HLS playlists may be safely rewritten. An upstream `Cache-Control: no-transform`, a raw
`206 Partial Content` response, `HEAD`, or a non-success status stays on the unmodified streaming
path. Rewritten bodies remove stale length/range/encoding/validator metadata, disable ranges, and
use private non-storable caching. Raw `206` framing and validators are preserved because its bytes
are not transformed.

These controls do not make a publicly reachable Stream Server a safe general-purpose application
proxy. Browser clients that can reach the listener can still request any destination allowed by the
current global policy and can consume server bandwidth and capacity. Keep the listener and firewall
exposure as narrow as your installation permits.

## Troubleshooting

- `400 Invalid proxy request`: the URL/options are malformed, use an unsupported scheme, contain an
unsafe custom header, or exceed an input limit.
- `403 Proxy destination is blocked`: an address, redirect, self-listener, metadata destination, or
returned hop marker is denied. Enable private/LAN sources only if the source and every reachable
browser/LAN client are trusted for this global exception.
- HTTP `403` JSON from `POST /settings`: a protected value changed without a valid local token, or
the request did not originate from loopback.
- `502 Proxy upstream request failed`: DNS, TLS, connection, redirect, response encoding, playlist
size, collection/rewrite, or timeout validation failed. A self-signed source may require the TLS
exception.
- `503 Proxy capacity is exhausted`: a global, per-client, or playlist quota is full. Retry after the
response's `Retry-After` delay.
13 changes: 11 additions & 2 deletions server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,11 @@ tracing-appender = "0.2.5"
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151"
anyhow = "1.0.104"
tokio-util = { version = "0.7.19", features = ["io", "compat"] }
tokio-util = { version = "0.7.19", features = ["io", "compat", "rt"] }
hex = "0.4.3"
librqbit = { version = "9.0.0", optional = true }
if-addrs = "0.15.0"
ipnet = "2.12.1"
urlencoding = "2.1.3"
regex = "1.13.1"
reqwest = { version = "0.13.4", features = ["blocking", "json", "stream"] }
Expand All @@ -48,6 +49,8 @@ tempfile = "3.27.0"
mimalloc = { version = "0.1.52", default-features = false }
rayon = "1.12.0"
sha2 = "0.11.0"
subtle = "2.6.1"
thiserror = "2.0.20"
semver = "1.0.28"
ratatui = "0.30.2"
crossterm = "0.29.0"
Expand All @@ -60,7 +63,7 @@ dirs = "6.0.0"
dashmap = "6.2.1"
tar = "0.4.46"
flate2 = "1.1.9"
uuid = "1.24.1"
uuid = { version = "1.24.1", features = ["v4"] }
quick-xml = { version = "0.41.0", features = ["serialize"] }
yenc = "0.2.2"
tokio-native-tls = "0.3.1"
Expand Down Expand Up @@ -91,6 +94,9 @@ tray-icon = { version = "0.24.2", default-features = false, features = ["gtk"] }
[target.'cfg(target_os = "android")'.dependencies]
rustls-platform-verifier = "0.7.0"

[target.'cfg(unix)'.dependencies]
libc = "0.2.189"

[target.'cfg(windows)'.dependencies]
windows = { version = "0.62.2", features = [
"Win32_Foundation",
Expand All @@ -104,6 +110,9 @@ windows = { version = "0.62.2", features = [
"Win32_UI_WindowsAndMessaging",
] }

[dev-dependencies]
tower = { version = "0.5.3", features = ["util"] }


[features]
default = ["libtorrent"]
Expand Down
Loading