Skip to content
Go back

What Your Reverse Proxy Leaks by Default

By KingPin 17 min read
What Your Reverse Proxy Leaks by Default
Contents

You Got the Padlock. Now Read What the Door Says.

You set up a reverse proxy, got the HTTPS padlock, and moved on to the next thing. Fair. The padlock means the traffic is encrypted. It says nothing about what the proxy tells a stranger who asks politely, or impolitely, before any login.

We tested the stock configs of three proxies, and every one of them hands a stranger something. None of them leaks the same thing. nginx announces its exact version on every response and error page, and serves your real certificate (with your hostname in it) to anyone who connects to the bare IP. Caddy leaks the least of the three, but it passes your app’s own banners straight through. Traefik’s quick-start dashboard lists every container on the host. And a fresh Nginx Proxy Manager install lets the first visitor to port 81 create the admin account. Most of the fixes are 1 to 5 lines each, and they are all below.

Full example: Clone the working files at github.com/KingPin/sumguy-examples/security/reverse-proxy-hardening/01-default-leaks

This is a SumGuy’s Lab post, so the box at the top lists the rig and its limits. The series folder includes audit.sh, which you can point at your own proxy: ./audit.sh 80 443 your.domain.example 203.0.113.10. This is Part 1 of a 5-part series on reverse proxy hardening, and the rest of the series reuses that script as its before/after check.

How We Tested

One machine, local Docker, every port bound to 127.0.0.1. Behind each proxy sits one deliberately leaky backend, a small Python app. It sends X-Powered-By: Express, Python’s default Server: BaseHTTP/0.6 Python/3.14.10, a stack trace on /boom, and an echo of the request headers it received.

Each proxy got a minimal config to proxy app.example.test over HTTPS, with an HTTP redirect: Caddy and Traefik from their own docs, nginx in the shape most tutorials hand you. Certificates come from a throwaway local CA. The probes are curl and openssl from the outside, plus testssl.sh 3.2. The versions: nginx 1.31.8, Caddy 2.11.7, and Traefik v3.7.17. Nginx Proxy Manager (NPM) 2.16.0 appears only for its first-run and admin-port findings.

Here are the stock configs. They are short on purpose, because short is how most of us run them.

nginx keeps the image’s own default.conf in place, as on a fresh install, and adds this:

conf.d/app.conf
server {
listen 80;
server_name app.example.test;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name app.example.test;
ssl_certificate /certs/app.crt;
ssl_certificate_key /certs/app.key;
location / {
proxy_pass http://backend:8080;
}
}

Caddy:

Caddyfile
app.example.test {
tls /certs/app.crt /certs/app.key
reverse_proxy backend:8080
}

A real Caddy deployment drops the tls line and gets an ACME cert. We used local certs so all three proxies serve the same one.

Traefik uses the flags from its own Docker getting-started page (--api.insecure=true, --providers.docker=true, entrypoint web on :80). We added a websecure entrypoint on :443, an HTTP-to-HTTPS redirect, and the Docker socket mount, which that page also uses. The app is routed by labels. That page says --api.insecure=true is for “development use only”. Hold that thought.

The Scorecard

What a stranger getsnginx 1.31.8Caddy 2.11.7Traefik v3.7.17
Proxy version in headersServer: nginx/1.31.8Server: Caddy, no versionNothing
Proxy version in error pagesnginx/1.31.8 in the 502 page bodyEmpty bodyBad Gateway, no name
App’s Server banner reaches the clientNo (nginx replaces it)Yes, plus Via: 1.0 CaddyYes
App’s X-Powered-By: ExpressPassed throughPassed throughPassed through
App’s stack trace on a 500Passed throughPassed throughPassed through
TLS with no SNI (scanner hits the IP)Your real cert: CN=app.example.testHandshake refusedTRAEFIK DEFAULT CERT
Client sends X-Forwarded-For: 6.6.6.6App sees 6.6.6.6 and nothing elseApp sees the real peer IPApp sees the real peer IP
Admin surface on the stock configNoneAdmin API on 127.0.0.1:2019 inside the container onlyUnauthenticated dashboard API on :8080
testssl.sh 3.2 ratingA+ (93)A+ (93)A+ (93)
TLS 1.2 cipher suites offered25 (14 without forward secrecy)35 (2 CBC)
HSTS / security headersNoneNoneNone (only nosniff on its own 404)

Read the stack trace row twice. All three proxies pass your app’s crash output to the client, because a proxy forwards what the app says. No proxy default fixes that. Only the app’s debug switch does (Django DEBUG = False, Flask debug off, NODE_ENV=production for Express). Everything below is the proxy side of the story, and proxy-side interception is a backstop, not the cure.

nginx: Politely Announces Itself

Every response carries Server: nginx/1.31.8. The built-in error pages (the 301 and 502 pages in our run) also print nginx/1.31.8 in the HTML body. A scanner that wants to match your version against a CVE list does not have to guess.

The bare IP answers with your hostname

We sent a bare-IP request to port 80. Our app’s server block answered and redirected to https://127.0.0.1/. The reason: nginx loads conf.d/*.conf alphabetically, so app.conf sorts before the image’s default.conf and became the default server for port 80. The “Welcome to nginx” page never showed.

On 443 the app block is the only TLS server, so it is the default there too. A TLS handshake with no SNI, or with a random SNI (nope.example.test), got the real certificate back: subject CN=app.example.test, SAN DNS:app.example.test. Anyone scanning IP ranges now has your hostname tied to your address.

One caveat before you panic. Every publicly trusted certificate is logged in Certificate Transparency logs (searchable on crt.sh), so the hostname on a public cert is public anyway. The no-SNI leak still matters because it connects that name to your IP for anyone sweeping address ranges. A wildcard cert keeps individual subdomain names out of the CT logs.

Forwarded headers: the stock proxy_pass trusts the attacker

With the stock proxy_pass, nginx sends the app no forwarding headers at all, and it passes the client’s own X-Forwarded-For through untouched. The app received X-Forwarded-For: 6.6.6.6 (what the client claimed) and no real client IP. Any app that trusts XFF for logging, rate limits, or allowlists now trusts the attacker. Stock nginx also sent the app Host: backend:8080 (the proxy_pass host), not the name the client asked for. We have a whole post on this one: The Header Your Reverse Proxy Keeps Dropping.

On the plus side, nginx hides the app’s Server header by default (it sits in nginx’s default proxy_hide_header list). It does pass X-Powered-By through.

The fix

conf.d/app.conf (hardened)
server_tokens off;
# Catch-all: anything that is not app.example.test gets nothing.
server {
listen 80 default_server;
listen 443 ssl default_server;
ssl_reject_handshake on; # no SNI or unknown SNI: refuse before sending a cert
return 444; # plain HTTP: close the connection without a response
}
server {
listen 80;
server_name app.example.test;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name app.example.test;
ssl_certificate /certs/app.crt;
ssl_certificate_key /certs/app.key;
proxy_hide_header X-Powered-By;
# Replace upstream error bodies (stack traces) and nginx's own error pages.
proxy_intercept_errors on;
error_page 500 502 503 504 @error;
location / {
proxy_pass http://backend:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr; # edge proxy: overwrite, never append
proxy_set_header X-Forwarded-Proto $scheme;
}
location @error {
default_type text/plain;
return 502 "Something went wrong.\n";
}
}

We reran the same probes after the change. The Server header reads nginx with no version. A bare-IP request on :80 gets the connection closed with no response. No-SNI and unknown-SNI handshakes are refused. The app sees the real client IP (172.18.0.1 in the lab), never 6.6.6.6. Both /boom and an app-down 502 return the plain text “Something went wrong.”, and X-Powered-By is gone.

A few gotchas on that config:

  1. server_tokens off hides the version only. The word nginx stays in the Server header, and removing it needs a third-party module, which is not worth the trouble.
  2. ssl_reject_handshake needs nginx 1.19.4 or newer.
  3. $remote_addr (not $proxy_add_x_forwarded_for) is right only when nginx is the first hop. Behind Cloudflare or another proxy you need the realip module, which Part 4 (rate limits) covers.
  4. proxy_intercept_errors also replaces JSON error bodies from APIs. Skip it for API hosts and fix the app’s debug mode instead.
  5. An error_page line without an =code keeps the original status, even though the @error block says return 502. An app 500 still reaches the client as a 500, and an app that is down still shows as a 502. Only the body changes.

More on the base config in Nginx: The Config That Makes Sense.

Caddy: Quiet, Except for Your App

Caddy leaks the least of the three. It sends Server: Caddy on redirects and on its own responses, never a version. A no-SNI or unknown-SNI handshake is refused, so no certificate leaves the box. That is the best result of the three, and it comes with zero config.

Its forwarding behavior is also saner out of the box. Caddy discarded the client’s 6.6.6.6 and sent the app the real peer IP, plus X-Forwarded-Host and X-Forwarded-Proto: https. (Caddy ignores a client’s XFF unless you configure trusted_proxies.) It also sends Via: 2.0 Caddy upstream.

The leak is on the way back. Proxied responses carry the app’s banner: server: BaseHTTP/0.6 Python/3.14.10, plus via: 1.0 Caddy and x-powered-by: Express. A stock Caddy tells a stranger the backend’s language and version. Two smaller observations: an app-down 502 comes back with an empty body and Server: Caddy, and a right-SNI request with a Host header for another name gets a 200 with an empty body, no app content. Caddy also advertises HTTP/3 with alt-svc: h3=":443", which fingerprints Caddy but says nothing about your app.

Is the admin API exposed?

Not on the network. The admin API listens on 127.0.0.1:2019 inside the container. From inside the container, GET /config/ returned the full running config (upstreams, cert paths). From another container on the same Docker network we got connection refused. There is no auth on it, but no network exposure either. Keep it that way: do not publish 2019 or move it to 0.0.0.0.

The fix

Caddyfile (hardened)
app.example.test {
tls /certs/app.crt /certs/app.key
# Site-level header ops run when the response is written, after reverse_proxy adds Via.
header -Server
header -Via
reverse_proxy backend:8080 {
header_down -X-Powered-By
@error status 5xx
handle_response @error {
respond "Something went wrong." {rp.status_code}
}
}
}

After the change, proxied responses carry no Server, no Via, and no X-Powered-By. /boom returns a 500 with “Something went wrong.” The Server: Caddy header remains on Caddy’s automatic HTTP redirects and on its own empty 502, without a version.

One gotcha cost us a few minutes. header_down -Via inside reverse_proxy did not remove the Via header in Caddy 2.11.7. The site-level header -Via did. If you copy a snippet from a forum that puts it inside the proxy block, check the response, not the config. More Caddy patterns in Caddyfile Patterns That Actually Work.

Traefik: The Dashboard Is a Phone Book

Traefik sends no Server header of its own. It passes the app’s Server and X-Powered-By through. A no-SNI or unknown-SNI handshake gets Traefik’s self-signed CN=TRAEFIK DEFAULT CERT (SAN <random hex>.traefik.default). That does not leak your hostname, but it tells a scanner “this is Traefik.”

An unknown Host gets 404 page not found with X-Content-Type-Options: nosniff. An app-down request gets 502 with the body Bad Gateway. Forwarding is sane: Traefik replaced the client’s XFF with the real peer IP and sent the app X-Real-Ip, X-Forwarded-Host, X-Forwarded-Port, X-Forwarded-Proto, and X-Forwarded-Server set to the Traefik container’s hostname (a container ID). That last one reaches the app only, not the client.

The real problem is the quick-start dashboard.

api.insecure on a home network

Traefik’s own page says --api.insecure=true is for “development use only”, and the flag sits in the very first Compose file on that page. With it on, port 8080 has no auth. GET /api/version returned {"Version":"3.7.17","Codename":"langres",...}. GET /api/http/services listed every backend with its internal IP and port (our app showed up as http://172.18.0.4:8080).

It gets worse, because the Docker provider watches the whole Docker socket and exposedByDefault is true. On the test machine, Traefik built services for 8 containers that belong to other, unrelated projects, plus every container in our stack. Their backend ports were 2375, 3000 twice, 5432 twice, 6379, 8080, and 9000. Those are the usual ports for a Docker API proxy, Postgres, and Redis or Valkey. All of that was visible to anyone who could reach :8080. That is a map of your network, handed over for free.

With only the quick-start flags (--api.insecure=true --providers.docker, no redirect) on port 80, Traefik auto-routes every container on its network under a Host rule built from its Compose service and project names. Requests with Host: npm-rp-leaks got a 200 from the NPM container. Host: nginx-rp-leaks got nginx’s 301, and Host: caddy-rp-leaks got Caddy’s 308. Nobody added labels to those containers. Guess the name, reach the container.

With our HTTPS redirect on, those auto-routers answered only with a 301, so the redirect happened to hide them. Do not rely on that.

The fix

Static config, as CLI flags: drop --api.insecure=true and add:

--providers.docker.exposedbydefault=false

Dynamic config (file provider). Keep your existing tls.certificates entries next to tls.options: with sniStrict on and no certificate loaded, Traefik refuses every handshake.

dynamic.yml
http:
middlewares:
strip-banners:
headers:
customResponseHeaders:
Server: ""
X-Powered-By: ""
tls:
options:
default:
sniStrict: true # no SNI or unknown SNI: refuse instead of sending TRAEFIK DEFAULT CERT
certificates:
- certFile: /certs/app.crt
keyFile: /certs/app.key

The app’s labels gain traefik.enable=true and traefik.http.routers.app.middlewares=strip-banners@file. After the change, Server and X-Powered-By are gone, no-SNI and unknown-SNI handshakes are refused instead of getting the default cert, the dashboard is gone, and only labelled containers are routed. The stack trace still passes through. Traefik’s errors middleware can replace error bodies, but it needs a separate web server to serve the page, so fix the app’s debug switch instead.

If you need the dashboard, Part 5 of this series puts it behind auth instead of --api.insecure. The label basics are in Traefik: Docker Routing with Labels.

Nginx Proxy Manager: The First Visitor Wins

NPM 2.16.0 appears here for one reason: its first-run behavior. On a fresh install with no proxy hosts, a bare-IP request on port 80 returns a 200 with NPM’s “Default Site” page and Server: openresty, no version. Fine.

Port 81 is the problem. GET /api/ without auth returned {"status":"OK","setup":false,"version":{"major":2,"minor":16,"revision":0}}. That is the exact version, before and after setup. The old documented default login ([email protected] / changeme) was rejected, because 2.16.0 uses a first-run setup instead.

But the setup is open to whoever arrives first. An anonymous POST /api/users with an admin role returned 201. Logging in as that new admin returned 200, and /api/ then reported "setup":true. The first visitor to port 81 owns your proxy: every host, redirect, and access list in it.

NPM’s own setup docs publish '81:81' # Admin Web Port on all interfaces. Docker-published ports bypass UFW’s INPUT rules, so “my firewall blocks 81” is often false.

The fix is two steps. First, do not publish 81 on all interfaces. Bind it to localhost or your LAN/VPN address: '127.0.0.1:81:81' or '192.168.1.10:81:81'. Second, finish the first-run setup before the container is ever reachable from outside. Since /api/ keeps reporting the exact version after setup, that is one more reason to keep 81 private. The walkthrough is in Nginx Proxy Manager for Normal Humans.

The Grade Is Not the Audit

testssl.sh 3.2 gave all three proxies A+ with a score of 93. Here is what sits under that grade.

nginx allows TLS 1.2 and 1.3 only (no SSLv3, TLS 1.0, or 1.1). Good. But its default cipher string offers 25 TLS 1.2 suites: 14 use static RSA key exchange (no forward secrecy) and 14 are CBC. testssl.sh flags LUCKY13 as “potentially VULNERABLE” because of the CBC suites, and reports “Has server cipher order? no”. It still printed A+. Caddy offers 3 TLS 1.2 suites. Traefik offers 5, 2 of them CBC. And none of the three sent HSTS. Apart from Traefik’s nosniff on its own 404 page, none sent any other security header either.

So the grade tells you the protocol floor is sane. It does not tell you the cipher list is tidy, and it does not check the version banner, the bare-IP behavior, or the dashboard. A letter grade is the dashboard light that says the engine is on. Part 2 covers TLS settings properly.

Run It Against Your Own Proxy

The Part 1 kit lives in security/reverse-proxy-hardening/01-default-leaks/ in the examples repo, with raw results committed from both the stock run and the hardened run. audit.sh sits one level up, in reverse-proxy-hardening/, because every part of the series shares it. To audit your own proxy:

Terminal window
cd sumguy-examples/security/reverse-proxy-hardening
./audit.sh 80 443 your.domain.example 203.0.113.10

The arguments are the HTTP port, the HTTPS port, your hostname, and the proxy’s IP. Run it before you change anything, save the output, then run it again after. Your 2 AM self will appreciate having the diff.

Where the series goes from here:

  1. Part 2: TLS settings (the cipher list, protocol floor, and what the grade hides).
  2. Part 3: security headers.
  3. Part 4: rate limits, including the realip setup for proxies behind a CDN.
  4. Part 5: hiding admin paths, including putting the Traefik dashboard behind auth.

If you want a baseline for how these proxies behave under load rather than under probing, our first Lab post is nginx vs Caddy: Measured on 2 Cores.

Common Questions

Does server_tokens off hide that I’m running nginx?

No. The server_tokens off directive removes the version number only. The response header still reads Server: nginx, so a scanner knows the software but not the release. Removing the word entirely needs a third-party module, which is not worth the maintenance for the little it hides.

Do certificate transparency logs expose my subdomains?

Yes, for publicly trusted certificates. Every such cert is logged in Certificate Transparency logs, searchable on crt.sh, so each hostname on a public cert is public. A wildcard certificate keeps individual subdomain names out of the logs, because only the wildcard name appears.

Does an A+ from testssl.sh mean my reverse proxy is secure?

No. We measured A+ (93) on nginx, Caddy, and Traefik defaults. The stock nginx config still offered 14 TLS 1.2 suites without forward secrecy, and none of the three proxies sent HSTS. The grade covers TLS configuration, not banners, default certificates, or admin surfaces.

Is the Caddy admin API exposed in Docker?

Not by default. Caddy’s admin API listens on 127.0.0.1:2019 inside the container. We reached it from inside the container and got the full running config, but another container on the same Docker network got connection refused. The API has no auth, so do not publish port 2019.

Is Traefik’s api.insecure safe on a home network?

No. The --api.insecure=true flag exposes an unauthenticated API on port 8080. In our test it returned the exact Traefik version and listed every backend with its internal IP and port, including containers from unrelated projects. Anyone on your LAN, or anyone reaching a published 8080, can read it.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Previous Post
PUID and PGID: Container Permission Fights
Next Post
PrivateBin vs Hemmelig: Secrets That Burn

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts