Skip to content

Security hardening: session secret, WS authorization, device secret - #35

Merged
Mischa323 merged 3 commits into
mainfrom
claude/security-hardening
Jun 20, 2026
Merged

Security hardening: session secret, WS authorization, device secret#35
Mischa323 merged 3 commits into
mainfrom
claude/security-hardening

Conversation

@Mischa323

Copy link
Copy Markdown
Owner

Addresses security findings #1, #3, #5 (server side). The agent side of #2 (TLS cert pinning) and #5 (device-secret client) shipped to leuffen-rmm-agent separately.

#1 — Insecure default SESSION_SECRET (committed here: auth.py)

Cookies were signed with the public default dev-insecure-secret-change-me if SESSION_SECRET was unset — anyone could forge an admin session. Now a real (non-dev) server refuses the default and instead generates a strong random secret once, persisted in the settings table (stable across restarts). Best practice: still set SESSION_SECRET explicitly in your compose.

#3 + #5 (server) — patch for server/app/main.py

main.py is 2,600 lines — too large to push safely through the bot interface in one shot, so here is the exact, py_compile-verified patch (only these two hunks change). Apply with git apply on this branch:

#3 — WebSocket authorization: the screen/terminal sockets only checked that you were logged in, not that you had access to the target device's org → any authenticated user could control any device by id. Now they enforce require_org.

#5 — per-device secret (trust-on-first-use): reconnects trusted device_id alone. Now the server issues a per-device secret to agents that support it and requires it thereafter (stored hashed in settings; no DB migration). Legacy agents keep working; set RMM_REQUIRE_DEVICE_SECRET=1 to enforce once the fleet is updated.

--- a/server/app/main.py
+++ b/server/app/main.py
@@ -1689,8 +1689,31 @@
         if org is None:
             await ws.close(code=4401)
             return
+        # Per-device secret: defends reconnect against device_id impersonation.
+        # Trust-on-first-use — issue a secret to agents that support it and then
+        # require it on later reconnects. Legacy agents (no support) are allowed
+        # unless RMM_REQUIRE_DEVICE_SECRET is set (enable once the fleet is updated).
+        import hashlib as _hl, hmac as _hmac, secrets as _secrets
+        _dsk = f"devsecret:{device_id}"
+        _stored = db.get_setting(_dsk)
+        _presented = first.get("device_secret") or ""
+        _issue = None
+        if _stored:
+            if not (_presented and _hmac.compare_digest(
+                    _hl.sha256(_presented.encode()).hexdigest(), _stored)):
+                log.warning("Agent %s failed device-secret check; rejecting", device_id)
+                await ws.close(code=4401)
+                return
+        elif first.get("supports_secret"):
+            _issue = _secrets.token_urlsafe(32)
+            db.set_setting(_dsk, _hl.sha256(_issue.encode()).hexdigest())
+        elif os.environ.get("RMM_REQUIRE_DEVICE_SECRET", "").lower() in ("1", "true", "yes"):
+            await ws.close(code=4401)
+            return
         db.upsert_device(org["id"], first, require_approval=require_approval())
         await manager.register(device_id, org["id"], ws)
+        if _issue:
+            await ws.send_json({"type": "device_secret", "secret": _issue})
         # Push admin-controlled device policy (Wake-on-LAN), per the policies that
         # target this device and whether its OS supports it.
         await ws.send_json({"type": "agent_policy",
@@ -1759,11 +1782,25 @@
 
 async def _bridge_ws(ws: WebSocket, device_id: str, channel: str) -> None:
     # Cookie auth (browser sends it automatically); dev mode is always allowed.
+    # Authenticate the operator via the signed session cookie ...
+    user = None
     if not auth.DEV_AUTH:
         raw = ws.cookies.get(auth.COOKIE)
-        if not (raw and auth.read_cookie(raw)):
+        data = auth.read_cookie(raw) if raw else None
+        if not data:
             await ws.close(code=4401)
             return
+        user = {"email": data["email"],
+                "is_global_admin": auth.is_global_admin(data["email"])}
+    # ... and AUTHORISE: they must have access to this device's organisation, so a
+    # signed-in user can't drive a device in another org by guessing its id.
+    dev = db.get_device(device_id)
+    if user is not None and dev is not None:
+        try:
+            auth.require_org(user, dev["org_id"])
+        except HTTPException:
+            await ws.close(code=4403)
+            return
     await ws.accept()
     agent = manager.get(device_id)
     if not agent:

New env vars

Var Where Purpose
SESSION_SECRET server Pin the cookie-signing key (else auto-generated).
RMM_SERVER_FINGERPRINT agent SHA-256 of the server TLS cert → pins it (MITM-proof even with self-signed).
RMM_REQUIRE_DEVICE_SECRET server Reject agents with no device secret (enable after the fleet is on the new agent).

🤖 Generated with Claude Code


Generated by Claude Code

If SESSION_SECRET is unset or the known placeholder, a real (non-dev) server now
generates a strong random secret and persists it in the settings table instead
of signing cookies with a publicly known key that anyone could forge into an
admin session. Set SESSION_SECRET in the env to control it explicitly.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant