From c236fdc65051cfca235dd0712ed102c836bc7942 Mon Sep 17 00:00:00 2001 From: maziggy Date: Thu, 25 Jun 2026 15:19:28 +0200 Subject: [PATCH 1/2] fix(auth): expose /api/v1/system/appliance through the auth middleware allowlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /system/appliance endpoint is fetched by the SPA's i18n bootstrap on mount to seed locale, hostname, timezone, and the chrony NTP-gate state BEFORE any login state exists. The route handler itself has no auth dependency and the test_route_auth_coverage allowlist correctly marks it public, but the global auth_middleware in main.py — which short-circuits every /api/ path not in PUBLIC_API_ROUTES — was never told about it. Result: every browser session on an auth-enabled install logged a 401 on the appliance endpoint before login. Added /api/v1/system/appliance to PUBLIC_API_ROUTES with a comment pointing at the dual-list pattern so this doesn't drift again, and a regression test in TestAuthMiddlewarePublicRoutes that posts /auth/setup to turn auth on, then asserts the endpoint returns 200 with the documented shape (hostname / timezone / locale / time_synced fields all present). --- backend/app/main.py | 8 ++++++++ backend/tests/integration/test_auth_api.py | 19 +++++++++++++++++++ .../unit/services/test_virtual_printer.py | 18 ++++++++++++------ 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index 6fb5a08d7..edbc3f652 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -6351,6 +6351,14 @@ PUBLIC_API_ROUTES = { "/api/v1/updates/version", # Metrics endpoint handles its own prometheus_token authentication "/api/v1/metrics", + # Appliance bootstrap (#1589 follow-up): the SPA's i18n setup polls + # this BEFORE a JWT is available to pick up the firstboot wizard's + # hostname / timezone / locale and the chrony NTP-gate state. The + # response contains user-set defaults and a public sync flag — no + # secrets. Without this entry the global auth middleware returns 401 + # before the route handler runs, regardless of the route's own + # "no auth required" intent. + "/api/v1/system/appliance", } # Route prefixes that are public (for routes with dynamic segments) diff --git a/backend/tests/integration/test_auth_api.py b/backend/tests/integration/test_auth_api.py index 72a260556..442f3635c 100644 --- a/backend/tests/integration/test_auth_api.py +++ b/backend/tests/integration/test_auth_api.py @@ -854,6 +854,25 @@ class TestAuthMiddlewarePublicRoutes: assert response.status_code == 200 assert "auth_enabled" in response.json() + @pytest.mark.asyncio + @pytest.mark.integration + async def test_system_appliance_is_public(self, async_client: AsyncClient, enabled_auth): + """Verify /api/v1/system/appliance is reachable without a JWT. + + The SPA's i18n bootstrap fetches this BEFORE login to seed locale, + hostname, timezone, and NTP-gate state. The route handler has no + auth dependency, but the global auth_middleware blocks every + /api/ path not in PUBLIC_API_ROUTES — so without an explicit + allowlist entry the user sees a 401 in the browser console on + every page load. + """ + response = await async_client.get("/api/v1/system/appliance") + assert response.status_code == 200, response.text + body = response.json() + # Shape contract (no-auth surface): + for key in ("hostname", "timezone", "locale", "time_synced"): + assert key in body + @pytest.mark.asyncio @pytest.mark.integration async def test_auth_login_is_public(self, async_client: AsyncClient, enabled_auth): diff --git a/backend/tests/unit/services/test_virtual_printer.py b/backend/tests/unit/services/test_virtual_printer.py index 19d940723..9c5b54883 100644 --- a/backend/tests/unit/services/test_virtual_printer.py +++ b/backend/tests/unit/services/test_virtual_printer.py @@ -2964,7 +2964,7 @@ class TestSlicerProxyManager: slicer and printer for all protocols except MQTT, which must be TLS-terminated to rewrite the printer's IP in MQTT payloads. """ - from unittest.mock import AsyncMock, patch + from unittest.mock import patch from backend.app.services.virtual_printer.tcp_proxy import ( SlicerProxyManager, @@ -2984,16 +2984,22 @@ class TestSlicerProxyManager: bind_address="10.0.0.1", ) - # Mock asyncio.create_task and asyncio.gather to prevent actual server start + # Mock asyncio.create_task and asyncio.gather to prevent actual + # server start. Close every coroutine handed to gather — otherwise + # the ~110 run_with_logging() coros built inside start() are + # garbage-collected unfinalized and surface later as + # PytestUnraisableExceptionWarning at random in other tests. + async def _close_pending(*coros, **_): + for c in coros: + if asyncio.iscoroutine(c): + c.close() + with ( patch("asyncio.create_task") as mock_create_task, - patch("asyncio.gather", new_callable=AsyncMock), + patch("asyncio.gather", side_effect=_close_pending), patch.object(SlicerProxyManager, "_log_activity"), ): mock_create_task.return_value = MagicMock() - # start() will create proxies then try to gather tasks — we just - # need to verify the proxy types after creation. - # Trigger start but let gather return immediately. await mgr.start() # FTP, FileTransfer, RTSP should be TCPProxy (transparent) From 0b43ac0d25982f87d0baba1c9dfb77ac92568171 Mon Sep 17 00:00:00 2001 From: maziggy Date: Thu, 25 Jun 2026 15:27:17 +0200 Subject: [PATCH 2/2] chore(deps): floor-pin pydantic-settings >=2.14.2 + msgpack >=1.2.1 for clean pip-audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pip-audit flagged two advisories at the resolved versions in the venv. Neither is reachable in shipped Bambuddy, but the pins are taken so the audit stays clean and a future reachable advisory in either package isn't masked by existing noise. pydantic-settings 2.14.2 patches GHSA-4xgf-cpjx-pc3j — NestedSecretsSettingsSource with secrets_nested_subdir=True followed symlinks pointing outside the configured secrets_dir, reading out-of-tree files into settings values and bypassing the documented secrets_dir_max_size cap. Affected: >=2.12.0, <2.14.2. Bambuddy uses pydantic-settings only for env-var-backed config; the secrets-dir loader is not used (grep clean on NestedSecretsSettingsSource / secrets_nested_subdir / secrets_dir under backend/). msgpack 1.2.1 patches GHSA-6v7p-g79w-8964 — reusing an Unpacker instance after it caught an error can crash with SEGV, which is a DoS vector on untrusted input. msgpack is not a runtime dep of Bambuddy; it enters the tree only as a transitive of CacheControl, itself pulled by pip-audit (the very tool that surfaced the advisory). Pin placed in requirements-dev.txt next to pip-audit so it travels with the security-scan tooling rather than implying a runtime use. --- CHANGELOG.md | 2 ++ requirements-dev.txt | 4 ++++ requirements.txt | 5 ++++- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 836f70fbb..39b546cf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ All notable changes to Bambuddy will be documented in this file. - **Vite 7 → 8 major bump** — Bambuddy's frontend now builds with Vite 8 (`^7.3.2` → `^8.0.16`) and the matching plugin-react release (`@vitejs/plugin-react` `^5.1.1` → `^5.2.0`). Headline architectural change: Vite 8 swaps Rollup for **Rolldown** as the default bundler — same plugin contract, Rust-backed core, slightly different chunk layout / output bytes (no functional regression). The bump also lifts the transitive `esbuild` floor to 0.28.1, which closes the last open advisory in the audit chain. **Bambuddy-side surface audited:** `vite.config.ts` uses only stable contracts that survived the v8 cut — `defineConfig`, the `Connect` type, the custom `serveGcodeViewer` `configureServer` middleware plugin (proxies `/gcode-viewer/*` to the repo's sibling `gcode_viewer/` directory in dev), the `server.proxy` with WebSocket upgrade for `/api/v1/ws`, `build.outDir`/`emptyOutDir`/`chunkSizeWarningLimit`, and `resolve.alias` for `@`. `base: '/'` regression guard from #1221 is unaffected. No SSR, no library mode, no CSS preprocessors, no exotic plugins. `vitest@4.1.8` already accepts vite 8 in its peer range (`^6 || ^7 || ^8`); no test-runner bump required. **Node:** vite 8 requires `^20.19.0 || >=22.12.0`; CI Node 20.x line satisfies this. **What this is NOT:** plugin-react v6 — that line requires `babel-plugin-react-compiler` + `@rolldown/plugin-babel` as peers and is a separate scope. `npm run build`, `npm run lint`, `npx vitest run` all clean; `npm audit` clean. - **Frontend dependency bumps** — Routine version updates across the runtime, build, and test dependency surface. **Runtime:** `dompurify` 3.4.0 → 3.4.10. `package.json` floor raised from `^3.4.0` to `^3.4.10` so fresh installs cannot land on the deprecated 3.4.4 release. Three call sites use string-output sanitisation (`frontend/src/pages/MakerworldPage.tsx`, `frontend/src/pages/ProjectDetailPage.tsx`, `frontend/src/components/ProjectPageModal.tsx`); release notes 3.4.1 → 3.4.10 reviewed for behavioural changes — 3.4.4 widened the default allow-list with `selectedcontent` + `command` + `commandfor` (all valid modern HTML, harmless for our two default-allow-list call sites), and `ProjectPageModal` is unaffected anyway because it sets an explicit `ALLOWED_TAGS` / `ALLOWED_ATTR` whitelist. **Build / lint / test tooling (transitive, dev-only):** `@babel/core` 7.29.0 → 7.29.7 (pulled by `@vitejs/plugin-react` and `eslint-plugin-react-hooks`), `vite` 7.3.2 → 7.3.5, `markdown-it` 14.1.1 → 14.2.0 (pulled by `@tiptap/extension-link` → `@tiptap/pm` → `prosemirror-markdown`; Bambuddy never calls `markdown-it.render` directly so the change is transparent), `js-yaml` 4.1.1 → 4.2.0 (pulled by `eslint`), `form-data` 4.0.5 → 4.0.6 + `ws` 8.20.1 → 8.21.0 (both pulled by `jsdom` in the test runtime). All bumps inside existing semver ranges except `dompurify`. No source changes required. - **`dompurify` 3.4.10 → 3.4.11** — Follow-up patch closes a moderate-severity advisory affecting `setConfig()` callers: the previous hook clone-guard added in 3.4.7 could be bypassed via `setConfig()`, leaving a permanent `ALLOWED_ATTR` pollution that the next `sanitize()` call inherited. **Bambuddy's exposure is nil** — `git grep DOMPurify.setConfig` returns zero hits across the entire codebase; all three sanitisation sites (`frontend/src/pages/MakerworldPage.tsx`, `frontend/src/pages/ProjectDetailPage.tsx`, `frontend/src/components/ProjectPageModal.tsx`) call `DOMPurify.sanitize(html)` or `DOMPurify.sanitize(html, {ALLOWED_TAGS, ALLOWED_ATTR})` directly, never through `setConfig()`. The bump is taken as defence-in-depth to keep XSS-sensitive surface area current and to silence `npm audit` so future audit-fix runs don't auto-bundle unintended changes. **Mechanical lockfile bump only:** the existing `^3.4.10` range already permitted 3.4.11, so `package.json` is unchanged; `package-lock.json` updates the resolved URL + integrity hash for the one entry. Verification: `npm audit` reports 0 vulnerabilities, `MakerworldPage.test.tsx`'s 12 DOMPurify sanitisation cases pass, `npm run build` clean. +- **Precautionary floor pins for pydantic-settings 2.14.2 + msgpack 1.2.1** — pip-audit surfaced two advisories that are not reachable in shipped Bambuddy but were flooring at vulnerable versions. **`pydantic-settings` 2.0.0 → 2.14.2** in `requirements.txt` clears GHSA-4xgf-cpjx-pc3j (`NestedSecretsSettingsSource` with `secrets_nested_subdir=True` follows symbolic links pointing outside the configured `secrets_dir`, reading out-of-tree files into settings values and bypassing the documented `secrets_dir_max_size` cap; affected `>=2.12.0,<2.14.2`). **Exposure: nil.** `grep -rn "NestedSecretsSettingsSource\|secrets_nested_subdir\|secrets_dir" backend/` returns zero hits — Bambuddy uses pydantic-settings only for env-var-backed config, never for the secrets-dir loader. **`msgpack` 1.2.1** floor-pinned in `requirements-dev.txt` next to `pip-audit>=2.7.0` to clear GHSA-6v7p-g79w-8964 (an `Unpacker` instance reused after catching an error can crash with SEGV; under repeated unpacking of untrusted input from an external source, this is a DoS vector). **Exposure: nil.** `grep -rn "import msgpack\|from msgpack" backend/` returns zero hits — msgpack enters Bambuddy's tree only as a transitive of `CacheControl`, which is itself pulled by `pip-audit` (the very tool that produced the report). Not a runtime dep of the shipped app. Both pins are taken as defence-in-depth / audit hygiene so the next `pip-audit` run is clean and a future *reachable* advisory in either package isn't masked by the existing noise. No code change, no behavioural change, no test change. + - **Backend dependency security floor raises (cryptography / python-multipart / starlette)** — pip-audit December 2026 cycle surfaced six advisories across three direct deps; floors in `requirements.txt` lifted to the documented fix releases, plus one transitive co-bump for resolver compatibility. **`cryptography` 46.0.7 → 48.0.1 floor** (resolver picks 49.0.0 within the new floor) — clears GHSA-537c-gmf6-5ccf (non-contiguous Python buffer handling that could overflow on APIs accepting buffer protocol input). **Release-notes audit (done before bump):** v47.0.0 dropped Python 3.8 + OpenSSL 1.1.x + binary elliptic curves (SECT*) + Camellia + CFB/OFB/CFB8 modes (moved to `cryptography_decrepit`); v48.0.0 dropped `PUBLIC_KEY_TYPES` / `PRIVATE_KEY_TYPES` type aliases. Bambuddy's grep is clean across every one of those: `core/encryption.py` uses Fernet (AES-128-CBC + HMAC), `services/spoolbuddy_ssh.py` uses ed25519, `services/virtual_printer/certificate.py` uses RSA + x509 + ExtendedKeyUsageOID. Python 3.13 + OpenSSL 3.x on container, so the version-floor bumps are no-ops for us. **`python-multipart` 0.0.27 → 0.0.31 floor** (resolver picks 0.0.32) — clears CVE-2026-53538/53539/53540 in the multipart parser surface (boundary length capped at 256 bytes, RFC 2231 continuation handling, Content-Length non-negative validation, bounded header field name size before validation). **Behavioural changes audited:** 0.0.30 stopped recognising RFC 2231/5987 extended `filename*` / `name*` parameters in incoming bodies — Bambuddy emits these on outgoing Content-Disposition response headers (`utils/http.py:17`) but doesn't parse them on the request side, and clients that include both `filename=` and `filename*=` keep working via the plain `filename=` fallback (slight cosmetic difference for non-ASCII filenames in uploads). 0.0.30 also tightened form-urlencoded parsing to treat only `&` as field separator — every Bambuddy client (browser, BambuStudio, OrcaSlicer) already uses `&`. **`starlette` 1.1.0 → 1.3.1 floor** — clears CVE-2026-54282/54283 (FormParser `max_part_size` / `max_fields` limits now actually enforced after being declared-but-ignored in earlier releases; `StaticFiles.lookup_path` rejects absolute paths; `FileResponse` clamps oversized suffix range requests; `URL.replace()` IndexError fix). **Critical pre-bump check:** the newly-enforced `max_part_size=1MB` default would have broken every file upload (`UploadFile = File(...)` in `inventory.py:1127`, `projects.py:886/1053/1780`, `library.py:1787`, `local_presets.py:82`, `external_links.py:166`, `local_backup.py`) if it applied to file streams. Inspected the `MultiPartParser.on_part_data` source: the size check at `if self._current_part.file is None:` only fires for **text** form fields, not file streams — so file uploads of arbitrary size still pass through unaffected. Text form bodies in Bambuddy are login credentials and similar small values, well under the 1MB ceiling. **Side rename:** `backend/app/api/routes/mfa.py:470/1364/1428` replaces 3 references of `status.HTTP_422_UNPROCESSABLE_ENTITY` (deprecated in starlette 1.3.x) with `HTTP_422_UNPROCESSABLE_CONTENT`. Same 422 wire status; silences the 3 deprecation warnings under our own ownership (the two remaining warnings come from FastAPI internals — upstream's to fix). **`pyopenssl` 26.0.0 → 26.3.0 floor** — **NOT a security fix**; required because pyOpenSSL `<26.3.0` caps `cryptography<47` in its install_requires, so without an explicit floor the resolver either downgrades cryptography below the GHSA-537c-gmf6-5ccf fix line or installs an inconsistent pair (pip's resolver warns but proceeds). Bambuddy has no direct `from OpenSSL ...` imports — pyOpenSSL is pulled transitively by `asyncssh` + `pywebpush`. **Verification:** `pip-audit` clean, `pip check` clean, `ruff check backend/` clean, backend `pytest -n 30` 6167/6167 in 86.55s. No DB migration, no API surface change, no permission change, no frontend change. ### Added diff --git a/requirements-dev.txt b/requirements-dev.txt index 5e97229ba..298cd24f1 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -18,5 +18,9 @@ pyOpenSSL>=26.0.0 # Security scanning bandit[sarif]>=1.7.0 pip-audit>=2.7.0 +# Transitive of pip-audit→CacheControl. 1.2.1 patches GHSA-6v7p-g79w-8964 +# (Unpacker SEGV/DoS on reuse after caught error). Not a runtime dep of +# Bambuddy — pinned here so the audit stays clean. +msgpack>=1.2.1 # Secrets scan: gitleaks (Go binary, not a Python package). # Install: go install github.com/zricethezav/gitleaks/v8@latest diff --git a/requirements.txt b/requirements.txt index 577771607..b62a71459 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,7 +21,10 @@ greenlet>=3.0.0 # Pydantic pydantic>=2.0.0 -pydantic-settings>=2.0.0 +# 2.14.2 patches GHSA-4xgf-cpjx-pc3j (NestedSecretsSettingsSource follows +# symlinks out of secrets_dir). Bambuddy does not use that source — pin +# is precautionary so the audit stays clean. +pydantic-settings>=2.14.2 # Transitive of pydantic-settings, floor-pinned to patch CVE-2026-28684 (dotenv 1.2.1) python-dotenv>=1.2.2