Skip to content

maxuru ~ % cat haproxy/security-headers

Add security headers in HAProxy

Severity: mediumApplies to: HAProxy 2.1+Applies to: HAProxy 3.x
The fix/etc/haproxy/haproxy.cfg
frontend web
http-response set-header Strict-Transport-Security "max-age=31536000; includeSubDomains"
http-response set-header X-Content-Type-Options nosniff
http-response set-header X-Frame-Options DENY
http-response set-header Referrer-Policy strict-origin-when-cross-origin
# Deliberately absent: X-XSS-Protection

The directive HAProxy suggests when it rejects rspadd is http-response add-header — and for security headers that is the wrong half of the pair.

  • add-header appends a header, even if one with that name already exists.
  • set-header replaces any existing value, leaving exactly one.

If a backend already emits X-Frame-Options and HAProxy appends a second one, the response carries two. Browser behaviour when headers conflict is not something to rely on, and for Content-Security-Policy in particular, multiple headers are combined by intersection — each additional policy can only further restrict, which produces confusing breakage that looks like a CSP bug.

Use set-header and the proxy’s value wins cleanly. See removed directives for the rest of the 2.1 migration.

HAProxy does not have nginx’s inheritance trap

Section titled “HAProxy does not have nginx’s inheritance trap”

Worth saying plainly, because if you came from nginx you may be bracing for it. In nginx, a single add_header inside a location silently discards every inherited security header. HAProxy has no equivalent behaviour: http-response rules are a list, they are evaluated in order, and adding one in a backend does not delete the ones set in the frontend.

The ordering rule to know is simply that frontend response rules run after backend response rules for a given response, so a set-header in the frontend overrides one in the backend. That is usually the behaviour you want — the edge has the final say.

Old HAProxy hardening guides include it, generally as rspadd X-XSS-Protection:\ 1;\ mode=block. Do not carry it forward.

The header controlled a browser XSS filter that has been removed from every current browser, and while it existed it was demonstrably harmful: attackers could craft URLs that made the filter misidentify legitimate scripts and selectively disable them, creating a vulnerability on pages that had none. MDN recommends against it without qualification.

Content-Security-Policy is what actually addresses XSS. It needs to be written against the specific application rather than pasted, which is why it is not in the block above — a CSP copied from a guide either breaks the site or is so permissive it does nothing. Set it once you can enumerate your own script sources, and use Content-Security-Policy-Report-Only first.

The same reasoning appears on the nginx and Apache pages; it is a property of the header, not of the server.

HSTS belongs on the load balancer, because the load balancer is what terminates TLS and therefore what the browser is talking to. Two cautions that apply wherever it is set:

  • includeSubDomains covers every subdomain, including ones not yet using HTTPS. Check them before adding it.
  • max-age=31536000 is a year, and it is enforced by the browser, not by you — a client that has seen the header will refuse plain HTTP for that domain until it expires. There is no fast way to undo it.

Start with a short max-age, confirm nothing breaks, then raise it.