test_security_headers.py 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  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, ``frame-ancestors 'self'``
  4. on /gcode-viewer/. Operators can opt into iframe embedding from trusted
  5. origins (e.g. Home Assistant on a different port) via the
  6. ``TRUSTED_FRAME_ORIGINS`` env var; when set, X-Frame-Options is dropped and
  7. ``frame-ancestors`` includes the allowlist.
  8. """
  9. from __future__ import annotations
  10. import pytest
  11. from httpx import AsyncClient
  12. # ─── helpers ──────────────────────────────────────────────────────────────
  13. def _parse_origins(value: str) -> tuple[str, ...]:
  14. """Re-import the parser with a specific env var set, return its result.
  15. Uses a fresh import so the module-level _TRUSTED_FRAME_ORIGINS is
  16. re-evaluated against the patched os.environ.
  17. """
  18. import os
  19. from backend.app import main as main_module
  20. old = os.environ.get("TRUSTED_FRAME_ORIGINS")
  21. try:
  22. if value is None:
  23. os.environ.pop("TRUSTED_FRAME_ORIGINS", None)
  24. else:
  25. os.environ["TRUSTED_FRAME_ORIGINS"] = value
  26. # Function reads from os.environ each call.
  27. return main_module._parse_trusted_frame_origins()
  28. finally:
  29. if old is None:
  30. os.environ.pop("TRUSTED_FRAME_ORIGINS", None)
  31. else:
  32. os.environ["TRUSTED_FRAME_ORIGINS"] = old
  33. # ─── env-var parsing ──────────────────────────────────────────────────────
  34. class TestParseTrustedFrameOrigins:
  35. """Unit tests for _parse_trusted_frame_origins."""
  36. def test_empty_env_returns_empty_tuple(self):
  37. assert _parse_origins("") == ()
  38. def test_unset_env_returns_empty_tuple(self):
  39. assert _parse_origins(None) == () # type: ignore[arg-type]
  40. def test_single_origin(self):
  41. assert _parse_origins("http://homeassistant.local:8123") == ("http://homeassistant.local:8123",)
  42. def test_multiple_origins(self):
  43. result = _parse_origins("http://homeassistant.local:8123,https://ha.example.com")
  44. assert result == ("http://homeassistant.local:8123", "https://ha.example.com")
  45. def test_whitespace_around_entries_stripped(self):
  46. result = _parse_origins(" http://a.local:1 , https://b.local:2 ")
  47. assert result == ("http://a.local:1", "https://b.local:2")
  48. def test_empty_segment_skipped(self):
  49. result = _parse_origins("http://a.local,,https://b.local")
  50. assert result == ("http://a.local", "https://b.local")
  51. def test_non_http_scheme_dropped(self):
  52. # ftp://, javascript:, file:// etc. — never a valid frame ancestor.
  53. assert _parse_origins("ftp://attacker.example,http://ok.local") == ("http://ok.local",)
  54. assert _parse_origins("javascript:alert(1)") == ()
  55. def test_missing_host_dropped(self):
  56. # "http://" with no host
  57. assert _parse_origins("http://") == ()
  58. def test_path_dropped(self):
  59. # frame-ancestors only takes scheme://host[:port], no path
  60. assert _parse_origins("http://ha.local/dashboard") == ()
  61. def test_query_or_fragment_dropped(self):
  62. assert _parse_origins("http://ha.local?foo=1") == ()
  63. assert _parse_origins("http://ha.local#frag") == ()
  64. def test_wildcard_in_host_dropped(self):
  65. # Wildcards would defeat the allowlist purpose; reject explicitly.
  66. assert _parse_origins("http://*.example.com") == ()
  67. def test_root_path_kept(self):
  68. # Trailing slash is a degenerate but harmless path; treat as bare host.
  69. assert _parse_origins("http://ha.local:8123/") == ("http://ha.local:8123",)
  70. # ─── HTTP integration: middleware emits expected headers ──────────────────
  71. @pytest.mark.asyncio
  72. @pytest.mark.integration
  73. async def test_default_headers_strict(async_client: AsyncClient, monkeypatch):
  74. """Without env var: X-Frame-Options=SAMEORIGIN and frame-ancestors 'none'."""
  75. monkeypatch.delenv("TRUSTED_FRAME_ORIGINS", raising=False)
  76. # Re-import the module-level constant so the middleware closes over the new value.
  77. from backend.app import main as main_module
  78. monkeypatch.setattr(main_module, "_TRUSTED_FRAME_ORIGINS", ())
  79. resp = await async_client.get("/api/v1/auth/status")
  80. assert resp.headers.get("X-Frame-Options") == "SAMEORIGIN"
  81. assert "frame-ancestors 'none'" in resp.headers.get("Content-Security-Policy", "")
  82. @pytest.mark.asyncio
  83. @pytest.mark.integration
  84. async def test_trusted_origins_relaxes_csp_and_drops_xfo(async_client: AsyncClient, monkeypatch):
  85. """With env var set: X-Frame-Options is absent, frame-ancestors lists the origins."""
  86. from backend.app import main as main_module
  87. monkeypatch.setattr(
  88. main_module,
  89. "_TRUSTED_FRAME_ORIGINS",
  90. ("http://homeassistant.local:8123",),
  91. )
  92. resp = await async_client.get("/api/v1/auth/status")
  93. assert "X-Frame-Options" not in resp.headers
  94. csp = resp.headers.get("Content-Security-Policy", "")
  95. assert "frame-ancestors 'self' http://homeassistant.local:8123;" in csp
  96. assert "'none'" not in csp.split("frame-ancestors")[1].split(";")[0]
  97. @pytest.mark.asyncio
  98. @pytest.mark.integration
  99. async def test_trusted_origins_applies_to_docs_branch(async_client: AsyncClient, monkeypatch):
  100. """The /docs CSP also honors the allowlist (consistent with main app)."""
  101. from backend.app import main as main_module
  102. monkeypatch.setattr(
  103. main_module,
  104. "_TRUSTED_FRAME_ORIGINS",
  105. ("https://ha.example.com",),
  106. )
  107. resp = await async_client.get("/docs")
  108. csp = resp.headers.get("Content-Security-Policy", "")
  109. assert "frame-ancestors 'self' https://ha.example.com;" in csp
  110. @pytest.mark.asyncio
  111. @pytest.mark.integration
  112. async def test_default_block_img_src_excludes_https(async_client: AsyncClient, monkeypatch):
  113. """#1333 regression guard: the default SPA CSP must NOT allow img-src https:.
  114. Bambuddy's policy for external images is a backend proxy (see
  115. /api/v1/makerworld/thumbnail and /api/v1/auth/oidc/providers/{id}/icon),
  116. not a CSP relaxation. If a future change adds ``https:`` to img-src to
  117. "fix" a broken-image, the proxy pattern silently degrades into a
  118. do-nothing layer and the entire SPA gains a hot-link surface.
  119. """
  120. from backend.app import main as main_module
  121. monkeypatch.setattr(main_module, "_TRUSTED_FRAME_ORIGINS", ())
  122. resp = await async_client.get("/api/v1/auth/status")
  123. csp = resp.headers.get("Content-Security-Policy", "")
  124. # Extract the img-src directive — splits on ';' for safety against
  125. # neighbouring directives that happen to contain the substring.
  126. img_src_directive = next(
  127. (d.strip() for d in csp.split(";") if d.strip().startswith("img-src")),
  128. "",
  129. )
  130. assert img_src_directive, f"img-src directive missing from CSP: {csp!r}"
  131. assert "https:" not in img_src_directive, (
  132. f"img-src must not allow arbitrary https: hosts (proxy external images instead); got: {img_src_directive!r}"
  133. )
  134. # Sanity: the legitimately allowed scheme sources are still present.
  135. assert "'self'" in img_src_directive
  136. assert "data:" in img_src_directive
  137. assert "blob:" in img_src_directive
  138. @pytest.mark.asyncio
  139. @pytest.mark.integration
  140. async def test_other_security_headers_unchanged(async_client: AsyncClient, monkeypatch):
  141. """Other headers (X-Content-Type-Options, Referrer-Policy) are not affected."""
  142. from backend.app import main as main_module
  143. # Test in both modes — headers should be the same regardless.
  144. for origins in [(), ("http://homeassistant.local:8123",)]:
  145. monkeypatch.setattr(main_module, "_TRUSTED_FRAME_ORIGINS", origins)
  146. resp = await async_client.get("/api/v1/auth/status")
  147. assert resp.headers.get("X-Content-Type-Options") == "nosniff"
  148. assert resp.headers.get("Referrer-Policy") == "strict-origin-when-cross-origin"