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.
|
||||
|
||||
Reference in New Issue
Block a user