#2587·mailinabox

/admin/ is missing the HSTS header — 2017 removal rationale ("multiple headers") no longer applies

Author: kprzybysCreated Jun 4, 2026Updated Jun 12, 2026

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:

bash
# 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             # -> nothing

So 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:

python
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:

nginx
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:

nginx
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