Просмотр исходного кода

fix(auth): let the connected-app consent page load inside Bambuddy's own frames

An app opened from the sidebar signs in inside Bambuddy's iframe, but every
page sent frame-ancestors 'none', so the browser refused to show
/connect/authorize there. That path now gets 'self', like the streaming
overlay: every ancestor must be Bambuddy itself, so foreign pages still
cannot frame it.
maziggy 2 дней назад
Родитель
Сommit
4e83751f71
3 измененных файлов с 38 добавлено и 3 удалено
  1. 1 1
      CHANGELOG.md
  2. 9 1
      backend/app/main.py
  3. 28 1
      backend/tests/integration/test_security_headers.py

+ 1 - 1
CHANGELOG.md

@@ -5,7 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.2.6b1] - Unreleased
 
 ### Added
-- **Connected apps: other applications can sign people in with their Bambuddy account** — Settings → API Keys → Connected Apps registers an app with one exact callback URL and gives it a client ID and a secret, shown once. The app sends the browser to `/connect/authorize`; the user confirms once ("Allow"), and after that sign-in is automatic, so an app opened from the sidebar needs no second login. The app's server then swaps a single-use code for the user's name, email, groups and permissions at `POST /api/v1/connect/token`. It is the OAuth 2.0 authorization-code flow with PKCE (S256 only), kept to what an app on the same network needs: a code lives 60 seconds, works once, is bound to its app, callback URL and PKCE challenge, and is redeemable only with the app's secret; codes and secrets are stored hashed, failed exchanges are rate-limited per app and per IP, and the page never redirects to a callback URL the backend has not matched against the registration. The app never receives the user's password or Bambuddy session, and an API key cannot sign its owner in to an app. Connected apps require authentication to be enabled; with it off, both endpoints answer `auth_disabled` so an app falls back to its own login instead of letting everyone in. See the wiki page *Connected Apps*.
+- **Connected apps: other applications can sign people in with their Bambuddy account** — Settings → API Keys → Connected Apps registers an app with one exact callback URL and gives it a client ID and a secret, shown once. The app sends the browser to `/connect/authorize`; the user confirms once ("Allow"), and after that sign-in is automatic, so an app opened from the sidebar needs no second login. The app's server then swaps a single-use code for the user's name, email, groups and permissions at `POST /api/v1/connect/token`. It is the OAuth 2.0 authorization-code flow with PKCE (S256 only), kept to what an app on the same network needs: a code lives 60 seconds, works once, is bound to its app, callback URL and PKCE challenge, and is redeemable only with the app's secret; codes and secrets are stored hashed, failed exchanges are rate-limited per app and per IP, and the page never redirects to a callback URL the backend has not matched against the registration. The app never receives the user's password or Bambuddy session, and an API key cannot sign its owner in to an app. Connected apps require authentication to be enabled; with it off, both endpoints answer `auth_disabled` so an app falls back to its own login instead of letting everyone in. The consent page may be shown inside Bambuddy's own frames (`frame-ancestors 'self'`), so an app opened from the sidebar can sign in there; every other page still refuses all framing. See the wiki page *Connected Apps*.
 - **A batch can record the external order it fulfils, so an integration can't queue the same order twice** — `POST /queue/batches` accepts `external_source` (e.g. `shopify`) and `external_ref`, a reference to the record in that system; both are returned on every batch and can be filtered on with `GET /queue/batches?external_source=&external_ref=`. The pair is unique: a second create for the same record answers 409 instead of making a second batch, so a connector that retries after losing the response can't print an order twice. Both fields are optional and must be given together; batches created without them behave exactly as before.
 - **PDFs in the File Manager get their thumbnail the moment they arrive (#2976)** — A PDF's grid thumbnail used to exist only once somebody had opened its preview, because the browser rendered it and posted it back; until then the card was blank. Page one is now rendered on the server with PDFium (`pypdfium2`, a new dependency whose wheels carry the PDFium binary for every platform Bambuddy ships, so no system package is needed) on upload, inside an extracted ZIP and when an external folder is scanned. **Generate Thumbnails** covers PDFs too, so existing ones can be backfilled in one click; its tooltip and its nothing-to-do toast say so in all 15 languages. The thumbnail has the same shape as the browser's (a 256 px square, page centred on white), so old and new cards match. PDFium is not thread-safe, so renders are serialised behind a lock and run off the event loop; the render scale comes from the page's own size, so a PDF declaring a huge page cannot make it allocate a huge bitmap. A PDF it cannot read (damaged, encrypted) gets no thumbnail and still lands in the library, and the browser preview remains the fallback.
 - **Colour swatches for printer slots in the Print / Schedule dialog's filament mapping (#3159, requested by @frantiseklorenc)** — The dialog's filament mapping compares the colour the slice asked for against the colour actually in the machine, and until now only the left-hand side of that comparison had a swatch. The right-hand side — the slot, and every slot in its dropdown — was text, and the text is not reliable: a slot's colour name comes from the Color Catalog or, failing that, from hue, so a third-party beige is announced as "Orange". On a farm running twenty or thirty non-Bambu colours that are swapped between machines daily, a "Color mismatch" warning then gives no way to tell a real mismatch from two names for the same hex without opening the printer card in another tab. Each slot now carries its colour, in the picker and on the row, together with its hex; the slot whose colour is exactly the one the slice asked for is ticked. It works for a slot bound to an inventory spool and for one configured through Configure Slot or on the printer itself, which is the distinction that mattered — the second kind has no inventory row behind it and draws the colour the printer reports. A bound spool also contributes what a tray record cannot carry: its remaining colour stops and its effect, so a two-tone or glittery spool draws as itself rather than as its base colour. The same treatment is applied to the filament-override picker used for model-based assignment, which is the same choice on the other dispatch path and would otherwise have stayed text-only. Both controls stop being `<select>`s to do it — an `<option>` renders text and nothing else — and both keep full keyboard operation, listbox semantics and the border colouring that encodes match, same-type-different-colour and not-loaded.

+ 9 - 1
backend/app/main.py

@@ -9695,7 +9695,15 @@ async def security_headers_middleware(request, call_next):
         # overlay — Home Assistant on another port — remains what
         # TRUSTED_FRAME_ORIGINS is for, and _frame_ancestors already folds that
         # allowlist in.
-        embeddable_same_origin = request.url.path.startswith("/overlay/")
+        #
+        # The connected-app consent page (/connect/authorize) gets the same
+        # 'self': an app opened from Bambuddy's sidebar runs in an iframe, and
+        # its "Sign in with Bambuddy" navigates that iframe to this page. With
+        # 'none' the browser refuses to show it even inside Bambuddy. 'self'
+        # requires every ancestor to be this origin, so a foreign page -- a
+        # sidebar link's site included -- still cannot frame the consent
+        # screen to bait a click.
+        embeddable_same_origin = request.url.path.startswith("/overlay/") or request.url.path == "/connect/authorize"
         # 'wasm-unsafe-eval' permits WebAssembly compilation ONLY — it does
         # not allow eval()/Function() for JS, unlike 'unsafe-eval'. Needed by
         # the STEP preview, which triangulates in the browser via OpenCascade

+ 28 - 1
backend/tests/integration/test_security_headers.py

@@ -135,6 +135,24 @@ async def test_overlay_route_allows_same_origin_framing(async_client: AsyncClien
     assert resp.headers.get("X-Frame-Options") == "SAMEORIGIN"
 
 
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_connect_authorize_allows_same_origin_framing(async_client: AsyncClient, monkeypatch):
+    """A connected app opened from the sidebar signs in inside Bambuddy's iframe.
+
+    Its "Sign in with Bambuddy" navigates that iframe to /connect/authorize;
+    with 'none' the browser refused to render the consent page there. 'self'
+    still refuses any foreign framer.
+    """
+    from backend.app import main as main_module
+
+    monkeypatch.setattr(main_module, "_TRUSTED_FRAME_ORIGINS", ())
+
+    resp = await async_client.get("/connect/authorize?client_id=x")
+    assert "frame-ancestors 'self';" in resp.headers.get("Content-Security-Policy", "")
+    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):
@@ -144,7 +162,16 @@ async def test_other_spa_routes_still_refuse_all_framing(async_client: AsyncClie
 
     monkeypatch.setattr(main_module, "_TRUSTED_FRAME_ORIGINS", ())
 
-    for path in ("/", "/settings", "/printers", "/overlays", "/camwall"):
+    for path in (
+        "/",
+        "/settings",
+        "/printers",
+        "/overlays",
+        "/camwall",
+        "/connect",
+        "/connect/authorize/x",
+        "/connect/other",
+    ):
         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"