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:
2026-08-21 00:09:18 +02:00
co-authored by Claude Opus 5
parent 4c8339e643
commit a8d1b3927e
10 changed files with 252 additions and 16 deletions
+10
View File
@@ -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:
+12 -4
View File
@@ -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)})
)