feat(server): viewer-token view-only/control split - closes CRITICAL #1
Some checks failed
Build and Test / Build Server (Linux) (push) Failing after 3m20s
Build and Test / Build Agent (Windows) (push) Successful in 6m9s
Build and Test / Security Audit (push) Successful in 4m21s
Build and Test / Build Summary (push) Has been skipped

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:
2026-05-29 19:24:32 -07:00
parent 0f258788f9
commit a453e7984e
5 changed files with 256 additions and 34 deletions

View File

@@ -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();