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:
2026-07-17 17:14:57 +02:00
parent 4d89dcd3d7
commit 600693914f
21 changed files with 292 additions and 75 deletions
+36 -6
View File
@@ -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))
}
}
+44 -18
View File
@@ -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);