feat(server): viewer-token view-only/control split - closes CRITICAL #1
Authz-strength fix (coord todo c8916c89), code-reviewed APPROVED. Replaces the
weak "view" gate (held by every role) with a permission-tiered access mode
stamped inside the signed viewer token:
- mint: is_admin() || has_permission("control") -> CONTROL token; else
has_permission("view") -> VIEW_ONLY token; else 403.
- enforce: the relay drops MouseEvent/KeyEvent/SpecialKey for a VIEW_ONLY token
before forwarding (video still streams); CONTROL tokens forward under the
Task-3 throttle. Mode is unforgeable (in the signature) and unbypassable
(all other viewer->agent payloads hit the catch-all and are never forwarded).
A low-privilege viewer-role user can now at most watch, never control. New
ViewerAccess enum (view_only|control) on ViewerClaims; 3 unit tests.
Audit CRITICAL #1 now fully closed (mechanism in Task 3 + this authz strength).
Not cargo-check-verified locally (no toolchain) - the push triggers CI
(clippy -D warnings + build + test) which is the verification gate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,7 @@ use std::net::SocketAddr;
|
||||
use tracing::{error, info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::ViewerAccess;
|
||||
use crate::db::{self, Database};
|
||||
use crate::proto;
|
||||
use crate::session::SessionManager;
|
||||
@@ -439,9 +440,17 @@ pub async fn viewer_ws_handler(
|
||||
return Err(StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
// The access mode comes from the VERIFIED token claims (signed; cannot be
|
||||
// forged or upgraded by the client). The relay enforces it: a view-only token
|
||||
// has its input silently dropped, a control token forwards input as today.
|
||||
let access = claims.access;
|
||||
|
||||
info!(
|
||||
"Viewer (user {}) authenticated via session-scoped token for session {} from {}",
|
||||
claims.sub, requested_session_id, client_ip
|
||||
"Viewer (user {}) authenticated via session-scoped {} token for session {} from {}",
|
||||
claims.sub,
|
||||
access.as_str(),
|
||||
requested_session_id,
|
||||
client_ip
|
||||
);
|
||||
|
||||
let session_id = params.session_id;
|
||||
@@ -463,6 +472,7 @@ pub async fn viewer_ws_handler(
|
||||
db,
|
||||
session_id,
|
||||
viewer_name,
|
||||
access,
|
||||
Some(client_ip),
|
||||
)
|
||||
}))
|
||||
@@ -773,12 +783,19 @@ async fn handle_agent_connection(
|
||||
}
|
||||
|
||||
/// Handle a viewer WebSocket connection
|
||||
///
|
||||
/// `access` is the VERIFIED access mode from the viewer token's signed claims.
|
||||
/// For [`ViewerAccess::ViewOnly`] the relay refuses to forward ANY input event
|
||||
/// to the agent (video still streams out); for [`ViewerAccess::Control`] input
|
||||
/// is forwarded subject to the per-viewer rate throttle below.
|
||||
#[allow(clippy::too_many_arguments)] // signature mirrors the relay/session protocol contract; refactor into a params struct tracked in docs/specs/native-remote-control/
|
||||
async fn handle_viewer_connection(
|
||||
socket: WebSocket,
|
||||
sessions: SessionManager,
|
||||
db: Option<Database>,
|
||||
session_id_str: String,
|
||||
viewer_name: String,
|
||||
access: ViewerAccess,
|
||||
client_ip: Option<std::net::IpAddr>,
|
||||
) {
|
||||
// Parse session ID
|
||||
@@ -850,6 +867,10 @@ async fn handle_viewer_connection(
|
||||
let mut input_tokens: f64 = VIEWER_INPUT_EVENTS_PER_SEC as f64;
|
||||
let mut last_refill = std::time::Instant::now();
|
||||
let mut dropped_input: u64 = 0;
|
||||
// Count of input events refused solely because this is a view-only token, so
|
||||
// the refusal can be observed (logged once-per-power-of-two) without leaking
|
||||
// event contents or spamming the log on a noisy viewer.
|
||||
let mut refused_viewonly_input: u64 = 0;
|
||||
|
||||
// Main loop: receive input from viewer and forward to agent
|
||||
while let Some(msg) = ws_receiver.next().await {
|
||||
@@ -859,9 +880,34 @@ async fn handle_viewer_connection(
|
||||
match proto::Message::decode(data.as_ref()) {
|
||||
Ok(proto_msg) => {
|
||||
match &proto_msg.payload {
|
||||
Some(proto::message::Payload::MouseEvent(_))
|
||||
| Some(proto::message::Payload::KeyEvent(_))
|
||||
| Some(proto::message::Payload::SpecialKey(_))
|
||||
if !access.can_control() =>
|
||||
{
|
||||
// VIEW-ONLY ENFORCEMENT (authz-strength split,
|
||||
// audit CRITICAL #1). This viewer holds a
|
||||
// view-only token: the relay refuses to forward
|
||||
// ANY injected-input event to the agent. We drop
|
||||
// it silently (the viewer keeps receiving video).
|
||||
// The access mode came from the SIGNED token, so a
|
||||
// view-only viewer cannot escalate to control by
|
||||
// crafting messages — the relay simply never
|
||||
// relays their input.
|
||||
refused_viewonly_input += 1;
|
||||
if refused_viewonly_input.is_power_of_two() {
|
||||
warn!(
|
||||
"View-only viewer {} sent input on session {}; \
|
||||
refusing to forward ({} input events refused so far)",
|
||||
viewer_id, session_id, refused_viewonly_input
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(proto::message::Payload::MouseEvent(_))
|
||||
| Some(proto::message::Payload::KeyEvent(_))
|
||||
| Some(proto::message::Payload::SpecialKey(_)) => {
|
||||
// Control token: forward input (subject to the
|
||||
// per-viewer rate throttle below).
|
||||
// Refill the token bucket based on elapsed time,
|
||||
// capped at one second's worth of capacity.
|
||||
let elapsed = last_refill.elapsed().as_secs_f64();
|
||||
|
||||
Reference in New Issue
Block a user