fix(portal): pre-merge security hardening from code review
- PORTAL_OPEN_LINKS now defaults OFF — /portal/open/* is an unauthenticated, proxy-reachable session-minting path (and a linked project's open link grants the whole client's scope), so it must be explicitly enabled in dev. - Session cookie: enforce server-side expiry (check iat vs COOKIE_MAX_AGE — was browser-only) and guard a non-dict signed body (was an uncaught AttributeError → 500, reachable if SECRET_KEY is the insecure default). - Escape operator-set strings (location/rule/event names) before innerHTML + Leaflet tooltips — they're client-facing, so a name with markup was stored XSS in the client's browser. Global esc() helper applied at every injection point. - WS _scrub_frame drops a non-JSON frame instead of forwarding it raw; /history rows now whitelisted like the other scoped endpoints. - Preview-client slug uses the full project id (an 8-char prefix could collide two projects onto one client). Verified: cookie reader (fresh/expired/non-dict/missing-iat) + open-links default off; templates parse; scoped scrubbing intact. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -211,7 +211,9 @@ async def portal_location_history(location_id: str, hours: float = 2.0,
|
||||
return {"status": "ok", "readings": []}
|
||||
if r.status_code != 200:
|
||||
return {"status": "ok", "readings": []}
|
||||
return {"status": "ok", "readings": (r.json() or {}).get("readings", [])}
|
||||
raw = (r.json() or {}).get("readings", [])
|
||||
fields = ("timestamp", "lp", "leq", "lmax", "ln1", "ln2") # whitelist, like the other endpoints
|
||||
return {"status": "ok", "readings": [{k: x.get(k) for k in fields} for x in raw]}
|
||||
|
||||
|
||||
# Whitelist of alert-event fields exposed to a client (no internal ids/ack-by).
|
||||
@@ -272,14 +274,15 @@ async def portal_location_thresholds(location_id: str,
|
||||
|
||||
# -- live stream (fan-out feed, scoped + scrubbed) ---------------------------
|
||||
|
||||
def _scrub_frame(raw: str) -> str:
|
||||
def _scrub_frame(raw: str):
|
||||
"""Project a monitor frame down to the portal whitelist. Drops internal fields
|
||||
(unit_id, raw_payload, lmin) before it reaches a client; passes control fields
|
||||
(feed_status, heartbeat) + timestamp through."""
|
||||
(feed_status, heartbeat) + timestamp through. Returns None for a non-JSON frame
|
||||
so the caller drops it rather than forwarding anything unscrubbed."""
|
||||
try:
|
||||
d = json.loads(raw)
|
||||
except Exception:
|
||||
return raw
|
||||
return None
|
||||
out = {k: d.get(k) for k in _PORTAL_LIVE_FIELDS if k in d}
|
||||
if "timestamp" in d:
|
||||
out["timestamp"] = d["timestamp"]
|
||||
@@ -327,7 +330,9 @@ async def portal_location_stream(websocket: WebSocket, location_id: str):
|
||||
|
||||
async def forward_to_client():
|
||||
async for message in backend_ws:
|
||||
await websocket.send_text(_scrub_frame(message))
|
||||
frame = _scrub_frame(message)
|
||||
if frame is not None:
|
||||
await websocket.send_text(frame)
|
||||
|
||||
async def watch_client():
|
||||
while True:
|
||||
|
||||
Reference in New Issue
Block a user