fix(security): land the 2026-07 audit fixes — SSRF guards, roster lane, SYSTEM path hygiene
The low/medium findings from the July host+Windows security review, as implemented in the audit session's working tree: - Webhooks and library-art fetches refuse loopback/link-local/metadata targets and no longer follow redirects (SSRF pivots from the privileged host process). - The paired-client rosters (/clients, /native/clients) move off the streaming-client auth lane — one paired device could enumerate every other device's name + fingerprint; only the bearer/loopback console keeps them. - Device-name sanitizing extends to bidi/format control characters (spoofable rendering) via the shared native_pairing::is_spoofy_char; stream-marker quoting uses the same set. - The SYSTEM service resolves powershell by its full System32 path — CreateProcess checks the launching EXE's own directory first, so a planted powershell.exe beside the host binary would have run as SYSTEM. - The pf-vdisplay driver's opt-in file log moves from world-writable C:\Users\Public to WUDFHost's own temp dir. - GameStream pairing sessions are single-use (removed whatever the outcome). - Uninstall also removes the pf_mouse driver-store entry (rider from the virtual-HID-mouse work). - openapi.json regenerated (hardened-config-dir doc wording). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -59,6 +59,33 @@ fn peer_is_paired(peer: &Option<Extension<PeerCertFingerprint>>, st: &AppState)
|
||||
.any(|der| hex::encode(punktfunk_core::quic::endpoint::cert_fingerprint(der)) == *fp)
|
||||
}
|
||||
|
||||
/// The peer's client-cert fingerprint as raw bytes — the form [`LaunchSession::owner_fp`] stores.
|
||||
/// `None` when no (or a blank/short) cert was presented.
|
||||
fn peer_fp(peer: &Option<Extension<PeerCertFingerprint>>) -> Option<[u8; 32]> {
|
||||
match peer {
|
||||
Some(Extension(PeerCertFingerprint(Some(fp)))) => hex::decode(fp)
|
||||
.ok()
|
||||
.and_then(|v| <[u8; 32]>::try_from(v).ok()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the caller may control (resume/cancel) the current launch session. `true` when there is
|
||||
/// no session (nothing to protect — keeps cancel idempotent), or the session's owner fingerprint
|
||||
/// matches the caller's. Only a paired-but-DIFFERENT client with a known, mismatching fingerprint is
|
||||
/// rejected — so a same-client control action always succeeds, but one paired client can no longer
|
||||
/// resume or cancel *another* paired client's session (security-review 2026-07-17).
|
||||
fn peer_may_control_session(peer: &Option<Extension<PeerCertFingerprint>>, st: &AppState) -> bool {
|
||||
match st.launch.lock().unwrap().as_ref() {
|
||||
None => true,
|
||||
Some(session) => match (session.owner_fp, peer_fp(peer)) {
|
||||
(Some(owner), Some(caller)) => owner == caller,
|
||||
// Owner or caller fingerprint unknown → the `peer_is_paired` gate already applied stands.
|
||||
_ => true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn router(state: Arc<AppState>, https: bool) -> Router {
|
||||
Router::new()
|
||||
.route("/serverinfo", get(h_serverinfo))
|
||||
@@ -131,12 +158,7 @@ async fn h_launch(
|
||||
tracing::warn!("launch rejected — client is not paired");
|
||||
return xml(error_xml()).into_response();
|
||||
}
|
||||
let req_fp: Option<[u8; 32]> = match &peer {
|
||||
Some(Extension(PeerCertFingerprint(Some(fp)))) => hex::decode(fp)
|
||||
.ok()
|
||||
.and_then(|v| <[u8; 32]>::try_from(v).ok()),
|
||||
_ => None,
|
||||
};
|
||||
let req_fp: Option<[u8; 32]> = peer_fp(&peer);
|
||||
|
||||
// Mode-conflict ADMISSION (Stage 4) — GameStream is single-session (`st.launch`), so a DIFFERENT
|
||||
// paired client launching while a session is live is governed by `mode_conflict` (see
|
||||
@@ -205,6 +227,10 @@ async fn h_resume(
|
||||
tracing::warn!("resume rejected — client is not paired");
|
||||
return xml(error_xml());
|
||||
}
|
||||
if !peer_may_control_session(&peer, &st) {
|
||||
tracing::warn!("resume rejected — caller does not own the session");
|
||||
return xml(error_xml());
|
||||
}
|
||||
if st.launch.lock().unwrap().is_some() {
|
||||
xml(session_url_xml(&st, "resume"))
|
||||
} else {
|
||||
@@ -220,6 +246,10 @@ async fn h_cancel(
|
||||
tracing::warn!("cancel rejected — client is not paired");
|
||||
return xml(error_xml());
|
||||
}
|
||||
if !peer_may_control_session(&peer, &st) {
|
||||
tracing::warn!("cancel rejected — caller does not own the session");
|
||||
return xml(error_xml());
|
||||
}
|
||||
*st.launch.lock().unwrap() = None;
|
||||
// Quit semantics: stop the running media threads (they observe these flags) so the session
|
||||
// actually ends — the virtual output/gamescope teardown follows via the capturer's RAII.
|
||||
|
||||
@@ -259,11 +259,22 @@ impl Pairing {
|
||||
// Constant-time compare so a timing side-channel can't probe the expected hash.
|
||||
let hash_ok = crypto::ct_eq(&expected, &s.client_hash);
|
||||
let sig_ok = verify256(&s.client_pubkey, client_secret, client_sig).is_ok();
|
||||
// Clone what the success branch needs so the `&mut map` borrow (`s`) is released before we
|
||||
// mutate the map below.
|
||||
let client_cert_der = s.client_cert_der.clone();
|
||||
// The pairing session is single-use: remove it now, WHATEVER the outcome. Phase 4 runs over
|
||||
// plain HTTP, so a passive observer captures the request; without this, a replay re-passes the
|
||||
// hash/sig check and re-pins the (same) cert over and over — unbounded `paired`/paired.json
|
||||
// growth + PairingCompleted event spam until restart (security-review 2026-07-17).
|
||||
map.remove(uniqueid);
|
||||
if hash_ok && sig_ok {
|
||||
{
|
||||
let mut store = paired_store.lock().unwrap();
|
||||
store.push(s.client_cert_der.clone());
|
||||
super::save_paired(&store);
|
||||
// De-dup: re-pairing an already-trusted cert must not append a duplicate DER.
|
||||
if !store.iter().any(|der| der == &client_cert_der) {
|
||||
store.push(client_cert_der.clone());
|
||||
super::save_paired(&store);
|
||||
}
|
||||
}
|
||||
tracing::info!(uniqueid, "pairing phase 4 complete — client cert pinned");
|
||||
// Lifecycle event, plane parity with `NativePairing::add` (RFC §4). GameStream
|
||||
@@ -271,7 +282,7 @@ impl Pairing {
|
||||
crate::events::emit(crate::events::EventKind::PairingCompleted {
|
||||
device: crate::events::DeviceRef {
|
||||
name: uniqueid.to_string(),
|
||||
fingerprint: hex::encode(crypto::sha256(&[s.client_cert_der.as_slice()])),
|
||||
fingerprint: hex::encode(crypto::sha256(&[client_cert_der.as_slice()])),
|
||||
plane: crate::events::Plane::Gamestream,
|
||||
},
|
||||
});
|
||||
@@ -283,7 +294,6 @@ impl Pairing {
|
||||
sig_ok,
|
||||
"pairing phase 4 rejected — PIN or cert mismatch"
|
||||
);
|
||||
map.remove(uniqueid);
|
||||
Ok(paired_xml("", false))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
use super::audio;
|
||||
use super::stream::{self, StreamConfig};
|
||||
use super::{AppState, AUDIO_PORT, CONTROL_PORT, RTSP_PORT, VIDEO_PORT};
|
||||
use super::{AppState, LaunchSession, AUDIO_PORT, CONTROL_PORT, RTSP_PORT, VIDEO_PORT};
|
||||
use crate::encode::Codec;
|
||||
use anyhow::{Context, Result};
|
||||
use std::collections::HashMap;
|
||||
@@ -172,6 +172,24 @@ fn parse_request(head: &str, body: String) -> Request {
|
||||
}
|
||||
}
|
||||
|
||||
/// Authorize a state-changing RTSP verb against the pairing-gated `/launch` session. The RTSP/UDP
|
||||
/// media plane is UNAUTHENTICATED, so `ANNOUNCE`/`PLAY`/`TEARDOWN` are honored only for the paired
|
||||
/// client that completed `/launch` (which set `state.launch`), and — when the launching IP is known
|
||||
/// — only from that same source IP. An unpaired RTSP peer can therefore neither start a stream,
|
||||
/// overwrite a paired client's negotiated config, nor tear its active stream down
|
||||
/// (security-review 2026-06-28 #4; extended from `PLAY`-only to `ANNOUNCE`/`TEARDOWN` 2026-07-17).
|
||||
/// `nvhttp` gates `/launch` on a pinned client cert. Returns the launch session on success — `PLAY`
|
||||
/// needs its keys/appid; the other verbs use it as a bool gate.
|
||||
fn authorized_launch(state: &AppState, peer: Option<SocketAddr>) -> Option<LaunchSession> {
|
||||
let ls = (*state.launch.lock().unwrap())?;
|
||||
match (ls.peer_ip, peer.map(|p| p.ip())) {
|
||||
// Launching IP known on both sides but mismatched → not the owner.
|
||||
(Some(want), Some(got)) if want != got => None,
|
||||
// Owner IP matches, or the address couldn't be captured on one side → launch-present only.
|
||||
_ => Some(ls),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_request(req: &Request, state: &AppState, peer: Option<SocketAddr>) -> String {
|
||||
match req.method.as_str() {
|
||||
"OPTIONS" => response(
|
||||
@@ -203,6 +221,16 @@ fn handle_request(req: &Request, state: &AppState, peer: Option<SocketAddr>) ->
|
||||
)
|
||||
}
|
||||
"ANNOUNCE" => {
|
||||
// ANNOUNCE overwrites the session's negotiated stream/audio config. Gate it to the
|
||||
// launch owner so an unpaired RTSP peer can't scribble a paired client's config (or
|
||||
// race one in ahead of the owner's own ANNOUNCE).
|
||||
if authorized_launch(state, peer).is_none() {
|
||||
tracing::warn!(
|
||||
?peer,
|
||||
"RTSP ANNOUNCE — refused: not the paired `/launch` owner"
|
||||
);
|
||||
return response_status("401 Unauthorized", &req.cseq, &[], None);
|
||||
}
|
||||
let map = parse_announce(&req.body);
|
||||
match stream_config(&map) {
|
||||
Some(cfg) => {
|
||||
@@ -217,25 +245,14 @@ fn handle_request(req: &Request, state: &AppState, peer: Option<SocketAddr>) ->
|
||||
response(&req.cseq, &[], None)
|
||||
}
|
||||
"PLAY" => {
|
||||
// The RTSP/UDP media plane is UNAUTHENTICATED. A stream may start only for the paired
|
||||
// client that completed the pairing-gated `/launch` (which set `state.launch`), and —
|
||||
// when the launching IP is known — only from that same source IP. So an unpaired RTSP
|
||||
// peer can neither start a stream on an idle host nor ride a paired client's active
|
||||
// launch (security-review 2026-06-28 #4). `nvhttp` gates `/launch` on a pinned cert.
|
||||
let launch = *state.launch.lock().unwrap();
|
||||
let Some(ls) = launch else {
|
||||
tracing::warn!(?peer, "RTSP PLAY — refused: no paired `/launch` session");
|
||||
// A stream may start only for the paired client that owns the current `/launch`
|
||||
// (`authorized_launch` enforces launch-present + matching source IP). `nvhttp` gates
|
||||
// `/launch` on a pinned cert, so an unpaired RTSP peer can neither start a stream on an
|
||||
// idle host nor ride a paired client's active launch.
|
||||
let Some(ls) = authorized_launch(state, peer) else {
|
||||
tracing::warn!(?peer, "RTSP PLAY — refused: not the paired `/launch` owner");
|
||||
return response_status("401 Unauthorized", &req.cseq, &[], None);
|
||||
};
|
||||
if let (Some(want), Some(got)) = (ls.peer_ip, peer.map(|p| p.ip())) {
|
||||
if want != got {
|
||||
tracing::warn!(
|
||||
%want, %got,
|
||||
"RTSP PLAY — refused: peer IP does not match the launching client"
|
||||
);
|
||||
return response_status("401 Unauthorized", &req.cseq, &[], None);
|
||||
}
|
||||
}
|
||||
let cfg = *state.stream.lock().unwrap();
|
||||
match cfg {
|
||||
Some(cfg) if !state.streaming.swap(true, Ordering::SeqCst) => {
|
||||
@@ -271,6 +288,15 @@ fn handle_request(req: &Request, state: &AppState, peer: Option<SocketAddr>) ->
|
||||
response(&req.cseq, &[("Session", "DEADBEEFCAFE;timeout = 90")], None)
|
||||
}
|
||||
"TEARDOWN" => {
|
||||
// Gate to the launch owner so an unpaired RTSP peer can't stop a paired client's media
|
||||
// threads (a trivial, repeatable stream DoS).
|
||||
if authorized_launch(state, peer).is_none() {
|
||||
tracing::warn!(
|
||||
?peer,
|
||||
"RTSP TEARDOWN — refused: not the paired `/launch` owner"
|
||||
);
|
||||
return response_status("401 Unauthorized", &req.cseq, &[], None);
|
||||
}
|
||||
// Signal both stream threads to stop.
|
||||
state.streaming.store(false, Ordering::SeqCst);
|
||||
state.audio_streaming.store(false, Ordering::SeqCst);
|
||||
|
||||
Reference in New Issue
Block a user