Explorar el Código

fix: unbreak CSP for sidebar iframes, service worker, and Google Fonts

  The strict CSP added in 0.2.3b4 blocked three things at once:
  external sidebar-link iframes (no frame-src declared, so they fell
  back to default-src 'self'), the inline service-worker registration
  script in index.html, and the Google Fonts @import used for Inter.

  - Add `frame-src 'self' https:` so user-configured HTTPS iframe
    targets load; frame-ancestors 'none' still prevents Bambuddy
    itself from being framed cross-origin.
  - Move the inline SW-registration script into public/sw-register.js
    so `script-src 'self'` covers it without 'unsafe-inline' or
    per-build hashes.
  - Allow fonts.googleapis.com in style-src and fonts.gstatic.com in
    font-src so the Inter webfont loads.
maziggy hace 1 mes
padre
commit
53a70e37d9
Se han modificado 6 ficheros con 69 adiciones y 54 borrados
  1. 1 0
      CHANGELOG.md
  2. 16 2
      backend/app/main.py
  3. 4 26
      frontend/index.html
  4. 22 0
      frontend/public/sw-register.js
  5. 4 26
      static/index.html
  6. 22 0
      static/sw-register.js

+ 1 - 0
CHANGELOG.md

@@ -19,6 +19,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Clear Plate Confirmation Bypassed on Power Cycle** ([#961](https://github.com/maziggy/bambuddy/issues/961)) — With Auto Off enabled and another job queued, the smart plug would cut power when a print finished and immediately re-power when the scheduler saw the queue, at which point the printer booted fresh into `IDLE` and the next job auto-dispatched without the "Clear Plate & Start Next" confirmation. Root cause: the plate-cleared gate lived only in the in-memory `PrinterManager._plate_cleared` set, and the scheduler's idle check treated `IDLE` as always-idle regardless of whether a previous finish had been acknowledged — so the gate was lost across both Bambuddy restarts and the IDLE-on-boot state transition. The gate is now an `awaiting_plate_clear` column on the `printers` table, set by `on_print_complete` when a print finishes or fails, cleared by the `/printers/{id}/clear-plate` endpoint and by the scheduler when it dispatches the next job, and rehydrated from the DB into `PrinterManager` on startup. `_is_printer_idle` now short-circuits to not-idle whenever `require_plate_clear` is on and the printer is awaiting ack, regardless of the currently reported state — so the prompt survives Auto Off cycles, Bambuddy restarts, and the printer booting back into `IDLE`. The clear-plate endpoint no longer requires the printer to currently report `FINISH`/`FAILED` (it accepts the ack whenever the awaiting flag is set), and the Printers page widget prompts based on the flag rather than the reported state. Thanks to @miaopas for reporting.
 - **Clear Plate Confirmation Bypassed on Power Cycle** ([#961](https://github.com/maziggy/bambuddy/issues/961)) — With Auto Off enabled and another job queued, the smart plug would cut power when a print finished and immediately re-power when the scheduler saw the queue, at which point the printer booted fresh into `IDLE` and the next job auto-dispatched without the "Clear Plate & Start Next" confirmation. Root cause: the plate-cleared gate lived only in the in-memory `PrinterManager._plate_cleared` set, and the scheduler's idle check treated `IDLE` as always-idle regardless of whether a previous finish had been acknowledged — so the gate was lost across both Bambuddy restarts and the IDLE-on-boot state transition. The gate is now an `awaiting_plate_clear` column on the `printers` table, set by `on_print_complete` when a print finishes or fails, cleared by the `/printers/{id}/clear-plate` endpoint and by the scheduler when it dispatches the next job, and rehydrated from the DB into `PrinterManager` on startup. `_is_printer_idle` now short-circuits to not-idle whenever `require_plate_clear` is on and the printer is awaiting ack, regardless of the currently reported state — so the prompt survives Auto Off cycles, Bambuddy restarts, and the printer booting back into `IDLE`. The clear-plate endpoint no longer requires the printer to currently report `FINISH`/`FAILED` (it accepts the ack whenever the awaiting flag is set), and the Printers page widget prompts based on the flag rather than the reported state. Thanks to @miaopas for reporting.
 - **Insecure Temp File Creation in Backup Export** — The manual backup download endpoint used `tempfile.mktemp()`, which is vulnerable to a symlink race condition (CWE-377). Replaced with `tempfile.mkstemp()` which atomically creates the file, eliminating the TOCTOU window.
 - **Insecure Temp File Creation in Backup Export** — The manual backup download endpoint used `tempfile.mktemp()`, which is vulnerable to a symlink race condition (CWE-377). Replaced with `tempfile.mkstemp()` which atomically creates the file, eliminating the TOCTOU window.
 - **Spoolman Iframe Blocked After 0.2.3b4 Security Headers** — The Spoolman page (Inventory → Spoolman iframe) failed to load when Spoolman was served from the same host as Bambuddy via a reverse proxy. The security-headers middleware added in 0.2.3b4 set `X-Frame-Options: DENY` on every response, which blocked even same-origin iframing. Relaxed to `SAMEORIGIN` so Spoolman (and any other same-origin tool behind the same reverse proxy) can be embedded again, while still preventing cross-origin clickjacking.
 - **Spoolman Iframe Blocked After 0.2.3b4 Security Headers** — The Spoolman page (Inventory → Spoolman iframe) failed to load when Spoolman was served from the same host as Bambuddy via a reverse proxy. The security-headers middleware added in 0.2.3b4 set `X-Frame-Options: DENY` on every response, which blocked even same-origin iframing. Relaxed to `SAMEORIGIN` so Spoolman (and any other same-origin tool behind the same reverse proxy) can be embedded again, while still preventing cross-origin clickjacking.
+- **CSP Blocked Sidebar Iframes, Service-Worker Registration, and Google Fonts** — The strict `Content-Security-Policy` header added in 0.2.3b4 broke three things at once: (1) custom sidebar links pointing at external HTTPS URLs (e.g. a Grafana/telemetry dashboard) rendered in `ExternalLinkPage` were blocked because no `frame-src` was declared and iframes fell back to `default-src 'self'`; (2) the inline service-worker registration `<script>` at the bottom of `index.html` was blocked by `script-src 'self'`, silently preventing the PWA service worker from installing; (3) the `@import` of Google Fonts' Inter from `index.css` was blocked by `style-src` and `font-src`. Fixed by adding `frame-src 'self' https:` for user-configured HTTPS iframe targets, moving the inline SW-registration script into `/sw-register.js` so `script-src 'self'` covers it without needing `'unsafe-inline'` or per-build hashes, and allowing `https://fonts.googleapis.com` in `style-src` and `https://fonts.gstatic.com` in `font-src`. `frame-ancestors 'none'` is preserved so Bambuddy itself still cannot be framed cross-origin.
 
 
 
 
 ## [0.2.3b3] - 2026-04-12
 ## [0.2.3b3] - 2026-04-12

+ 16 - 2
backend/app/main.py

@@ -4173,13 +4173,14 @@ async def security_headers_middleware(request, call_next):
     response.headers["Content-Security-Policy"] = (
     response.headers["Content-Security-Policy"] = (
         "default-src 'self'; "
         "default-src 'self'; "
         "script-src 'self'; "
         "script-src 'self'; "
-        "style-src 'self' 'unsafe-inline'; "
+        "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
         "img-src 'self' data: blob:; "
         "img-src 'self' data: blob:; "
         "media-src 'self' blob:; "
         "media-src 'self' blob:; "
         "connect-src 'self' ws: wss:; "
         "connect-src 'self' ws: wss:; "
-        "font-src 'self' data:; "
+        "font-src 'self' data: https://fonts.gstatic.com; "
         "object-src 'none'; "
         "object-src 'none'; "
         "base-uri 'self'; "
         "base-uri 'self'; "
+        "frame-src 'self' https:; "
         "frame-ancestors 'none';"
         "frame-ancestors 'none';"
     )
     )
     if request.url.scheme == "https":
     if request.url.scheme == "https":
@@ -4416,6 +4417,19 @@ async def serve_service_worker():
     return {"error": "Service worker not found"}
     return {"error": "Service worker not found"}
 
 
 
 
+@app.get("/sw-register.js")
+async def serve_sw_register():
+    """Serve the service-worker registration bootstrap script.
+
+    Served as a real JS file so the strict `script-src 'self'` CSP covers it
+    without needing 'unsafe-inline' or per-build hashes on the inline tag.
+    """
+    reg_file = app_settings.static_dir / "sw-register.js"
+    if reg_file.exists():
+        return FileResponse(reg_file, media_type="application/javascript")
+    return {"error": "sw-register.js not found"}
+
+
 # Catch-all route for React Router (must be last)
 # Catch-all route for React Router (must be last)
 @app.get("/{full_path:path}")
 @app.get("/{full_path:path}")
 async def serve_spa(full_path: str):
 async def serve_spa(full_path: str):

+ 4 - 26
frontend/index.html

@@ -31,31 +31,9 @@
     <div id="root"></div>
     <div id="root"></div>
     <script type="module" src="/src/main.tsx"></script>
     <script type="module" src="/src/main.tsx"></script>
 
 
-    <!-- Service Worker Registration (skip on SpoolBuddy kiosk) -->
-    <script>
-      if ('serviceWorker' in navigator) {
-        if (location.pathname.startsWith('/spoolbuddy')) {
-          // Kiosk mode — nuke SW and all caches, then reload once to get clean state
-          navigator.serviceWorker.getRegistrations().then((regs) => {
-            if (regs.length > 0) {
-              Promise.all([
-                ...regs.map((r) => r.unregister()),
-                caches.keys().then((names) => Promise.all(names.map((n) => caches.delete(n)))),
-              ]).then(() => location.reload());
-            }
-          });
-        } else {
-          window.addEventListener('load', () => {
-            navigator.serviceWorker.register('/sw.js')
-              .then((registration) => {
-                console.log('SW registered:', registration.scope);
-              })
-              .catch((error) => {
-                console.log('SW registration failed:', error);
-              });
-          });
-        }
-      }
-    </script>
+    <!-- Service Worker Registration (skip on SpoolBuddy kiosk).
+         Kept as an external file so the CSP `script-src 'self'` covers it
+         without needing 'unsafe-inline' or per-build hashes. -->
+    <script src="/sw-register.js"></script>
   </body>
   </body>
 </html>
 </html>

+ 22 - 0
frontend/public/sw-register.js

@@ -0,0 +1,22 @@
+if ('serviceWorker' in navigator) {
+  if (location.pathname.startsWith('/spoolbuddy')) {
+    navigator.serviceWorker.getRegistrations().then((regs) => {
+      if (regs.length > 0) {
+        Promise.all([
+          ...regs.map((r) => r.unregister()),
+          caches.keys().then((names) => Promise.all(names.map((n) => caches.delete(n)))),
+        ]).then(() => location.reload());
+      }
+    });
+  } else {
+    window.addEventListener('load', () => {
+      navigator.serviceWorker.register('/sw.js')
+        .then((registration) => {
+          console.log('SW registered:', registration.scope);
+        })
+        .catch((error) => {
+          console.log('SW registration failed:', error);
+        });
+    });
+  }
+}

+ 4 - 26
static/index.html

@@ -32,31 +32,9 @@
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>
 
 
-    <!-- Service Worker Registration (skip on SpoolBuddy kiosk) -->
-    <script>
-      if ('serviceWorker' in navigator) {
-        if (location.pathname.startsWith('/spoolbuddy')) {
-          // Kiosk mode — nuke SW and all caches, then reload once to get clean state
-          navigator.serviceWorker.getRegistrations().then((regs) => {
-            if (regs.length > 0) {
-              Promise.all([
-                ...regs.map((r) => r.unregister()),
-                caches.keys().then((names) => Promise.all(names.map((n) => caches.delete(n)))),
-              ]).then(() => location.reload());
-            }
-          });
-        } else {
-          window.addEventListener('load', () => {
-            navigator.serviceWorker.register('/sw.js')
-              .then((registration) => {
-                console.log('SW registered:', registration.scope);
-              })
-              .catch((error) => {
-                console.log('SW registration failed:', error);
-              });
-          });
-        }
-      }
-    </script>
+    <!-- Service Worker Registration (skip on SpoolBuddy kiosk).
+         Kept as an external file so the CSP `script-src 'self'` covers it
+         without needing 'unsafe-inline' or per-build hashes. -->
+    <script src="/sw-register.js"></script>
   </body>
   </body>
 </html>
 </html>

+ 22 - 0
static/sw-register.js

@@ -0,0 +1,22 @@
+if ('serviceWorker' in navigator) {
+  if (location.pathname.startsWith('/spoolbuddy')) {
+    navigator.serviceWorker.getRegistrations().then((regs) => {
+      if (regs.length > 0) {
+        Promise.all([
+          ...regs.map((r) => r.unregister()),
+          caches.keys().then((names) => Promise.all(names.map((n) => caches.delete(n)))),
+        ]).then(() => location.reload());
+      }
+    });
+  } else {
+    window.addEventListener('load', () => {
+      navigator.serviceWorker.register('/sw.js')
+        .then((registration) => {
+          console.log('SW registered:', registration.scope);
+        })
+        .catch((error) => {
+          console.log('SW registration failed:', error);
+        });
+    });
+  }
+}