Skip to content

Commit 7973cd9

Browse files
authored
Deduplicate HTTP security headers between nginx and Flask-Talisman (#169)
nginx and Superset's own Flask-Talisman middleware both independently added Strict-Transport-Security, X-Content-Type-Options, X-Frame-Options, X-XSS-Protection, and Referrer-Policy to every response. nginx's add_header doesn't replace upstream headers, it appends, so responses carried duplicates with different values - per spec/browser handling this silently dropped nginx's preload flag on HSTS (RFC 6797: only the first STS header is processed), risked invalidating X-Frame-Options entirely on some browsers when comma-joined, and silently downgraded Referrer-Policy to nginx's weaker legacy default (UAs take the last policy across combined headers). Only nginx's HSTS line carried `always`, so its other four headers vanished on non-2xx/3xx responses (404, 500), relying entirely on Talisman as an unconditional fallback. Use proxy_hide_header to strip Talisman's upstream copies and let nginx's own add_header lines (all now with `always`) be the sole source of these five headers. Corrects Referrer-Policy to strict-origin-when-cross-origin. Left Talisman's CSP and all other config untouched - reconfiguring Talisman instead would have required replicating Superset's internal default CSP dict verbatim to safely override TALISMAN_CONFIG. Add a functional test (status_headers) that curls both a normal path and a nonexistent one, asserting each header appears exactly once with the expected value on both.
1 parent 20e3dc7 commit 7973cd9

3 files changed

Lines changed: 50 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3939

4040
### Fixed
4141

42+
* Deduplicated HTTP security headers between nginx and Flask-Talisman: nginx now hides Talisman's upstream
43+
copies via `proxy_hide_header` and is the sole source of `Strict-Transport-Security`, `X-Frame-Options`,
44+
`X-XSS-Protection`, `X-Content-Type-Options`, and `Referrer-Policy`, applied with `always` so they persist
45+
on error responses too. Corrected `Referrer-Policy` to `strict-origin-when-cross-origin`.
4246
* Redacted MySQL password from `superset` entrypoint's `test_db` output and disabled `-x` tracing around it,
4347
preventing plaintext password exposure in `docker logs`/`docker service logs`.
4448
* Upload `.py` source instead of `.pyc` bytecode to decouple host Python version. (#38, #41)

services/superset/nginx.conf

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,17 @@ http {
2121
ssl_prefer_server_ciphers on;
2222
ssl_session_cache shared:SSL:10m;
2323

24+
proxy_hide_header Strict-Transport-Security;
25+
proxy_hide_header X-Content-Type-Options;
26+
proxy_hide_header X-Frame-Options;
27+
proxy_hide_header X-XSS-Protection;
28+
proxy_hide_header Referrer-Policy;
29+
2430
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
25-
add_header X-Content-Type-Options nosniff;
26-
add_header X-Frame-Options DENY;
27-
add_header X-XSS-Protection "1; mode=block";
28-
add_header Referrer-Policy "no-referrer-when-downgrade";
31+
add_header X-Content-Type-Options nosniff always;
32+
add_header X-Frame-Options DENY always;
33+
add_header X-XSS-Protection "1; mode=block" always;
34+
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
2935

3036
server_tokens off;
3137
tcp_nopush on;

tests/testsuite/roles/testing/files/functional_superset.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,42 @@ def status_swarm(self) -> None:
294294
swarm_info["ControlAvailable"] is True, \
295295
"The testing localhost is supposed to be a Swarm manager, but it is not"
296296

297+
@decorators.Overlay.run_selected_methods_once
298+
def status_headers(self) -> None:
299+
expected_headers = {
300+
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
301+
"x-content-type-options": "nosniff",
302+
"x-frame-options": "DENY",
303+
"x-xss-protection": "1; mode=block",
304+
"referrer-policy": "strict-origin-when-cross-origin",
305+
}
306+
for path in ("/", "/this-path-does-not-exist-xyz"):
307+
command = f"""
308+
curl \
309+
--cacert /app/server_certificate.pem \
310+
--silent \
311+
--head \
312+
https://{self.virtual_ip_address}{path}
313+
"""
314+
response = self.run_command_on_the_container(command).decode("utf-8")
315+
response_lines = [line.strip() for line in response.splitlines() if ":" in line]
316+
for header_name, expected_value in expected_headers.items():
317+
matches = [
318+
line.split(":", 1)[1].strip()
319+
for line in response_lines
320+
if line.split(":", 1)[0].strip().lower() == header_name
321+
]
322+
assert \
323+
len(matches) == 1, \
324+
f"""Expected exactly one {header_name} header on {path}, found {len(matches)}: {matches}
325+
\nCommand: {command!r}\nReturned: {response!r}
326+
"""
327+
assert \
328+
matches[0] == expected_value, \
329+
f"""Expected {header_name} to be {expected_value!r} on {path}, got {matches[0]!r}
330+
\nCommand: {command!r}\nReturned: {response!r}
331+
"""
332+
297333
def run_query(self) -> float:
298334
payload = '{"database_id": 1, "runAsync": true, "sql": "SELECT * FROM superset.logs;"}'
299335
command = f"""

0 commit comments

Comments
 (0)