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:
@@ -105,8 +105,11 @@ pub(crate) fn cert_may_access(method: &Method, path: &str) -> bool {
|
||||
"/api/v1/host"
|
||||
| "/api/v1/compositors"
|
||||
| "/api/v1/status"
|
||||
| "/api/v1/clients"
|
||||
| "/api/v1/native/clients"
|
||||
// The paired-client ROSTERS (`/clients`, `/native/clients`) are deliberately NOT on
|
||||
// this lane — they expose every OTHER paired device's name + fingerprint, which one
|
||||
// paired streaming client must not be able to enumerate. Only the bearer/loopback
|
||||
// console needs them, and no first-party client calls them (security-review 2026-07-17).
|
||||
//
|
||||
// The native clients browse the game library with their cert (no bearer token); the
|
||||
// library MUTATIONS (POST/PUT/DELETE /library/custom) stay token-only via the exact
|
||||
// GET-path match above.
|
||||
|
||||
@@ -47,7 +47,12 @@ pub(crate) struct SubmitPin {
|
||||
pub(crate) async fn list_paired_clients(
|
||||
State(st): State<Arc<MgmtState>>,
|
||||
) -> Json<Vec<PairedClient>> {
|
||||
let ders = st.app.paired.lock().unwrap().clone();
|
||||
let ders = st
|
||||
.app
|
||||
.paired
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clone();
|
||||
Json(ders.iter().map(|der| client_info(der)).collect())
|
||||
}
|
||||
|
||||
@@ -101,7 +106,7 @@ pub(crate) async fn unpair_client(
|
||||
"fingerprint must be the 64-char hex SHA-256 of the client certificate DER",
|
||||
);
|
||||
}
|
||||
let mut paired = st.app.paired.lock().unwrap();
|
||||
let mut paired = st.app.paired.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let before = paired.len();
|
||||
paired.retain(|der| !hex::encode(Sha256::digest(der)).eq_ignore_ascii_case(&fingerprint));
|
||||
if paired.len() < before {
|
||||
|
||||
@@ -306,8 +306,8 @@ pub(crate) async fn list_compositors() -> Json<Vec<AvailableCompositor>> {
|
||||
)]
|
||||
pub(crate) async fn get_status(State(st): State<Arc<MgmtState>>) -> Json<RuntimeStatus> {
|
||||
// GameStream plane (set by RTSP/nvhttp on the compat path).
|
||||
let gs_launch = *st.app.launch.lock().unwrap();
|
||||
let gs_stream = *st.app.stream.lock().unwrap();
|
||||
let gs_launch = *st.app.launch.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let gs_stream = *st.app.stream.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let gs_video = st.app.streaming.load(Ordering::SeqCst);
|
||||
let gs_audio = st.app.audio_streaming.load(Ordering::SeqCst);
|
||||
// Native punktfunk/1 plane (published by the native video loop; the default plane). See
|
||||
@@ -362,7 +362,12 @@ pub(crate) async fn get_status(State(st): State<Arc<MgmtState>>) -> Json<Runtime
|
||||
video_streaming: gs_video || !native.is_empty(),
|
||||
audio_streaming: gs_audio || !native.is_empty(),
|
||||
pin_pending: st.app.pairing.pin.awaiting_pin(),
|
||||
paired_clients: st.app.paired.lock().unwrap().len() as u32,
|
||||
paired_clients: st
|
||||
.app
|
||||
.paired
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.len() as u32,
|
||||
active_sessions: native.len() as u32 + u32::from(gs_video),
|
||||
session,
|
||||
stream,
|
||||
@@ -417,7 +422,12 @@ pub(crate) async fn get_local_summary(State(st): State<Arc<MgmtState>>) -> Json<
|
||||
video_streaming: st.app.streaming.load(Ordering::SeqCst),
|
||||
audio_streaming: st.app.audio_streaming.load(Ordering::SeqCst),
|
||||
session,
|
||||
paired_clients: st.app.paired.lock().unwrap().len() as u32,
|
||||
paired_clients: st
|
||||
.app
|
||||
.paired
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.len() as u32,
|
||||
native_paired_clients,
|
||||
pin_pending: st.app.pairing.pin.awaiting_pin(),
|
||||
pending_approvals,
|
||||
|
||||
@@ -21,8 +21,8 @@ use std::sync::atomic::Ordering;
|
||||
pub(crate) async fn stop_session(State(st): State<Arc<MgmtState>>) -> StatusCode {
|
||||
let was_streaming = st.app.streaming.swap(false, Ordering::SeqCst);
|
||||
st.app.audio_streaming.store(false, Ordering::SeqCst);
|
||||
*st.app.launch.lock().unwrap() = None;
|
||||
*st.app.stream.lock().unwrap() = None;
|
||||
*st.app.launch.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
*st.app.stream.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
// Native plane: the GameStream flags above don't reach it (it runs its own loops off the shared
|
||||
// session registry), so signal every live native session to tear down too.
|
||||
let native = crate::session_status::count();
|
||||
|
||||
@@ -121,8 +121,6 @@ async fn cert_auth_is_a_read_only_allowlist() {
|
||||
"/api/v1/host",
|
||||
"/api/v1/status",
|
||||
"/api/v1/compositors",
|
||||
"/api/v1/clients",
|
||||
"/api/v1/native/clients",
|
||||
"/api/v1/library",
|
||||
] {
|
||||
assert_ne!(
|
||||
@@ -131,6 +129,15 @@ async fn cert_auth_is_a_read_only_allowlist() {
|
||||
"a paired streaming cert should authorize GET {p}"
|
||||
);
|
||||
}
|
||||
// The paired-client ROSTERS are token-only: one paired cert must NOT be able to enumerate every
|
||||
// other paired device's name + fingerprint (security-review 2026-07-17).
|
||||
for p in ["/api/v1/clients", "/api/v1/native/clients"] {
|
||||
assert_eq!(
|
||||
send_cert(&app, get_req(p), fp).await,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"the client roster {p} must require the bearer token, not just a paired cert"
|
||||
);
|
||||
}
|
||||
// PIN-exposing GET + state-changing routes → token-only (cert rejected without a bearer).
|
||||
assert_eq!(
|
||||
send_cert(&app, get_req("/api/v1/native/pair"), fp).await,
|
||||
|
||||
Reference in New Issue
Block a user