/admin/ is missing the HSTS header — 2017 removal rationale ("multiple headers") no longer applies
Title
/admin/ does not send Strict-Transport-Security — the 2017 removal rationale ("multiple headers") no longer applies
Summary
The /admin/ control-panel location returns no Strict-Transport-Security (HSTS)
header, while the server root and /mail/ do. This was reported and fixed years
ago, then deliberately reverted — but the reason for the revert is no longer true
on current versions, so the net effect today is simply a missing header on the
most security-sensitive path.
History (important — this is not a naive regression)
| Date | Ref | What happened |
|---|---|---|
| 2016-06 | issue #878 | Reported: HSTS not sent on /admin (or Z-Push) |
| 2016-07 | PR #879 (4e3cfea) |
Fix: added add_header Strict-Transport-Security max-age=31536000; to the /admin/ block |
| 2017-10 | commit 2556e3fb |
Reverted the line, message: "HSTS header does not belong here, will result in multiple headers" |
| now (v76) | — | /admin/ sends no HSTS header at all |
Why the removal rationale no longer holds
The line was removed in 2017 to avoid a duplicated HSTS header (most likely the management backend was emitting its own HSTS at the time). On v76 neither source emits it anymore:
# nginx /admin/ — no HSTS:
curl -sI https://box.example.com/admin/ | grep -i strict-transport # -> nothing
# management backend on 127.0.0.1:10222 — no HSTS either:
curl -sI http://127.0.0.1:10222/ | grep -i strict-transport # -> nothingSo the "multiple headers" concern is gone; the current net effect is a missing header, not a duplicate one.
Root cause (mechanics)
HSTS is injected once at server scope in management/web_update.py:
if hsts == "yes":
nginx_conf_extra += '\tadd_header Strict-Transport-Security "max-age=15768000" always;\n'
elif hsts == "preload":
nginx_conf_extra += '\tadd_header Strict-Transport-Security "max-age=15768000; includeSubDomains; preload" always;\n'But conf/nginx-primaryonly.conf gives /admin/ its own headers:
location /admin/ {
proxy_pass http://127.0.0.1:10222/;
proxy_set_header X-Forwarded-For $remote_addr;
add_header X-Frame-Options "DENY";
add_header X-Content-Type-Options nosniff;
add_header Content-Security-Policy "frame-ancestors 'none';";
}Per nginx, a location with its own add_header does not inherit the
server-level add_header directives:
"These directives are inherited from the previous configuration level if and only if there are no add_header directives defined on the current level." — https://nginx.org/en/docs/http/ngx_http_headers_module.html#add_header
So /admin/ loses the server-scope HSTS.
Impact
Low in practice — HSTS is host-scoped, so once a browser sees the header on /
or /mail/ it pins the whole host (including /admin/). The only gap is a
browser whose first-ever contact with the host is a direct request to /admin/
with no prior HSTS state. Still, /admin/ is the most sensitive path (admin
login + mail-user API) and per-response scanners (internet.nl, Hardenize) flag it.
Proposed fix
Re-add HSTS inside the /admin/ block, this time with the always modifier and
matching whatever web_update.py emits (honoring the per-domain hsts/preload
option). Since the backend no longer emits HSTS, this will not duplicate it:
location /admin/ {
proxy_pass http://127.0.0.1:10222/;
proxy_set_header X-Forwarded-For $remote_addr;
add_header X-Frame-Options "DENY";
add_header X-Content-Type-Options nosniff;
add_header Content-Security-Policy "frame-ancestors 'none';";
add_header Strict-Transport-Security "max-age=15768000" always; # <-- re-add
}(Cross-references: #878, #879, commit 2556e3fb, and #1790 which added the always
modifier to the server-level directive.)
Environment
- Mail-in-a-Box v76
- Ubuntu 22.04.5 LTS
- nginx (distro package)
Source: mail-in-a-box/mailinabox