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 <host-ip>: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.
This commit is contained in:
denkfabrik-li
2026-08-29 00:05:03 +02:00
parent 81bb136e9e
commit da7eb6f67d
3 changed files with 35 additions and 3 deletions
+8 -1
View File
@@ -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://<host-ip>: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
```
+21 -1
View File
@@ -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
+6 -1
View File
@@ -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