From da7eb6f67d73bbd7d68cd5e78d9f8a9558546629 Mon Sep 17 00:00:00 2001 From: denkfabrik-li <274324701+denkfabrik-li@users.noreply.github.com> Date: Sat, 29 Aug 2026 00:05:03 +0200 Subject: [PATCH] Publish the quickstart on loopback, since it trusts any proxy compose.example.yaml does two things that are each fine alone and unsafe together: ports: - "8080:80" # Docker binds 0.0.0.0 unless told otherwise environment: TRUSTED_PROXIES: "*" # believe the X-Forwarded-For of whoever connects Behind a proxy that appends the header, "*" is correct and harmless -- Symfony strips the peer and takes the real client the proxy appended. The example never gets there. It publishes the container on every interface, so a visitor can reach port 8080 themselves, and then *they* are the peer the application has been told to trust. `X-Forwarded-For: 203.0.113.9` makes request()->ip() return exactly that. What that costs, all of it on the signed-out surface: - the login lockout, keyed on `email|ip` in LoginRequest::throttleKey() - throttle:6,1 on register, password-email, password-reset, two-factor - throttle:30,1 on share-link, public-browse, public-comment - the download log, the activity log, and the `ip_address` recorded on guest comments -- which FileComments::post calls "the one handle that makes spam actionable" Rotate the header and every one of them counts a different attacker. The project's own test states the primitive: TrustedProxiesTest sets trustedproxy.proxies = '*', sends X-Forwarded-For from a *direct* client, and asserts the address is taken. The documentation has always qualified "*" correctly -- .env.example says it is "only safe when nothing but the proxy can reach the app", and dockerhub-overview.md repeats it. The example file is what did not meet its own precondition, and it is the file the Docker Hub description tells a first-time reader to copy. Publishing on 127.0.0.1 restores the precondition: a proxy on the host, or in this compose file, still reaches it; nothing off the machine does. This repository's own compose.yaml already publishes Adminer that way, for the same reason. The two settings are now documented as a pair in all three places that carry them, including what to do when the proxy is on another host: bind to the interface it arrives from and name that address in TRUSTED_PROXIES instead of "*". DOCKER.md's health-check command changes with it -- it told the reader to curl :8080 from the same machine, which the new binding does not answer. It now says 127.0.0.1:8080. No test: this is packaging and prose. `docker compose config` parses the edited file. --- DOCKER.md | 9 ++++++++- docker/production/compose.example.yaml | 22 +++++++++++++++++++++- docker/production/dockerhub-overview.md | 7 ++++++- 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/DOCKER.md b/DOCKER.md index 262738a3..3aeb947f 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -121,6 +121,13 @@ Without it every visitor appears to come from the proxy. The login rate limiter your users as one attacker, and the download log records the proxy's address instead of the person's. `compose.example.yaml` already sets this. +`"*"` means "trust whoever connected to me", so it belongs with a published port only the proxy can +reach — which is why `compose.example.yaml` publishes on `127.0.0.1`. If anybody can open the +container's port directly, they are the proxy as far as this setting is concerned, and the +`X-Forwarded-For` they send is the address the rate limiters and the download log will use. Where +the proxy runs on another host, publish on the interface it arrives from and name that address or +subnet here instead of `"*"`. + Leaving it unset does not cause a `502` — that means your proxy could not get a usable response out of the container at all, which is a different problem with a different fix. It does cause a **419 "page expired"**. Without it the application never learns the proxy terminated TLS, so it builds @@ -177,7 +184,7 @@ is the quickest way to separate "the app is down" from "the proxy cannot reach t during an outage, from the same machine: ```sh -curl -s -o /dev/null -w '%{http_code}\n' http://:8080/up # straight at the container +curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/up # straight at the container curl -s -o /dev/null -w '%{http_code}\n' https://files.example.com/up ``` diff --git a/docker/production/compose.example.yaml b/docker/production/compose.example.yaml index 66a160d9..c1abb213 100644 --- a/docker/production/compose.example.yaml +++ b/docker/production/compose.example.yaml @@ -17,7 +17,23 @@ services: # Put a TLS-terminating proxy in front of this in any real install. # ProjectSend issues download links and password-reset emails using # APP_URL, so that value — not this port — is what users must reach. - - "8080:80" + # + # Bound to the loopback address, not to every interface, because + # TRUSTED_PROXIES below is "*". That setting tells the application to + # believe the X-Forwarded-For header of whoever connects to it, which + # is correct behind a proxy and catastrophic when anybody can connect + # directly: a visitor who reaches this port themselves is then the + # "proxy", and can hand the application any client IP they like — + # which is enough to walk straight through the login lockout, every + # named rate limit, and the address recorded in the download log. + # + # Publishing on the loopback address keeps the proxy (on this host, + # or in this compose file) able to reach it while nothing off the + # machine can. If you move the proxy to another host, publish on the + # interface it comes from and narrow TRUSTED_PROXIES to that address + # or subnet at the same time — the two settings only make sense + # together. + - "127.0.0.1:8080:80" environment: APP_URL: https://files.example.com APP_ENV: production @@ -55,6 +71,10 @@ services: # Without it every visitor appears to come from the proxy: the login # rate limiter treats all of your users as one attacker, and the # download log records the proxy's address. + # + # "*" means "trust whoever connects to me", which is only safe when + # nothing but the proxy can — which is what the loopback binding + # above is for. Change one and you have to change the other. TRUSTED_PROXIES: "*" # Optional: uncomment these — with a password of your own — to create diff --git a/docker/production/dockerhub-overview.md b/docker/production/dockerhub-overview.md index cc14e17c..8c3933e3 100644 --- a/docker/production/dockerhub-overview.md +++ b/docker/production/dockerhub-overview.md @@ -40,7 +40,10 @@ services: restart: unless-stopped ports: # Put a TLS-terminating proxy in front of this in any real install. - - "8080:80" + # Bound to loopback because TRUSTED_PROXIES below is "*": the + # application then believes the X-Forwarded-For of whoever connects, + # so nobody but the proxy may be able to. + - "127.0.0.1:8080:80" environment: APP_URL: https://files.example.com APP_ENV: production @@ -58,6 +61,8 @@ services: # Required whenever anything sits between your visitors and this # container — including the reverse proxy you should be running. + # "*" trusts whoever connects, so it goes together with the loopback + # binding above: change one and you have to change the other. TRUSTED_PROXIES: "*" # Optional: uncomment these — with a password of your own — to create