Bound a panel credential where the route check cannot reach
Three things the security pass on the portal pairing turned up. The first two were already true of a screen on the local network; what changed is that a panel credential is now presentable from the internet, which is what makes them worth closing rather than recording. The artifact endpoint authenticates for itself, because a worker's credential has to open it and that token is no use anywhere else. It resolved the caller without handing over the request, so the one credential that is scoped by route was judged by no route at all — a panel could read and write the store as whoever approved it. It passes the request it already holds now. The websocket has no route to judge either, and there the bound has to be on what is sent: a panel is given the values its own dashboards draw and nothing else — no node status, no logs, no shape of the graph. The keys stay in the message, emptied, because a screen on a wall runs the bundle it was paired with. `messages_for` reads that set off the published dashboards, and is the walk the `/messages/` allowlist has wanted for a while. And locality is no longer a header anyone can type. The marker the connector stamps is a value minted per process, so reaching this API directly cannot buy a device the credential meant for one that cannot reach it at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017F9RnYCJgASuBTcAjxmnsp
This commit is contained in:
@@ -17,6 +17,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
@@ -27,6 +28,15 @@ from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Marks a request the connector replayed off the tunnel. The value is minted
|
||||
#: per process and never leaves it, because the header itself is not evidence:
|
||||
#: anything that can reach this API directly can set one, and the difference
|
||||
#: decides whether a pairing device is handed a credential the whole internet
|
||||
#: can present. The connector overwrites it on every frame, so a browser
|
||||
#: sending its own gets nowhere from either direction.
|
||||
VIA_HEADER = "x-fluksio-via"
|
||||
VIA_PORTAL = secrets.token_urlsafe(16)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CloudConfig:
|
||||
|
||||
@@ -222,8 +222,9 @@ class CloudConnector:
|
||||
# Set here rather than trusted from the frame: a browser can send
|
||||
# any header it likes through the proxy, and this one decides where
|
||||
# a pairing device's credential is minted. Arriving on this socket
|
||||
# is the only thing that makes it true.
|
||||
headers["x-fluksio-via"] = "portal"
|
||||
# is the only thing that makes it true, and the value is this
|
||||
# process's own so nothing off the network can imitate it.
|
||||
headers[cloud_config.VIA_HEADER] = cloud_config.VIA_PORTAL
|
||||
|
||||
async with self._client() as client:
|
||||
request = client.build_request(
|
||||
@@ -298,7 +299,11 @@ class CloudConnector:
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.api.deps import user_from_token
|
||||
from app.api.routes.flows import snapshot_payload
|
||||
from app.api.routes.flows import (
|
||||
event_for_panel,
|
||||
panel_scope,
|
||||
snapshot_payload,
|
||||
)
|
||||
from app.core.db import engine
|
||||
from app.flow.events import event_bus
|
||||
|
||||
@@ -316,6 +321,7 @@ class CloudConnector:
|
||||
_dump({"op": "ws_close", "id": stream_id, "code": 1008})
|
||||
)
|
||||
return
|
||||
only = panel_scope(token, self._app)
|
||||
|
||||
await socket.send(_dump({"op": "ws_open_ok", "id": stream_id}))
|
||||
|
||||
@@ -326,7 +332,7 @@ class CloudConnector:
|
||||
{
|
||||
"op": "ws_msg",
|
||||
"id": stream_id,
|
||||
"text": _dump(snapshot_payload(controller)),
|
||||
"text": _dump(snapshot_payload(controller, only)),
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -335,6 +341,8 @@ class CloudConnector:
|
||||
async with event_bus.subscribe() as queue:
|
||||
while True:
|
||||
event = await queue.get()
|
||||
if only is not None and not event_for_panel(event, only):
|
||||
continue
|
||||
await socket.send(
|
||||
_dump({"op": "ws_msg", "id": stream_id, "text": _dump(event)})
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user