test_security_headers.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390
  1. """Integration tests for security_headers_middleware (#1191).
  2. Default behaviour is strict: ``X-Frame-Options: SAMEORIGIN`` plus
  3. ``frame-ancestors 'none'`` on the catch-all route, and ``frame-ancestors
  4. 'self'`` on the streaming overlay, which the Settings URL builder previews
  5. same-origin. Operators can opt into iframe embedding from trusted
  6. origins (e.g. Home Assistant on a different port) via the
  7. ``TRUSTED_FRAME_ORIGINS`` env var; when set, X-Frame-Options is dropped and
  8. ``frame-ancestors`` includes the allowlist.
  9. """
  10. from __future__ import annotations
  11. import pytest
  12. from httpx import AsyncClient
  13. # ─── helpers ──────────────────────────────────────────────────────────────
  14. def _parse_origins(value: str) -> tuple[str, ...]:
  15. """Re-import the parser with a specific env var set, return its result.
  16. Uses a fresh import so the module-level _TRUSTED_FRAME_ORIGINS is
  17. re-evaluated against the patched os.environ.
  18. """
  19. import os
  20. from backend.app import main as main_module
  21. old = os.environ.get("TRUSTED_FRAME_ORIGINS")
  22. try:
  23. if value is None:
  24. os.environ.pop("TRUSTED_FRAME_ORIGINS", None)
  25. else:
  26. os.environ["TRUSTED_FRAME_ORIGINS"] = value
  27. # Function reads from os.environ each call.
  28. return main_module._parse_trusted_frame_origins()
  29. finally:
  30. if old is None:
  31. os.environ.pop("TRUSTED_FRAME_ORIGINS", None)
  32. else:
  33. os.environ["TRUSTED_FRAME_ORIGINS"] = old
  34. # ─── env-var parsing ──────────────────────────────────────────────────────
  35. class TestParseTrustedFrameOrigins:
  36. """Unit tests for _parse_trusted_frame_origins."""
  37. def test_empty_env_returns_empty_tuple(self):
  38. assert _parse_origins("") == ()
  39. def test_unset_env_returns_empty_tuple(self):
  40. assert _parse_origins(None) == () # type: ignore[arg-type]
  41. def test_single_origin(self):
  42. assert _parse_origins("http://homeassistant.local:8123") == ("http://homeassistant.local:8123",)
  43. def test_multiple_origins(self):
  44. result = _parse_origins("http://homeassistant.local:8123,https://ha.example.com")
  45. assert result == ("http://homeassistant.local:8123", "https://ha.example.com")
  46. def test_whitespace_around_entries_stripped(self):
  47. result = _parse_origins(" http://a.local:1 , https://b.local:2 ")
  48. assert result == ("http://a.local:1", "https://b.local:2")
  49. def test_empty_segment_skipped(self):
  50. result = _parse_origins("http://a.local,,https://b.local")
  51. assert result == ("http://a.local", "https://b.local")
  52. def test_non_http_scheme_dropped(self):
  53. # ftp://, javascript:, file:// etc. — never a valid frame ancestor.
  54. assert _parse_origins("ftp://attacker.example,http://ok.local") == ("http://ok.local",)
  55. assert _parse_origins("javascript:alert(1)") == ()
  56. def test_missing_host_dropped(self):
  57. # "http://" with no host
  58. assert _parse_origins("http://") == ()
  59. def test_path_dropped(self):
  60. # frame-ancestors only takes scheme://host[:port], no path
  61. assert _parse_origins("http://ha.local/dashboard") == ()
  62. def test_query_or_fragment_dropped(self):
  63. assert _parse_origins("http://ha.local?foo=1") == ()
  64. assert _parse_origins("http://ha.local#frag") == ()
  65. def test_wildcard_in_host_dropped(self):
  66. # Wildcards would defeat the allowlist purpose; reject explicitly.
  67. assert _parse_origins("http://*.example.com") == ()
  68. def test_root_path_kept(self):
  69. # Trailing slash is a degenerate but harmless path; treat as bare host.
  70. assert _parse_origins("http://ha.local:8123/") == ("http://ha.local:8123",)
  71. # ─── HTTP integration: middleware emits expected headers ──────────────────
  72. @pytest.mark.asyncio
  73. @pytest.mark.integration
  74. async def test_default_headers_strict(async_client: AsyncClient, monkeypatch):
  75. """Without env var: X-Frame-Options=SAMEORIGIN and frame-ancestors 'none'."""
  76. monkeypatch.delenv("TRUSTED_FRAME_ORIGINS", raising=False)
  77. # Re-import the module-level constant so the middleware closes over the new value.
  78. from backend.app import main as main_module
  79. monkeypatch.setattr(main_module, "_TRUSTED_FRAME_ORIGINS", ())
  80. resp = await async_client.get("/api/v1/auth/status")
  81. assert resp.headers.get("X-Frame-Options") == "SAMEORIGIN"
  82. assert "frame-ancestors 'none'" in resp.headers.get("Content-Security-Policy", "")
  83. @pytest.mark.asyncio
  84. @pytest.mark.integration
  85. async def test_overlay_route_allows_same_origin_framing(async_client: AsyncClient, monkeypatch):
  86. """#1422 — the overlay is framed same-origin by the URL builder's preview.
  87. 'none' blocks that too, which is why the preview showed Firefox's "will not
  88. allow Firefox to display the page if another site has embedded it". 'self'
  89. permits only a framer on this origin — Bambuddy's own UI — so a
  90. clickjacking page on another host is refused exactly as before.
  91. """
  92. from backend.app import main as main_module
  93. monkeypatch.setattr(main_module, "_TRUSTED_FRAME_ORIGINS", ())
  94. resp = await async_client.get("/overlay/1")
  95. csp = resp.headers.get("Content-Security-Policy", "")
  96. assert "frame-ancestors 'self';" in csp
  97. # The legacy header already permitted same-origin framing; only the CSP was
  98. # blocking it. Assert it still says so rather than being dropped.
  99. assert resp.headers.get("X-Frame-Options") == "SAMEORIGIN"
  100. @pytest.mark.asyncio
  101. @pytest.mark.integration
  102. async def test_connect_authorize_allows_same_origin_framing(async_client: AsyncClient, monkeypatch):
  103. """A connected app opened from the sidebar signs in inside Bambuddy's iframe.
  104. Its "Sign in with Bambuddy" navigates that iframe to /connect/authorize;
  105. with 'none' the browser refused to render the consent page there. 'self'
  106. still refuses any foreign framer.
  107. """
  108. from backend.app import main as main_module
  109. monkeypatch.setattr(main_module, "_TRUSTED_FRAME_ORIGINS", ())
  110. resp = await async_client.get("/connect/authorize?client_id=x")
  111. assert "frame-ancestors 'self';" in resp.headers.get("Content-Security-Policy", "")
  112. assert resp.headers.get("X-Frame-Options") == "SAMEORIGIN"
  113. @pytest.mark.asyncio
  114. @pytest.mark.integration
  115. async def test_other_spa_routes_still_refuse_all_framing(async_client: AsyncClient, monkeypatch):
  116. """The #1422 carve-out is the overlay path only — everything else keeps
  117. 'none', including paths that merely start with something similar."""
  118. from backend.app import main as main_module
  119. monkeypatch.setattr(main_module, "_TRUSTED_FRAME_ORIGINS", ())
  120. for path in (
  121. "/",
  122. "/settings",
  123. "/printers",
  124. "/overlays",
  125. "/camwall",
  126. "/connect",
  127. "/connect/authorize/x",
  128. "/connect/other",
  129. ):
  130. resp = await async_client.get(path)
  131. csp = resp.headers.get("Content-Security-Policy", "")
  132. assert "frame-ancestors 'none'" in csp, f"{path} must not be framable"
  133. @pytest.mark.asyncio
  134. @pytest.mark.integration
  135. async def test_trusted_origins_relaxes_csp_and_drops_xfo(async_client: AsyncClient, monkeypatch):
  136. """With env var set: X-Frame-Options is absent, frame-ancestors lists the origins."""
  137. from backend.app import main as main_module
  138. monkeypatch.setattr(
  139. main_module,
  140. "_TRUSTED_FRAME_ORIGINS",
  141. ("http://homeassistant.local:8123",),
  142. )
  143. resp = await async_client.get("/api/v1/auth/status")
  144. assert "X-Frame-Options" not in resp.headers
  145. csp = resp.headers.get("Content-Security-Policy", "")
  146. assert "frame-ancestors 'self' http://homeassistant.local:8123;" in csp
  147. assert "'none'" not in csp.split("frame-ancestors")[1].split(";")[0]
  148. @pytest.mark.asyncio
  149. @pytest.mark.integration
  150. async def test_trusted_origins_applies_to_docs_branch(async_client: AsyncClient, monkeypatch):
  151. """The /docs CSP also honors the allowlist (consistent with main app)."""
  152. from backend.app import main as main_module
  153. monkeypatch.setattr(
  154. main_module,
  155. "_TRUSTED_FRAME_ORIGINS",
  156. ("https://ha.example.com",),
  157. )
  158. resp = await async_client.get("/docs")
  159. csp = resp.headers.get("Content-Security-Policy", "")
  160. assert "frame-ancestors 'self' https://ha.example.com;" in csp
  161. @pytest.mark.asyncio
  162. @pytest.mark.integration
  163. async def test_default_block_img_src_excludes_https(async_client: AsyncClient, monkeypatch):
  164. """#1333 regression guard: the default SPA CSP must NOT allow img-src https:.
  165. Bambuddy's policy for external images is a backend proxy (see
  166. /api/v1/makerworld/thumbnail and /api/v1/auth/oidc/providers/{id}/icon),
  167. not a CSP relaxation. If a future change adds ``https:`` to img-src to
  168. "fix" a broken-image, the proxy pattern silently degrades into a
  169. do-nothing layer and the entire SPA gains a hot-link surface.
  170. """
  171. from backend.app import main as main_module
  172. monkeypatch.setattr(main_module, "_TRUSTED_FRAME_ORIGINS", ())
  173. resp = await async_client.get("/api/v1/auth/status")
  174. csp = resp.headers.get("Content-Security-Policy", "")
  175. # Extract the img-src directive — splits on ';' for safety against
  176. # neighbouring directives that happen to contain the substring.
  177. img_src_directive = next(
  178. (d.strip() for d in csp.split(";") if d.strip().startswith("img-src")),
  179. "",
  180. )
  181. assert img_src_directive, f"img-src directive missing from CSP: {csp!r}"
  182. assert "https:" not in img_src_directive, (
  183. f"img-src must not allow arbitrary https: hosts (proxy external images instead); got: {img_src_directive!r}"
  184. )
  185. # Sanity: the legitimately allowed scheme sources are still present.
  186. assert "'self'" in img_src_directive
  187. assert "data:" in img_src_directive
  188. assert "blob:" in img_src_directive
  189. @pytest.mark.asyncio
  190. @pytest.mark.integration
  191. async def test_other_security_headers_unchanged(async_client: AsyncClient, monkeypatch):
  192. """Other headers (X-Content-Type-Options, Referrer-Policy) are not affected."""
  193. from backend.app import main as main_module
  194. # Test in both modes — headers should be the same regardless.
  195. for origins in [(), ("http://homeassistant.local:8123",)]:
  196. monkeypatch.setattr(main_module, "_TRUSTED_FRAME_ORIGINS", origins)
  197. resp = await async_client.get("/api/v1/auth/status")
  198. assert resp.headers.get("X-Content-Type-Options") == "nosniff"
  199. assert resp.headers.get("Referrer-Policy") == "strict-origin-when-cross-origin"
  200. # ─── #1460: nonce-based script-src so Cloudflare-injected scripts pass ────
  201. @pytest.mark.asyncio
  202. @pytest.mark.integration
  203. async def test_spa_csp_includes_per_request_script_nonce(async_client: AsyncClient):
  204. """SPA CSP must stamp a fresh `'nonce-…'` token into script-src (#1460).
  205. Cloudflare's bot-detection inline script is injected after our response
  206. leaves the app, with a per-load hash that defeats hash allowlisting. When
  207. a nonce is present in the CSP header, Cloudflare clones it onto its
  208. injected `<script>` and the CSP passes without `'unsafe-inline'`.
  209. """
  210. import re
  211. resp = await async_client.get("/api/v1/auth/status")
  212. csp = resp.headers.get("Content-Security-Policy", "")
  213. # Pull out the script-src directive (split on ';' so neighbours don't confuse us).
  214. script_src = next(
  215. (d.strip() for d in csp.split(";") if d.strip().startswith("script-src")),
  216. "",
  217. )
  218. assert script_src, f"script-src directive missing: {csp!r}"
  219. assert "'self'" in script_src, f"script-src must still allow 'self': {script_src!r}"
  220. # Nonce token is `'nonce-<base64url>'` where the inner value is
  221. # secrets.token_urlsafe(16) — about 22 url-safe chars.
  222. assert re.search(r"'nonce-[A-Za-z0-9_-]{16,}'", script_src), (
  223. f"script-src must include a 'nonce-…' token: {script_src!r}"
  224. )
  225. # We deliberately did NOT add 'unsafe-inline' alongside the nonce — that
  226. # would defeat the purpose of using a nonce in the first place.
  227. assert "'unsafe-inline'" not in script_src, (
  228. f"script-src must not relax to 'unsafe-inline' on the SPA route: {script_src!r}"
  229. )
  230. @pytest.mark.asyncio
  231. @pytest.mark.integration
  232. async def test_spa_csp_nonce_changes_per_request(async_client: AsyncClient):
  233. """A nonce is only useful if it's fresh per request (#1460)."""
  234. import re
  235. nonce_re = re.compile(r"'nonce-([A-Za-z0-9_-]+)'")
  236. nonces = set()
  237. for _ in range(5):
  238. resp = await async_client.get("/api/v1/auth/status")
  239. csp = resp.headers.get("Content-Security-Policy", "")
  240. m = nonce_re.search(csp)
  241. assert m, f"no nonce in CSP: {csp!r}"
  242. nonces.add(m.group(1))
  243. # 5 random 16-byte tokens collide with probability ~0 — anything less
  244. # than all-5-distinct means we're handing out a stale/global nonce.
  245. assert len(nonces) == 5, f"nonces should be per-request, got {nonces!r}"
  246. # ─── #2976: STEP preview needs WebAssembly, and only WebAssembly ─────────
  247. @pytest.mark.asyncio
  248. @pytest.mark.integration
  249. async def test_spa_csp_allows_wasm_but_not_eval(async_client: AsyncClient):
  250. """script-src must carry 'wasm-unsafe-eval' but never 'unsafe-eval' (#2976).
  251. The STEP preview triangulates in the browser via OpenCascade compiled to
  252. WASM; without 'wasm-unsafe-eval' the nonce-based CSP blocks
  253. WebAssembly.instantiate() and the preview dies with a CompileError.
  254. 'wasm-unsafe-eval' permits wasm compilation only — JS eval()/Function()
  255. stay blocked, which is what the second assertion pins.
  256. """
  257. resp = await async_client.get("/api/v1/auth/status")
  258. csp = resp.headers.get("Content-Security-Policy", "")
  259. script_src = next(
  260. (d.strip() for d in csp.split(";") if d.strip().startswith("script-src")),
  261. "",
  262. )
  263. assert "'wasm-unsafe-eval'" in script_src, f"script-src must allow wasm compilation: {script_src!r}"
  264. # Substring check must not be fooled by 'wasm-unsafe-eval' containing
  265. # "unsafe-eval" — compare whole tokens.
  266. tokens = script_src.split()
  267. assert "'unsafe-eval'" not in tokens, f"script-src must not allow JS eval: {script_src!r}"
  268. @pytest.mark.asyncio
  269. @pytest.mark.integration
  270. async def test_step_worker_asset_csp_relaxes_eval_only_for_that_file(async_client: AsyncClient):
  271. """Only the STEP worker script's own response may carry 'unsafe-eval' (#2976).
  272. The occt-import-js embind glue generates invokers with `new Function`,
  273. so the dedicated worker needs an eval-permitting policy. Per CSP3 a
  274. worker is governed by the policy delivered with its own script response,
  275. which confines eval to that DOM-less context. Any other asset — and the
  276. SPA document itself — must stay nonce-strict. Both requests 404 in the
  277. test checkout; the security middleware stamps headers regardless.
  278. """
  279. def script_src_tokens(resp) -> list[str]:
  280. csp = resp.headers.get("Content-Security-Policy", "")
  281. directive = next(
  282. (d.strip() for d in csp.split(";") if d.strip().startswith("script-src")),
  283. "",
  284. )
  285. return directive.split()
  286. worker = await async_client.get("/assets/stepPreview.worker-Ck9aB12c.js")
  287. assert "'unsafe-eval'" in script_src_tokens(worker), "step worker script must be allowed to eval"
  288. other = await async_client.get("/assets/index-Ck9aB12c.js")
  289. assert "'unsafe-eval'" not in script_src_tokens(other), "ordinary assets must stay eval-free"
  290. # ─── #1460: HEAD on PWA bootstrap routes (manifest / sw / sw-register) ───
  291. @pytest.mark.asyncio
  292. @pytest.mark.integration
  293. @pytest.mark.parametrize("path", ["/manifest.json", "/sw.js", "/sw-register.js"])
  294. async def test_pwa_bootstrap_routes_accept_head(async_client: AsyncClient, path: str):
  295. """Scanners and `curl -I` HEAD-probe these — must not 405 (#1460).
  296. Previously these were `@app.get` only, so HEAD returned 405 Method Not
  297. Allowed and looked like a manifest/SW server-side bug when debugging
  298. Cloudflare-fronted deployments.
  299. """
  300. resp = await async_client.head(path)
  301. # 200 if static asset is present in the test environment, 404 if it's
  302. # not packaged in this checkout — but never 405.
  303. assert resp.status_code != 405, f"HEAD {path} returned 405 — route must accept HEAD as well as GET"