feat(media-proxy): sign attachment URLs and gate origins (#2830)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-18 15:57:32 +02:00
1 parent 025c01ab13
commit 522cf08e61
108 files changed
+10148 -441

No files matched your search

@@ -58,7 +58,7 @@ Default `http`. The scheme for internal service URLs. Must be `http` or `https`,
## Secrets you must generate
Every value below ships as `CHANGE_ME`. Replace them all before the first start. Compose refuses to start when one is unset or empty, and names the variable. Generate each with `openssl rand -hex 32`, except the relay secret and the VAPID pair, whose own entries name the command. The installer in [Get started](/operator/get-started/) generates them all, so come here to rotate one value later or to set them up by hand.
Every value below ships as `CHANGE_ME`. Replace them all before the first start. Compose refuses to start when one is unset or empty, and names the variable. Generate each with `openssl rand -hex 32`, except the relay secret, the attachment URL secrets, and the VAPID pair, whose own entries name the command. The installer in [Get started](/operator/get-started/) generates them all, so come here to rotate one value later or to set them up by hand.
#### `POSTGRES_PASSWORD`
@@ -90,7 +90,21 @@ Media Proxy URLs. Must match across `api`, `worker`, `media-proxy`, `gifs`, and
#### `FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64`
Generate with `openssl rand -base64 32`. [Upload relay](/media-proxy/upload-relay/) capability tokens. Must be standard base64 decoding to at least 32 bytes. Hex fails. Must match on `api`, `worker`, and `media-proxy`. The API and the Media Proxy both check it at boot and refuse to start without it in `upload` mode.
Generate with `openssl rand -base64 32`. [Upload relay](/media-proxy/upload-relay/) capability tokens. Must be standard base64 decoding to at least 32 bytes. Must match on `api`, `worker`, and `media-proxy`. `api` and `worker` refuse to start without it in every mode. `media-proxy` refuses to start without it when `FLUXER_MEDIA_PROXY_MODE` is `upload` or `relay`.
#### `FLUXER_MEDIA_PROXY_ATTACHMENT_URL_SECRETS_BASE64`
Generate each entry with `openssl rand -base64 32`. Signs the [attachment URLs](/media-proxy/overview/#signed-attachment-urls) Fluxer returns. A comma-separated list, where each entry must be standard base64 decoding to at least 32 bytes. Spaces around an entry and empty entries are ignored. The first entry signs and every entry verifies. Must match on `api`, `worker`, and `media-proxy`, and must differ from `FLUXER_MEDIA_PROXY_SECRET_KEY`. `api` and `worker` refuse to start when an entry is invalid, and with no entry they return unsigned URLs. `media-proxy` reads it only when `FLUXER_MEDIA_PROXY_MODE` is `mp` or `upload`, and then refuses to start with an invalid entry, or with no entry when `FLUXER_MEDIA_PROXY_ATTACHMENT_SIGNATURE_MODE` is `report` or `enforce`.
The Compose stack leaves it empty, so a self-hosted instance returns unsigned URLs until an operator sets one. Setting it alone changes nothing on the read side, because `FLUXER_MEDIA_PROXY_ATTACHMENT_SIGNATURE_MODE` stays `off` until it is set too.
Rotate it in three steps, and apply each one with `docker compose up -d` before starting the next:
1. Add the new secret as the second entry. Every `media-proxy` then accepts it before any service signs with it.
2. Move the new secret to the first entry, so it signs.
3. Remove the old secret no sooner than one day after step 2, because an ordinary URL it signed stays valid for up to a day.
A [data package URL](/http-api/messages/#data-package-attachment-urls) inside a harvest export never expires, so removing a secret ends every data package URL it signed at once, including the links inside exports already delivered, and those exports have to be rebuilt. Step 3 is safe for ordinary URLs alone.
#### `FLUXER_ADMIN_SECRET_KEY_BASE`
@@ -1309,7 +1323,7 @@ The Media Proxy takes uploads, transforms images, and serves media back. All are
#### `FLUXER_MEDIA_PROXY_MODE`
Default `mp`. Which routes are served. `mp`, `static`, or `upload`. Anything else is a startup error. Compose sets `upload`.
Default `mp`. Which routes are served. `mp`, `static`, `upload`, or `relay`. `relay` serves the upload relay and the operator and internal endpoints, and returns 404 for every read. Anything else is a startup error. Also settable with `--mode`. Compose sets `upload`.
#### `FLUXER_MEDIA_PROXY_HOST`
@@ -1411,6 +1425,22 @@ Default empty. An external NSFW classifier. No such service ships with the stack
Default `0.85`. The media-side NSFW cutoff. Accepts 0.0 to 1.0 and finite. Distinct from the API threshold.
#### `FLUXER_MEDIA_PROXY_CORS_MODE`
Default `off`. The [cross-origin read policy](/media-proxy/overview/#cross-origin-reads). `off`, `report`, or `enforce`. Anything else is a startup error. `report` logs each read whose origin would be refused and changes no response. `enforce` refuses those reads with 403 and sets `Access-Control-Allow-Origin` to the request origin. When `FLUXER_MEDIA_PROXY_MODE` is `static` or `relay`, the Media Proxy ignores it, so a bad value is no startup error. Compose leaves it `off`.
#### `FLUXER_MEDIA_PROXY_CORS_ALLOWED_ORIGINS`
Default empty. Comma-separated origins that may read media through CORS, such as `https://web.fluxer.app`. Spaces around an entry and empty entries are ignored. Each entry is parsed as a URL and must be `http` or `https` with no credentials, query, fragment, or path beyond `/`. Its host must be an IPv4 address, a bracketed IPv6 address, or a domain name whose labels are ASCII letters, digits, and hyphens after parsing. So a wildcard such as `https://*.fluxer.app`, a brace pattern, an underscore, a trailing dot on a domain name, or any non-ASCII character fails. Write an internationalised domain name in its punycode form. Any failing entry makes the process exit. An entry is compared in the form a browser sends. `https://Web.Fluxer.App:443/` matches `https://web.fluxer.app`. Required when `FLUXER_MEDIA_PROXY_CORS_MODE` is `report` or `enforce`. When the policy is `report` or `enforce`, the Media Proxy logs the parsed list once at startup. When `FLUXER_MEDIA_PROXY_MODE` is `static` or `relay`, the Media Proxy ignores it.
Compose sets it to the public origin, built from `FLUXER_PUBLIC_ORIGIN`, or from `FLUXER_PUBLIC_SCHEME`, `FLUXER_DOMAIN`, and `FLUXER_PUBLIC_PORT`. That origin is the only entry, so the web app the instance serves reads its own media. The hosted web client and the desktop app run on `https://web.fluxer.app`. To let them read this instance's media through CORS, set the list in `.env` to the public origin and `https://web.fluxer.app`, such as `https://chat.example.com,https://web.fluxer.app`. A value set in `.env` replaces the Compose value, so it must name the public origin too.
#### `FLUXER_MEDIA_PROXY_ATTACHMENT_SIGNATURE_MODE`
Default `off`. The [signed attachment URL policy](/media-proxy/overview/#signed-attachment-urls). `off`, `report`, or `enforce`. Anything else is a startup error, an empty value included. `report` logs each attachment read `enforce` would refuse and serves it. `enforce` refuses those reads with 404. Under both, a read whose signature is valid gets a `Cache-Control` that ends with the signature instead of the usual year. `report` and `enforce` require `FLUXER_MEDIA_PROXY_ATTACHMENT_URL_SECRETS_BASE64`, and the Media Proxy logs the mode and the number of secrets once at startup. When `FLUXER_MEDIA_PROXY_MODE` is `static` or `relay`, the Media Proxy ignores it and the secrets, so a bad value is no startup error. Compose leaves it `off`, so a self-hosted instance signs nothing until an operator turns it on.
This variable is the whole switch, and moving between the three states needs nothing else changed. The Media Proxy reads it once at start, so apply a change with `docker compose up -d media-proxy`, which replaces the container. `docker compose restart media-proxy` reuses the old environment. A cache or proxy in front of the instance has a switch of its own, and [Turning the signature policy on](/operator/reverse-proxy/#turning-the-signature-policy-on) gives the order to move the two in and what each pairing serves.
`media-proxy` range-checks these values at startup and then reads them nowhere: `FLUXER_MEDIA_PROXY_UPLOAD_RELAY_BUFFERED_RETRY_BYTES` and `FLUXER_MEDIA_PROXY_UPLOAD_RELAY_BUFFERED_RETRY_TOTAL_BYTES`. A bad value still fails the boot.
## Gateway settings
@@ -436,6 +436,82 @@ The edge reads the variable at container start, so apply a change with `docker c
`private_ranges` trusts the whole LAN. With `FLUXER_EDGE_BIND=0.0.0.0:8080`, any host on that network can impersonate any client address. Firewall the port to the proxy host.
:::
## Caching in front of Fluxer
Media reads are the only traffic worth caching, and the instance expects no cache in front of it. Every rule below is a correctness rule rather than a tuning choice, and a cache that cannot follow one should not store media at all.
#### Leave the signature parameters out of the cache key
`ex`, `is`, `hm`, and `uc` are the [signed attachment URL](/media-proxy/overview/#signed-attachment-urls) parameters. A key that keeps them stores a new entry every time the same object is signed again, which empties the cache of anything useful. Drop all four and keep every other parameter, because the rest select the representation.
Only drop them if your proxy also meets the next two rules. A key without them is the same key for a signed request and an unsigned one, so the signature check is the only thing between an unsigned request and the object.
#### Decode parameter names, and take the last value
Fluxer parses a query string with form URL decoding, so `%73ize` is the name `size` and `+` is a space. When a name repeats, the final value wins. A cache that compares raw bytes, or that keeps the first value, can be handed `%73ize=64` or `size=64&size=4096` and made to store one rendering under the key for another. Match both rules, or refuse to cache a request whose query string you cannot parse that way.
#### Check the signature before the cache lookup
A cache hit never reaches the instance, so the Media Proxy's own signature check never runs on one. Under the `enforce` [signed attachment URL policy](/media-proxy/overview/#signed-attachment-urls), verify the signature in the proxy before it looks in the cache. An nginx `access_by_lua_block` runs in the access phase, which is before the cache lookup. A proxy that cannot do that must leave `/attachments/` uncached, or it will serve an expired or forged URL from a warm entry.
#### Verify the same path the cache key is built from
Running before the cache lookup is not enough on its own. The check and the key must read the same spelling of the path, or a request the check skips still reaches the entry the key names.
In nginx that spelling is `$uri`, which nginx has already percent decoded, merged repeated slashes in, and resolved `.` and `..` out of. `$request_uri` is the raw bytes the client sent. A check that scopes itself on `$request_uri` never sees `//attachments/...`, `/attachments%2F...`, `/attachments/./...`, or `/x/../attachments/...` as an attachment read, while `$uri` and the cache key make all four the same object. Read `$uri` for the prefix test and for the storage key, take the query from `$request_uri`, and refuse a path whose `$uri` is not a usable storage key rather than passing it through.
The signature covers the decoded storage key, which is `$uri` without its leading slash.
#### Do not cache a range request without slice support
nginx removes `Range` from a request it may cache unless the slice module is built in, so a seek into a video pulls the whole object. Set `proxy_cache_max_range_offset 0`. A range request then reaches the instance untouched and is not stored, while an entry that is already warm still answers ranges from cache.
#### Wire up purge, and bound the entry lifetime anyway
Every media 200 is `public, max-age=31536000` with no `ETag` and no `Last-Modified`, so a cache never revalidates and a deleted object stays until its entry goes. Point `FLUXER_CACHE_PURGE_HTTP_ENDPOINT` at a receiver that clears your cache, and read the contract it must meet under [Cache purge](/operator/configuration/#feature-flags-and-development-switches). Give stored media a shorter lifetime of your own as well, because a purge that is lost is never sent again.
## Turning the signature policy on
`FLUXER_MEDIA_PROXY_ATTACHMENT_SIGNATURE_MODE` is the instance switch, and a proxy that verifies signatures has one of its own. Both sides name the same three states, `off`, `report`, and `enforce`. Both read their setting once at start, so apply an instance change with `docker compose up -d media-proxy`, which replaces the container. `docker compose restart media-proxy` reuses the old environment and changes nothing.
Turn the policy on in this order, and stop wherever a refusal appears that you did not expect:
1. Give `api`, `worker`, and `media-proxy` the same `FLUXER_MEDIA_PROXY_ATTACHMENT_URL_SECRETS_BASE64`, and give the proxy the same list. Every URL the instance returns is signed from here on, and nothing verifies yet.
2. Leave it there until the clients holding the unsigned URLs issued before step 1 have replaced them. A client refreshes a URL whose `ex` is absent or under an hour away, and one that has been open since before step 1 keeps its unsigned URLs until it reloads.
3. Set both sides to `report` and read the logs. Under `report` nothing is refused, and each read `enforce` would refuse is logged with its reason on the side that saw it.
4. Move the proxy to `enforce` once `report` is quiet.
5. Move the instance to `enforce`.
The proxy goes first because a cache hit never reaches the instance. Reversing steps 4 and 5 leaves every warm entry readable without a signature for as long as it lives.
From step 3 the instance caps `Cache-Control` on a read whose signature is valid at the time that signature has left, where `off` returns a year. `report` caps it too, so the entries stored during the dry run do not outlive the URLs that made them. A read `report` would refuse still returns a year, because there is no signature to cap it against. Entries stored before step 3 keep the year they were given.
### When the two sides disagree
| Proxy | Instance | What an attachment read gets |
| --- | --- | --- |
| `off` | `off` | Served. Neither side reads a signature |
| `off` | `report` | Served. The instance logs what `enforce` would refuse |
| `off` | `enforce` | Refused, unless a warm entry answers it without reaching the instance |
| `report` | `off` | Served. The proxy logs what its own `enforce` would refuse |
| `report` | `report` | Served. Both sides log |
| `report` | `enforce` | Refused, unless a warm entry answers it without reaching the instance |
| `enforce` | `off` | Refused at the proxy, before the cache lookup |
| `enforce` | `report` | Refused at the proxy. The instance logs nothing, because it is never asked |
| `enforce` | `enforce` | Refused at the proxy |
The two rows that serve a read the instance would refuse are the ones where the instance enforces alone. The instance is right and the cache is stale, and the entry answers until it goes, so pair an instance-first change with a purge of `/attachments/`.
A change to `FLUXER_MEDIA_PROXY_CORS_MODE` has the same problem and no step order that avoids it. An entry stored while the policy was `off` holds `Access-Control-Allow-Origin: *` and a year of `Cache-Control`, and the proxy replays both on every hit without asking the instance. The proxy's own signature check does not help, because it reads the signature and never reads `Origin`, so a foreign page holding one valid signed URL still reads the bytes cross-origin from a warm entry. Purge `/attachments/` when you move the CORS policy to `enforce`, the same purge step 5 needs.
`enforce` at the proxy alone covers every request that passes through it. Anything that can reach the instance another way is unchecked until the instance enforces too: a second cache, a health probe from outside the proxy, an internal service that reads an attachment URL directly, and a deployment running no proxy at all. Those are what step 5 covers, and what it can break.
Step 4 also ends the instance's own evidence. A read the proxy refuses never reaches the instance, so once the proxy enforces the instance logs nothing under `report` and its stream stays empty whatever it would have refused. Take step 5 on what step 3 showed, while both sides were still in `report`.
Both sides must hold the same secret list. A side missing a key refuses the URLs signed with it, so add a new key to both sides before the instance starts signing with it, and keep a retired key on both until the last URL it signed has expired. An ordinary URL expires within a day. A [data package URL](/http-api/messages/#data-package-attachment-urls) never does, and removing the key that signed it is what ends it.
With no secret list, `api` and `worker` return unsigned URLs, and a side in `enforce` refuses every attachment read. Never move a side to `enforce` before signing is on.
## Voice media does not use the proxy
LiveKit signalling goes through `/livekit/*` like everything else. WebRTC media does not touch the proxy at all: