mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +02:00
The overlay at /overlay/{printer} draws live print data over a
full-screen camera view for OBS, a wall display or any browser source.
It has been tunable since it shipped -- which fields, what size, what
frame rate -- but only through query parameters documented in the wiki,
and temperatures were not among the fields on offer. The request asked
for temperatures first and for the field set to be selectable in the web
UI; this addresses both.
Nozzle, bed and chamber readings join the list. The target is drawn only
while the heater is still climbing, so a settled hotend reads "220°C"
for the rest of the print instead of the noisier "220 / 220°C" -- 219.6
against a target of 220 rounds to the same number, and repeating it says
nothing. Both nozzles appear on a dual-nozzle machine. They are drawn
whether or not a print is running, because a preheating printer is
exactly when they are worth watching, and each reading appears only when
the printer genuinely reports one: chamber temperature stays absent on
P1 and A1 models, which publish a chamber_temper with no sensor behind
it, so the overlay never puts a measurement on screen that does not
exist. Labels reuse the heater chart's strings rather than inventing a
second vocabulary for the same three things.
The feed sends an allow-list rather than the temperatures dict. That
dict doubles as the MQTT client's working memory -- derived heater flags
and private target-set timestamps live alongside the readings -- and an
overlay token is a narrower grant than a login, so it gets exactly what
the overlay draws and does not pick up fields as the dict grows. The
same chamber-sensor gate the full status payload already applies is
applied here. The integration test that asserts the payload's exact key
set, which exists to catch that surface widening silently, is updated
deliberately.
Temperatures are not in the default field set, so an overlay URL already
pasted into a scene renders identically after upgrading.
Settings -> API Keys -> Streaming Overlay now builds the URL: printer,
field checkboxes, size, frame rate, camera toggle, an optional token,
and a copy button. It persists nothing and calls nothing new -- the URL
is the configuration, which keeps a scene reproducible by copy-paste and
lets two displays show different fields off one token. Fields are
emitted in the overlay's own top-to-bottom order rather than click
order, and parameters left at their default are omitted, so the same
selection always produces the same URL. The preview alongside it stays
off until asked for: an always-live iframe would hold a subscriber on
the printer's single camera connection for as long as the settings tab
stayed open.
The preview needed one narrow security-header change. Every SPA route
sent frame-ancestors 'none', which is stricter than the SAMEORIGIN in
X-Frame-Options beside it and refuses even a same-origin frame, so the
preview showed Firefox's "another site has embedded it" page instead of
the overlay. The overlay path now sends 'self', mirroring /gcode-viewer,
which admits a framer only on this origin -- Bambuddy's own UI. Every
other path keeps 'none', and embedding the overlay from another host
still requires TRUSTED_FRAME_ORIGINS.
308 lines
13 KiB
Python
308 lines
13 KiB
Python
"""Integration tests for security_headers_middleware (#1191).
|
|
|
|
Default behaviour is strict: ``X-Frame-Options: SAMEORIGIN`` plus
|
|
``frame-ancestors 'none'`` on the catch-all route, ``frame-ancestors 'self'``
|
|
on /gcode-viewer/. Operators can opt into iframe embedding from trusted
|
|
origins (e.g. Home Assistant on a different port) via the
|
|
``TRUSTED_FRAME_ORIGINS`` env var; when set, X-Frame-Options is dropped and
|
|
``frame-ancestors`` includes the allowlist.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from httpx import AsyncClient
|
|
|
|
# ─── helpers ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _parse_origins(value: str) -> tuple[str, ...]:
|
|
"""Re-import the parser with a specific env var set, return its result.
|
|
|
|
Uses a fresh import so the module-level _TRUSTED_FRAME_ORIGINS is
|
|
re-evaluated against the patched os.environ.
|
|
"""
|
|
import os
|
|
|
|
from backend.app import main as main_module
|
|
|
|
old = os.environ.get("TRUSTED_FRAME_ORIGINS")
|
|
try:
|
|
if value is None:
|
|
os.environ.pop("TRUSTED_FRAME_ORIGINS", None)
|
|
else:
|
|
os.environ["TRUSTED_FRAME_ORIGINS"] = value
|
|
# Function reads from os.environ each call.
|
|
return main_module._parse_trusted_frame_origins()
|
|
finally:
|
|
if old is None:
|
|
os.environ.pop("TRUSTED_FRAME_ORIGINS", None)
|
|
else:
|
|
os.environ["TRUSTED_FRAME_ORIGINS"] = old
|
|
|
|
|
|
# ─── env-var parsing ──────────────────────────────────────────────────────
|
|
|
|
|
|
class TestParseTrustedFrameOrigins:
|
|
"""Unit tests for _parse_trusted_frame_origins."""
|
|
|
|
def test_empty_env_returns_empty_tuple(self):
|
|
assert _parse_origins("") == ()
|
|
|
|
def test_unset_env_returns_empty_tuple(self):
|
|
assert _parse_origins(None) == () # type: ignore[arg-type]
|
|
|
|
def test_single_origin(self):
|
|
assert _parse_origins("http://homeassistant.local:8123") == ("http://homeassistant.local:8123",)
|
|
|
|
def test_multiple_origins(self):
|
|
result = _parse_origins("http://homeassistant.local:8123,https://ha.example.com")
|
|
assert result == ("http://homeassistant.local:8123", "https://ha.example.com")
|
|
|
|
def test_whitespace_around_entries_stripped(self):
|
|
result = _parse_origins(" http://a.local:1 , https://b.local:2 ")
|
|
assert result == ("http://a.local:1", "https://b.local:2")
|
|
|
|
def test_empty_segment_skipped(self):
|
|
result = _parse_origins("http://a.local,,https://b.local")
|
|
assert result == ("http://a.local", "https://b.local")
|
|
|
|
def test_non_http_scheme_dropped(self):
|
|
# ftp://, javascript:, file:// etc. — never a valid frame ancestor.
|
|
assert _parse_origins("ftp://attacker.example,http://ok.local") == ("http://ok.local",)
|
|
assert _parse_origins("javascript:alert(1)") == ()
|
|
|
|
def test_missing_host_dropped(self):
|
|
# "http://" with no host
|
|
assert _parse_origins("http://") == ()
|
|
|
|
def test_path_dropped(self):
|
|
# frame-ancestors only takes scheme://host[:port], no path
|
|
assert _parse_origins("http://ha.local/dashboard") == ()
|
|
|
|
def test_query_or_fragment_dropped(self):
|
|
assert _parse_origins("http://ha.local?foo=1") == ()
|
|
assert _parse_origins("http://ha.local#frag") == ()
|
|
|
|
def test_wildcard_in_host_dropped(self):
|
|
# Wildcards would defeat the allowlist purpose; reject explicitly.
|
|
assert _parse_origins("http://*.example.com") == ()
|
|
|
|
def test_root_path_kept(self):
|
|
# Trailing slash is a degenerate but harmless path; treat as bare host.
|
|
assert _parse_origins("http://ha.local:8123/") == ("http://ha.local:8123",)
|
|
|
|
|
|
# ─── HTTP integration: middleware emits expected headers ──────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_default_headers_strict(async_client: AsyncClient, monkeypatch):
|
|
"""Without env var: X-Frame-Options=SAMEORIGIN and frame-ancestors 'none'."""
|
|
monkeypatch.delenv("TRUSTED_FRAME_ORIGINS", raising=False)
|
|
# Re-import the module-level constant so the middleware closes over the new value.
|
|
from backend.app import main as main_module
|
|
|
|
monkeypatch.setattr(main_module, "_TRUSTED_FRAME_ORIGINS", ())
|
|
|
|
resp = await async_client.get("/api/v1/auth/status")
|
|
assert resp.headers.get("X-Frame-Options") == "SAMEORIGIN"
|
|
assert "frame-ancestors 'none'" in resp.headers.get("Content-Security-Policy", "")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_overlay_route_allows_same_origin_framing(async_client: AsyncClient, monkeypatch):
|
|
"""#1422 — the overlay is framed same-origin by the URL builder's preview.
|
|
|
|
'none' blocks that too, which is why the preview showed Firefox's "will not
|
|
allow Firefox to display the page if another site has embedded it". 'self'
|
|
permits only a framer on this origin — Bambuddy's own UI — so a
|
|
clickjacking page on another host is refused exactly as before.
|
|
"""
|
|
from backend.app import main as main_module
|
|
|
|
monkeypatch.setattr(main_module, "_TRUSTED_FRAME_ORIGINS", ())
|
|
|
|
resp = await async_client.get("/overlay/1")
|
|
csp = resp.headers.get("Content-Security-Policy", "")
|
|
assert "frame-ancestors 'self';" in csp
|
|
# The legacy header already permitted same-origin framing; only the CSP was
|
|
# blocking it. Assert it still says so rather than being dropped.
|
|
assert resp.headers.get("X-Frame-Options") == "SAMEORIGIN"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_other_spa_routes_still_refuse_all_framing(async_client: AsyncClient, monkeypatch):
|
|
"""The #1422 carve-out is the overlay path only — everything else keeps
|
|
'none', including paths that merely start with something similar."""
|
|
from backend.app import main as main_module
|
|
|
|
monkeypatch.setattr(main_module, "_TRUSTED_FRAME_ORIGINS", ())
|
|
|
|
for path in ("/", "/settings", "/printers", "/overlays", "/camwall"):
|
|
resp = await async_client.get(path)
|
|
csp = resp.headers.get("Content-Security-Policy", "")
|
|
assert "frame-ancestors 'none'" in csp, f"{path} must not be framable"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_trusted_origins_relaxes_csp_and_drops_xfo(async_client: AsyncClient, monkeypatch):
|
|
"""With env var set: X-Frame-Options is absent, frame-ancestors lists the origins."""
|
|
from backend.app import main as main_module
|
|
|
|
monkeypatch.setattr(
|
|
main_module,
|
|
"_TRUSTED_FRAME_ORIGINS",
|
|
("http://homeassistant.local:8123",),
|
|
)
|
|
|
|
resp = await async_client.get("/api/v1/auth/status")
|
|
assert "X-Frame-Options" not in resp.headers
|
|
csp = resp.headers.get("Content-Security-Policy", "")
|
|
assert "frame-ancestors 'self' http://homeassistant.local:8123;" in csp
|
|
assert "'none'" not in csp.split("frame-ancestors")[1].split(";")[0]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_trusted_origins_applies_to_docs_branch(async_client: AsyncClient, monkeypatch):
|
|
"""The /docs CSP also honors the allowlist (consistent with main app)."""
|
|
from backend.app import main as main_module
|
|
|
|
monkeypatch.setattr(
|
|
main_module,
|
|
"_TRUSTED_FRAME_ORIGINS",
|
|
("https://ha.example.com",),
|
|
)
|
|
|
|
resp = await async_client.get("/docs")
|
|
csp = resp.headers.get("Content-Security-Policy", "")
|
|
assert "frame-ancestors 'self' https://ha.example.com;" in csp
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_default_block_img_src_excludes_https(async_client: AsyncClient, monkeypatch):
|
|
"""#1333 regression guard: the default SPA CSP must NOT allow img-src https:.
|
|
|
|
Bambuddy's policy for external images is a backend proxy (see
|
|
/api/v1/makerworld/thumbnail and /api/v1/auth/oidc/providers/{id}/icon),
|
|
not a CSP relaxation. If a future change adds ``https:`` to img-src to
|
|
"fix" a broken-image, the proxy pattern silently degrades into a
|
|
do-nothing layer and the entire SPA gains a hot-link surface.
|
|
"""
|
|
from backend.app import main as main_module
|
|
|
|
monkeypatch.setattr(main_module, "_TRUSTED_FRAME_ORIGINS", ())
|
|
|
|
resp = await async_client.get("/api/v1/auth/status")
|
|
csp = resp.headers.get("Content-Security-Policy", "")
|
|
# Extract the img-src directive — splits on ';' for safety against
|
|
# neighbouring directives that happen to contain the substring.
|
|
img_src_directive = next(
|
|
(d.strip() for d in csp.split(";") if d.strip().startswith("img-src")),
|
|
"",
|
|
)
|
|
assert img_src_directive, f"img-src directive missing from CSP: {csp!r}"
|
|
assert "https:" not in img_src_directive, (
|
|
f"img-src must not allow arbitrary https: hosts (proxy external images instead); got: {img_src_directive!r}"
|
|
)
|
|
# Sanity: the legitimately allowed scheme sources are still present.
|
|
assert "'self'" in img_src_directive
|
|
assert "data:" in img_src_directive
|
|
assert "blob:" in img_src_directive
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_other_security_headers_unchanged(async_client: AsyncClient, monkeypatch):
|
|
"""Other headers (X-Content-Type-Options, Referrer-Policy) are not affected."""
|
|
from backend.app import main as main_module
|
|
|
|
# Test in both modes — headers should be the same regardless.
|
|
for origins in [(), ("http://homeassistant.local:8123",)]:
|
|
monkeypatch.setattr(main_module, "_TRUSTED_FRAME_ORIGINS", origins)
|
|
resp = await async_client.get("/api/v1/auth/status")
|
|
assert resp.headers.get("X-Content-Type-Options") == "nosniff"
|
|
assert resp.headers.get("Referrer-Policy") == "strict-origin-when-cross-origin"
|
|
|
|
|
|
# ─── #1460: nonce-based script-src so Cloudflare-injected scripts pass ────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_spa_csp_includes_per_request_script_nonce(async_client: AsyncClient):
|
|
"""SPA CSP must stamp a fresh `'nonce-…'` token into script-src (#1460).
|
|
|
|
Cloudflare's bot-detection inline script is injected after our response
|
|
leaves the app, with a per-load hash that defeats hash allowlisting. When
|
|
a nonce is present in the CSP header, Cloudflare clones it onto its
|
|
injected `<script>` and the CSP passes without `'unsafe-inline'`.
|
|
"""
|
|
import re
|
|
|
|
resp = await async_client.get("/api/v1/auth/status")
|
|
csp = resp.headers.get("Content-Security-Policy", "")
|
|
# Pull out the script-src directive (split on ';' so neighbours don't confuse us).
|
|
script_src = next(
|
|
(d.strip() for d in csp.split(";") if d.strip().startswith("script-src")),
|
|
"",
|
|
)
|
|
assert script_src, f"script-src directive missing: {csp!r}"
|
|
assert "'self'" in script_src, f"script-src must still allow 'self': {script_src!r}"
|
|
# Nonce token is `'nonce-<base64url>'` where the inner value is
|
|
# secrets.token_urlsafe(16) — about 22 url-safe chars.
|
|
assert re.search(r"'nonce-[A-Za-z0-9_-]{16,}'", script_src), (
|
|
f"script-src must include a 'nonce-…' token: {script_src!r}"
|
|
)
|
|
# We deliberately did NOT add 'unsafe-inline' alongside the nonce — that
|
|
# would defeat the purpose of using a nonce in the first place.
|
|
assert "'unsafe-inline'" not in script_src, (
|
|
f"script-src must not relax to 'unsafe-inline' on the SPA route: {script_src!r}"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_spa_csp_nonce_changes_per_request(async_client: AsyncClient):
|
|
"""A nonce is only useful if it's fresh per request (#1460)."""
|
|
import re
|
|
|
|
nonce_re = re.compile(r"'nonce-([A-Za-z0-9_-]+)'")
|
|
|
|
nonces = set()
|
|
for _ in range(5):
|
|
resp = await async_client.get("/api/v1/auth/status")
|
|
csp = resp.headers.get("Content-Security-Policy", "")
|
|
m = nonce_re.search(csp)
|
|
assert m, f"no nonce in CSP: {csp!r}"
|
|
nonces.add(m.group(1))
|
|
# 5 random 16-byte tokens collide with probability ~0 — anything less
|
|
# than all-5-distinct means we're handing out a stale/global nonce.
|
|
assert len(nonces) == 5, f"nonces should be per-request, got {nonces!r}"
|
|
|
|
|
|
# ─── #1460: HEAD on PWA bootstrap routes (manifest / sw / sw-register) ───
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
@pytest.mark.parametrize("path", ["/manifest.json", "/sw.js", "/sw-register.js"])
|
|
async def test_pwa_bootstrap_routes_accept_head(async_client: AsyncClient, path: str):
|
|
"""Scanners and `curl -I` HEAD-probe these — must not 405 (#1460).
|
|
|
|
Previously these were `@app.get` only, so HEAD returned 405 Method Not
|
|
Allowed and looked like a manifest/SW server-side bug when debugging
|
|
Cloudflare-fronted deployments.
|
|
"""
|
|
resp = await async_client.head(path)
|
|
# 200 if static asset is present in the test environment, 404 if it's
|
|
# not packaged in this checkout — but never 405.
|
|
assert resp.status_code != 405, f"HEAD {path} returned 405 — route must accept HEAD as well as GET"
|