Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **WS-KEEPALIVE — server-side Phase F heartbeat (completes the #3 "WS: Reconnecting" idle-timeout fix)**: the browser client already replied to server `{"type": "ping"}` frames with a pong (`src/frontend/assets/websocket_client.js`), but nothing on the server ever *sent* those pings, so the `/ws/training` receive loop (`src/main.py` — `asyncio.wait_for(websocket.receive_text(), timeout=idle_timeout_seconds)`, default 120s) idled out on any quiet-but-healthy training stream and the client flapped Connected→Reconnecting. `src/main.py` now starts a `_websocket_keepalive_loop` task in the application lifespan that calls `websocket_manager.broadcast_ping(channel="training")` every `websocket.heartbeat_interval` seconds (the previously-dormant 30s setting, well under the 120s idle timeout) and cancels it on shutdown; the client's existing pong resets the server idle timer. `WebSocketManager.broadcast()` / `broadcast_ping()` gain an optional `channel` filter so the heartbeat is scoped to the training channel only — `/ws/control` has no idle timeout and would mis-handle the resulting pong as an unknown command. Regression coverage: `test_main_import_and_lifespan.py::TestWebSocketKeepalive` (loop pings the training channel periodically and survives a transient broadcast error) and `test_websocket_comprehensive.py::TestHeartbeatFunctionality` (channel-scoped ping reaches training but not control; no-channel still pings all). Latent adjacent issue noted for a separate PR: the `/ws/control` endpoint treats an inbound `{"type": "pong"}` as an unknown command — dormant today because the heartbeat never pings control.
- **#1 TAB-FEEDBACK-LOOP — collapse to one tab-persistence system + equality-guard the restore callback**: clicking one tab then another re-opened the previous tab (deterministic Snapshots→Dataset). Two compounding causes in `src/frontend/dashboard_manager.py`: (1) the clientside callback that restores `visualization-tabs.active_tab` from `layout-state-store` (Input on the Store's `data`) re-asserted the tab on *every* Store change — including the echo from the write callback that stamps the Store on each tab change — re-triggering every `Input("visualization-tabs", "active_tab")` callback and racing the `allow_duplicate` active_tab outputs; (2) a redundant *second* persistence system (hand-rolled `localStorage['juniper_canopy_active_tab']` with an `active_tab`→`active_tab` self-edge writer plus a `params-init-interval` mount restore) raced the Store restore at mount. Fix: the restore callback now takes the current tab as `State("visualization-tabs", "active_tab")` and returns `no_update` when `state.active_tab === currentTab` (mirrors the write callback's existing `prev.active_tab === activeTab` guard), and the legacy localStorage pair is deleted so `layout-state-store` (`storage_type="local"`) is the single source of truth. Net: `visualization-tabs.active_tab` now has exactly two writers (Store restore + tutorial-link trigger) and a single mount-time restore. Regression coverage in `test_dashboard_manager.py::TestLayoutStatePersistence`: legacy key fully removed, restore callback equality-guarded, exactly two active_tab outputs.

### Security

- **SEC-F22 / D2 — startup loopback bind-guard (the loopback bind is now an enforced invariant)**: canopy's browser training-control gate (`/api/train/*`, `/ws/control`) authenticates the same-origin browser by `Origin` + CSRF, both of which are forgeable by an in-network **non-browser** client (spoofable `Origin`, anonymously-mintable CSRF token — audit HO-6), so the **only** effective control is the loopback bind. That bind was an implicit default, not an enforced invariant — flipping `BIND_HOST=0.0.0.0` silently made the control surface in-network- (or internet-) reachable. A new startup guard (`src/security.py`: `is_loopback_host` / `enforce_loopback_bind_guard` / `NonLoopbackBindError`, called from `main.lifespan`, mirroring the E-8 `enforce_dependency_floors` fail-loud idiom) now **refuses to start** (CRITICAL log + raise; fail-closed) when `settings.server.host` (`JUNIPER_CANOPY_SERVER__HOST`) is a non-loopback interface (anything not in `127.0.0.0/8`, `::1`, or `localhost`) **unless** the new `settings.fronting_auth_attested` (`JUNIPER_CANOPY_FRONTING_AUTH_ATTESTED`, default `False`) is `True` — an operator attestation that a fronting authenticating proxy is present (the attested non-loopback path logs a loud WARNING). Loopback binds (the default) start normally, so this is zero-UX for the shipped posture. Implemented **inline in canopy** (no new dependency). Regression coverage: `src/tests/unit/test_bind_guard.py`. Design-of-record: juniper-ml [`notes/JUNIPER_CANOPY_CONTROL_SURFACE_AUTH_AND_NAT_DESIGN_2026-07-03.md`](https://github.com/pcalnon/juniper-ml/blob/main/notes/JUNIPER_CANOPY_CONTROL_SURFACE_AUTH_AND_NAT_DESIGN_2026-07-03.md) §4 / §8 (D2); implementation note: [`notes/JUNIPER_CANOPY_CONTROL-SURFACE-HARDENING_SEC-F22-F19_NOTE_2026-07-04.md`](notes/JUNIPER_CANOPY_CONTROL-SURFACE-HARDENING_SEC-F22-F19_NOTE_2026-07-04.md).

- **SEC-F19 / D4 — global + per-session WebSocket connection caps (kills the shared-NAT self-DoS)**: Docker NAT collapses every WS client to the bridge-gateway IP (audit HO-3), so the existing per-IP cap (`max_connections_per_ip=5`) is shared across all users behind the gateway — one client's five sockets exhaust the cap for everyone (a live self-DoS). `src/communication/websocket_manager.py` now adds, alongside the per-IP cap: (a) the stack-absolute **global** cap `max_connections` (=50) enforced in `connect()` — the single admission choke point shared by `/ws/training`, `/ws/control`, `/ws` — rejecting the N+1th connection stack-wide with close code `1013`; and (b) a **per-session** cap `max_connections_per_session` (=5, new `WebSocketSettings` field) keyed on the anonymous `canopy_session` cookie read from the WS handshake, restoring per-client fairness where the per-IP cap is inert (one session can no longer starve another behind the same gateway). A cookieless first connection is allowed and left to the global cap as the backstop. The three endpoints call a new `check_connection_limits()` (per-IP then per-session, rolling back the per-IP slot on a per-session rejection so a rejected attempt can't leak the per-IP counter); each endpoint keeps its existing close-reason (`/ws/control` stays opaque per M-SEC-06). The per-IP cap is retained but re-scoped honestly (a code comment + this note): it is **inert behind NAT** — DoS-dampening, **not** authentication. Regression coverage: `src/tests/unit/test_ws_connection_caps.py`. Design-of-record §5 / §8 (D4). **Deferred (Phase 4, owner-gated, NOT in this PR):** X-Forwarded-For-from-trusted-proxy (D6) and a real dashboard login / fronting proxy (D7) — the only mechanisms that restore genuine per-client identity / close SEC-F22 for the remote/multi-user case.

## [0.5.0] - 2026-05-23

**Note on version history**: `pyproject.toml` was bumped 0.3.0 → 0.4.0 on 2026-03-03 in preparation for a 0.4.0 release that was never cut to PyPI (the `[0.4.0]` section below documents the work that *would have* shipped). This 0.5.0 release rolls up both that work and the subsequent ~2.5 months of changes (983 commits since `v0.3.0`) into a single PyPI release. Subsequent entries in this section list the additional work landed since 2026-03-03.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# Juniper-Canopy — Control-Surface Hardening (SEC-F22 bind-guard + SEC-F19 WS caps) — Implementation Note

**Project**: Juniper
**Sub-Project**: JuniperCanopy
**Application**: juniper_canopy
**Author**: Paul Calnon (implementation by Claude Code, Opus 4.8)
**License**: MIT License
**Date**: 2026-07-04
**Status**: Implemented — Phases 1–2 of the design of record. Owner-gated for any deploy roll-out; do not auto-merge.
**Scope**: Records the two now-enforced control-surface invariants shipped in this PR and, explicitly, what remains deferred.

> Design of record (read first): juniper-ml
> [`notes/JUNIPER_CANOPY_CONTROL_SURFACE_AUTH_AND_NAT_DESIGN_2026-07-03.md`](https://github.com/pcalnon/juniper-ml/blob/main/notes/JUNIPER_CANOPY_CONTROL_SURFACE_AUTH_AND_NAT_DESIGN_2026-07-03.md)
> — §4 (SEC-F22 Option A), §5 (SEC-F19 Option B), §7 Phases 1–2, §8 decisions D2 + D4, §9 testing.
> Companion: [`JUNIPER_CANOPY_TRAINING-CONTROL-AUTH_DESIGN_2026-06-30.md`](JUNIPER_CANOPY_TRAINING-CONTROL-AUTH_DESIGN_2026-06-30.md)
> §7.3 stated the load-bearing loopback precondition this PR now enforces.

---

## 1. What changed

This PR is a defensive hardening of the platform owner's own containerized stack. It implements the two app-local,
proxy-free remediation steps from the design of record and changes no deploy / env / secret file.

### 1.1 SEC-F22 (D2) — the loopback bind is now an enforced invariant

canopy's browser training-control gate (`/api/train/*`, `/ws/control`) authenticates the same-origin browser by
`Origin` + CSRF. Both are forgeable by an in-network **non-browser** client — the `Origin` header is a spoofable
string and the CSRF token is anonymously mintable (audit HO-6) — so the **only** effective control is the loopback
bind: an in-network foothold cannot reach a `127.0.0.0/8` port. Until now that loopback bind was an implicit default,
not an enforced invariant: flipping `BIND_HOST=0.0.0.0` silently converted SEC-F22 from same-host-only to
in-network- (or internet-) reachable, with no guard rail.

A **startup bind-guard** now converts that precondition into a fail-closed invariant. At canopy startup, before it
serves any request, canopy **refuses to start** when `settings.server.host`
(`JUNIPER_CANOPY_SERVER__HOST`) is a **non-loopback** interface — anything not in `127.0.0.0/8`, not `::1`, not
`localhost` — **unless** the new `settings.fronting_auth_attested`
(`JUNIPER_CANOPY_FRONTING_AUTH_ATTESTED`, default `False`) is `True`. A loopback host always starts. The refusal is
fail-loud (a CRITICAL log) and fail-closed (raises `NonLoopbackBindError`, so uvicorn exits). The attested
non-loopback path is allowed but logs a loud WARNING — the flag is an operator **attestation** that a fronting
authenticating proxy is present, not a verification.

Implemented **inline in canopy** (no new dependency):

- `src/security.py` — `is_loopback_host`, `enforce_loopback_bind_guard`, `NonLoopbackBindError`.
- `src/main.py` — the `lifespan` startup calls the guard (mirrors the existing E-8 `enforce_dependency_floors`
fail-loud idiom), before backend init.
- `src/settings.py` — the `fronting_auth_attested` field.

> **Deploy roll-out caveat (owner-gated).** The containerized deploy sets
> `JUNIPER_CANOPY_SERVER__HOST=0.0.0.0` *inside* the container
> (`juniper-deploy/docker-compose.yml:570`) — the standard Docker pattern where
> the real perimeter is the host-side **loopback publish**
> (`127.0.0.1:8050:8050`, `:557`), not the in-container bind. Because the guard
> keys on `settings.server.host`, rolling this code out to that deploy will make
> canopy **refuse to start** until the owner sets
> `JUNIPER_CANOPY_FRONTING_AUTH_ATTESTED=true` — the conscious attestation that
> the perimeter (the loopback publish today; a fronting proxy when Phase 4
> lands) is in place. That is the intended effect: the guard converts the silent
> `0.0.0.0` bind into a documented, deliberate choice. This is a deploy/env
> change and is therefore owner-gated (not part of this merge).

### 1.2 SEC-F19 (D4) — global + per-session WS caps; the per-IP cap is re-scoped honestly

Docker NAT collapses every WS client to the bridge-gateway IP (audit HO-3), so the existing per-IP cap
(`max_connections_per_ip=5`) is shared across **all** users behind the gateway — one client's five sockets exhaust
the cap for everyone (the live self-DoS). Two caps are added alongside it, in
`src/communication/websocket_manager.py`:

- **Global cap** — the stack-absolute `max_connections` (=50) enforced in `WebSocketManager.connect()`, the single
admission choke point shared by every WS endpoint (`/ws/training`, `/ws/control`, `/ws`). It bounds total server
resource and backstops the cookieless case; the N+1th connection stack-wide is rejected with close code `1013`.
- **Per-session cap** — `max_connections_per_session` (=5), keyed on the anonymous `canopy_session` cookie read from
the WS handshake. It restores per-client fairness where the per-IP cap is inert: one browser session can no longer
monopolize the shared cap and starve another session behind the same gateway. A cookieless first connection is
allowed and left to the global cap as the backstop (§9 R2). Over-cap → close `1013`.

The three WS endpoints now call `check_connection_limits(...)` (per-IP then per-session, with the per-IP slot rolled
back on a per-session rejection so a rejected attempt cannot leak the per-IP counter). Each endpoint keeps its
existing close-reason string — `/ws/control` stays opaque (`Policy violation`, M-SEC-06).

## 2. Honest security labeling (state this, don't drift from it)

- **Loopback bind (bind-guard)** — the real perimeter for the single-user / trusted-LAN research posture, now an
**enforced invariant** rather than an implicit default.
- **Per-IP cap** — DoS-dampening only, and **inert behind NAT** (every client presents as the bridge gateway). It was
**never** authentication and must not be documented as such. Kept because it still dampens a single-IP flood
off-NAT. Annotated as such in `WebSocketSettings.max_connections_per_ip` and `check_per_ip_limit`.
- **Global connection cap** — availability / DoS-dampening (best-effort), not authentication.
- **Per-session cap** — per-client fairness / DoS-dampening (best-effort), not authentication (a determined attacker
rotates cookies).

## 3. Explicitly deferred (Phase 4 — owner-gated, NOT in this PR)

The structural pieces of both findings converge on one component and are **not** built here:

- **D6 — X-Forwarded-For** trusted-proxy client-IP resolution (the only mechanism that restores genuine per-client
identity for the caps and the metrics allowlist). Deferred; the invariant "trust XFF only from the configured proxy
IP" is written down now.
- **D7 — a real dashboard login** / fronting authenticating reverse proxy (the only robust close of SEC-F22 for the
remote / multi-user case). A page-injected token (design B1) was rejected in the design record as insufficient (it
relocates the anonymous mint from `/api/csrf` to the anonymously-served `/dashboard/`).

These are justified only when genuine remote or multi-user access becomes a requirement.

## 4. Verification

- New unit tests: `src/tests/unit/test_bind_guard.py` (SEC-F22/D2 — non-loopback + no attest refuses to start;
non-loopback + attest binds; loopback binds regardless; fail-loud logging) and
`src/tests/unit/test_ws_connection_caps.py` (SEC-F19/D4 — global cap rejects the N+1th stack-wide; two sessions from
one peer IP each keep their per-session allocation; a legit single user is unaffected; cookieless allowed;
per-IP-slot rollback on per-session rejection).
- Run the CI-equivalent unit + regression scope (per the canopy "green tests / dead app" caveat — CI selects by
path, not just marker):

```bash
cd src && pytest -m "not requires_cascor and not requires_server and not slow" tests/unit/ tests/regression/
```

- Per the design §9, the deploy roll-out still owes a **live** curl / WS probe matrix on an isolated stack
(unit-green alone is not sufficient for this class). That is an owner-gated deploy step, not part of this merge.
Loading
Loading