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:
+1
-1
@@ -2744,7 +2744,7 @@
|
|||||||
},
|
},
|
||||||
"CustomEntry": {
|
"CustomEntry": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "A user-added title, persisted in `~/.config/punktfunk/library.json`. Same shape the API\nreturns and the web console edits.",
|
"description": "A user-added title, persisted in the hardened host config dir's `library.json` (see\n[`custom_path`]). Same shape the API returns and the web console edits.",
|
||||||
"required": [
|
"required": [
|
||||||
"id",
|
"id",
|
||||||
"title"
|
"title"
|
||||||
|
|||||||
@@ -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)
|
.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 {
|
fn router(state: Arc<AppState>, https: bool) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/serverinfo", get(h_serverinfo))
|
.route("/serverinfo", get(h_serverinfo))
|
||||||
@@ -131,12 +158,7 @@ async fn h_launch(
|
|||||||
tracing::warn!("launch rejected — client is not paired");
|
tracing::warn!("launch rejected — client is not paired");
|
||||||
return xml(error_xml()).into_response();
|
return xml(error_xml()).into_response();
|
||||||
}
|
}
|
||||||
let req_fp: Option<[u8; 32]> = match &peer {
|
let req_fp: Option<[u8; 32]> = peer_fp(&peer);
|
||||||
Some(Extension(PeerCertFingerprint(Some(fp)))) => hex::decode(fp)
|
|
||||||
.ok()
|
|
||||||
.and_then(|v| <[u8; 32]>::try_from(v).ok()),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mode-conflict ADMISSION (Stage 4) — GameStream is single-session (`st.launch`), so a DIFFERENT
|
// 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
|
// 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");
|
tracing::warn!("resume rejected — client is not paired");
|
||||||
return xml(error_xml());
|
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() {
|
if st.launch.lock().unwrap().is_some() {
|
||||||
xml(session_url_xml(&st, "resume"))
|
xml(session_url_xml(&st, "resume"))
|
||||||
} else {
|
} else {
|
||||||
@@ -220,6 +246,10 @@ async fn h_cancel(
|
|||||||
tracing::warn!("cancel rejected — client is not paired");
|
tracing::warn!("cancel rejected — client is not paired");
|
||||||
return xml(error_xml());
|
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;
|
*st.launch.lock().unwrap() = None;
|
||||||
// Quit semantics: stop the running media threads (they observe these flags) so the session
|
// 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.
|
// actually ends — the virtual output/gamescope teardown follows via the capturer's RAII.
|
||||||
|
|||||||
@@ -259,19 +259,30 @@ impl Pairing {
|
|||||||
// Constant-time compare so a timing side-channel can't probe the expected hash.
|
// 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 hash_ok = crypto::ct_eq(&expected, &s.client_hash);
|
||||||
let sig_ok = verify256(&s.client_pubkey, client_secret, client_sig).is_ok();
|
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 {
|
if hash_ok && sig_ok {
|
||||||
{
|
{
|
||||||
let mut store = paired_store.lock().unwrap();
|
let mut store = paired_store.lock().unwrap();
|
||||||
store.push(s.client_cert_der.clone());
|
// 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);
|
super::save_paired(&store);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
tracing::info!(uniqueid, "pairing phase 4 complete — client cert pinned");
|
tracing::info!(uniqueid, "pairing phase 4 complete — client cert pinned");
|
||||||
// Lifecycle event, plane parity with `NativePairing::add` (RFC §4). GameStream
|
// Lifecycle event, plane parity with `NativePairing::add` (RFC §4). GameStream
|
||||||
// pairing has no device name — the client's uniqueid is the identity it presents.
|
// pairing has no device name — the client's uniqueid is the identity it presents.
|
||||||
crate::events::emit(crate::events::EventKind::PairingCompleted {
|
crate::events::emit(crate::events::EventKind::PairingCompleted {
|
||||||
device: crate::events::DeviceRef {
|
device: crate::events::DeviceRef {
|
||||||
name: uniqueid.to_string(),
|
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,
|
plane: crate::events::Plane::Gamestream,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -283,7 +294,6 @@ impl Pairing {
|
|||||||
sig_ok,
|
sig_ok,
|
||||||
"pairing phase 4 rejected — PIN or cert mismatch"
|
"pairing phase 4 rejected — PIN or cert mismatch"
|
||||||
);
|
);
|
||||||
map.remove(uniqueid);
|
|
||||||
Ok(paired_xml("", false))
|
Ok(paired_xml("", false))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
use super::audio;
|
use super::audio;
|
||||||
use super::stream::{self, StreamConfig};
|
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 crate::encode::Codec;
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use std::collections::HashMap;
|
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 {
|
fn handle_request(req: &Request, state: &AppState, peer: Option<SocketAddr>) -> String {
|
||||||
match req.method.as_str() {
|
match req.method.as_str() {
|
||||||
"OPTIONS" => response(
|
"OPTIONS" => response(
|
||||||
@@ -203,6 +221,16 @@ fn handle_request(req: &Request, state: &AppState, peer: Option<SocketAddr>) ->
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
"ANNOUNCE" => {
|
"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);
|
let map = parse_announce(&req.body);
|
||||||
match stream_config(&map) {
|
match stream_config(&map) {
|
||||||
Some(cfg) => {
|
Some(cfg) => {
|
||||||
@@ -217,25 +245,14 @@ fn handle_request(req: &Request, state: &AppState, peer: Option<SocketAddr>) ->
|
|||||||
response(&req.cseq, &[], None)
|
response(&req.cseq, &[], None)
|
||||||
}
|
}
|
||||||
"PLAY" => {
|
"PLAY" => {
|
||||||
// The RTSP/UDP media plane is UNAUTHENTICATED. A stream may start only for the paired
|
// A stream may start only for the paired client that owns the current `/launch`
|
||||||
// client that completed the pairing-gated `/launch` (which set `state.launch`), and —
|
// (`authorized_launch` enforces launch-present + matching source IP). `nvhttp` gates
|
||||||
// when the launching IP is known — only from that same source IP. So an unpaired RTSP
|
// `/launch` on a pinned cert, so an unpaired RTSP peer can neither start a stream on an
|
||||||
// peer can neither start a stream on an idle host nor ride a paired client's active
|
// idle host nor ride a paired client's active launch.
|
||||||
// launch (security-review 2026-06-28 #4). `nvhttp` gates `/launch` on a pinned cert.
|
let Some(ls) = authorized_launch(state, peer) else {
|
||||||
let launch = *state.launch.lock().unwrap();
|
tracing::warn!(?peer, "RTSP PLAY — refused: not the paired `/launch` owner");
|
||||||
let Some(ls) = launch else {
|
|
||||||
tracing::warn!(?peer, "RTSP PLAY — refused: no paired `/launch` session");
|
|
||||||
return response_status("401 Unauthorized", &req.cseq, &[], None);
|
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();
|
let cfg = *state.stream.lock().unwrap();
|
||||||
match cfg {
|
match cfg {
|
||||||
Some(cfg) if !state.streaming.swap(true, Ordering::SeqCst) => {
|
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)
|
response(&req.cseq, &[("Session", "DEADBEEFCAFE;timeout = 90")], None)
|
||||||
}
|
}
|
||||||
"TEARDOWN" => {
|
"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.
|
// Signal both stream threads to stop.
|
||||||
state.streaming.store(false, Ordering::SeqCst);
|
state.streaming.store(false, Ordering::SeqCst);
|
||||||
state.audio_streaming.store(false, Ordering::SeqCst);
|
state.audio_streaming.store(false, Ordering::SeqCst);
|
||||||
|
|||||||
@@ -150,6 +150,19 @@ impl HooksConfig {
|
|||||||
if !url.starts_with("https://") && !url.starts_with("http://") {
|
if !url.starts_with("https://") && !url.starts_with("http://") {
|
||||||
return Err(at("`webhook` must be an http(s):// URL"));
|
return Err(at("`webhook` must be an http(s):// URL"));
|
||||||
}
|
}
|
||||||
|
if webhook_host_is_internal(url) {
|
||||||
|
return Err(at(
|
||||||
|
"`webhook` must not target a loopback/link-local/metadata host",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// A signed webhook over plaintext http:// sends the HMAC'd event body in the clear.
|
||||||
|
// Warn rather than reject (an internal-only `http://` receiver may be intentional).
|
||||||
|
if h.hmac_secret_file.is_some() && url.starts_with("http://") {
|
||||||
|
tracing::warn!(
|
||||||
|
%url,
|
||||||
|
"webhook has an hmac_secret_file but is http:// — the signed body is sent in cleartext; prefer https://"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if h.timeout_s == 0 || h.timeout_s > MAX_TIMEOUT_S {
|
if h.timeout_s == 0 || h.timeout_s > MAX_TIMEOUT_S {
|
||||||
return Err(at(&format!("`timeout_s` must be 1–{MAX_TIMEOUT_S}")));
|
return Err(at(&format!("`timeout_s` must be 1–{MAX_TIMEOUT_S}")));
|
||||||
@@ -586,6 +599,45 @@ fn fire_webhook(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True if `url`'s host is a clearly-illegitimate webhook target — loopback, link-local (which
|
||||||
|
/// includes the `169.254.169.254` cloud-metadata endpoint), the unspecified address, or `localhost`
|
||||||
|
/// — so a tampered/misguided hooks.json can't make the privileged host POST event data to its own
|
||||||
|
/// services or a metadata endpoint (direct-SSRF guard; security-review 2026-07-17). Deliberately does
|
||||||
|
/// NOT block RFC-1918 / ULA / `.local` — a webhook to another box on the operator's own LAN is a
|
||||||
|
/// legitimate self-hosting config. A best-effort textual + IP-literal check (no DNS resolution, so
|
||||||
|
/// not a full anti-rebinding defense; the operator-gated config already limits the threat).
|
||||||
|
fn webhook_host_is_internal(url: &str) -> bool {
|
||||||
|
// scheme://[userinfo@]host[:port]/... → the bare host.
|
||||||
|
let after_scheme = url.split_once("://").map(|(_, r)| r).unwrap_or(url);
|
||||||
|
let authority = after_scheme.split(['/', '?', '#']).next().unwrap_or("");
|
||||||
|
let hostport = authority
|
||||||
|
.rsplit_once('@')
|
||||||
|
.map(|(_, h)| h)
|
||||||
|
.unwrap_or(authority);
|
||||||
|
let host = if let Some(rest) = hostport.strip_prefix('[') {
|
||||||
|
rest.split(']').next().unwrap_or("") // [::1]:443 → ::1
|
||||||
|
} else {
|
||||||
|
hostport
|
||||||
|
.rsplit_once(':')
|
||||||
|
.map(|(h, _)| h)
|
||||||
|
.unwrap_or(hostport)
|
||||||
|
};
|
||||||
|
let host = host.trim().to_ascii_lowercase();
|
||||||
|
if host.is_empty() || host == "localhost" || host.ends_with(".localhost") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
match host.parse::<std::net::IpAddr>() {
|
||||||
|
Ok(std::net::IpAddr::V4(v4)) => {
|
||||||
|
v4.is_loopback() || v4.is_link_local() || v4.is_unspecified()
|
||||||
|
}
|
||||||
|
Ok(std::net::IpAddr::V6(v6)) => {
|
||||||
|
// Loopback (::1), unspecified (::), or link-local fe80::/10.
|
||||||
|
v6.is_loopback() || v6.is_unspecified() || (v6.segments()[0] & 0xffc0) == 0xfe80
|
||||||
|
}
|
||||||
|
Err(_) => false, // a resolvable hostname — not statically classifiable here
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn post_webhook(url: &str, json: &str, secret_file: Option<&std::path::Path>) {
|
fn post_webhook(url: &str, json: &str, secret_file: Option<&std::path::Path>) {
|
||||||
// TLS is verified (ureq's default rustls roots); redirects are never followed, so a
|
// TLS is verified (ureq's default rustls roots); redirects are never followed, so a
|
||||||
// compromised receiver can't bounce the POST cross-origin (RFC §9.5).
|
// compromised receiver can't bounce the POST cross-origin (RFC §9.5).
|
||||||
|
|||||||
@@ -88,6 +88,10 @@ fn warm_art_once() {
|
|||||||
fn fetch_json(url: &str) -> Option<serde_json::Value> {
|
fn fetch_json(url: &str) -> Option<serde_json::Value> {
|
||||||
let agent = ureq::AgentBuilder::new()
|
let agent = ureq::AgentBuilder::new()
|
||||||
.timeout(std::time::Duration::from_secs(10))
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
|
// Don't follow redirects — a redirect target (`3xx` → `http://169.254.169.254/…` or an
|
||||||
|
// internal host) would be an SSRF pivot from the privileged host. Matches the webhook path
|
||||||
|
// (security-review 2026-07-17). A rare legitimately-redirecting CDN just yields no art.
|
||||||
|
.redirects(0)
|
||||||
.build();
|
.build();
|
||||||
let body = agent.get(url).call().ok()?.into_string().ok()?;
|
let body = agent.get(url).call().ok()?.into_string().ok()?;
|
||||||
serde_json::from_str(&body).ok()
|
serde_json::from_str(&body).ok()
|
||||||
@@ -123,6 +127,10 @@ pub(crate) fn fetch_image(url: &str) -> Option<(Vec<u8>, String)> {
|
|||||||
}
|
}
|
||||||
let agent = ureq::AgentBuilder::new()
|
let agent = ureq::AgentBuilder::new()
|
||||||
.timeout(std::time::Duration::from_secs(10))
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
|
// Don't follow redirects (SSRF pivot): this is called on launcher-cache- and custom-entry-
|
||||||
|
// supplied URLs, so a `3xx` to an internal/metadata endpoint must not be chased by the
|
||||||
|
// privileged host. Matches the webhook path (security-review 2026-07-17).
|
||||||
|
.redirects(0)
|
||||||
.build();
|
.build();
|
||||||
let resp = agent.get(url).call().ok()?;
|
let resp = agent.get(url).call().ok()?;
|
||||||
let ctype = resp
|
let ctype = resp
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// A user-added title, persisted in `~/.config/punktfunk/library.json`. Same shape the API
|
/// A user-added title, persisted in the hardened host config dir's `library.json` (see
|
||||||
/// returns and the web console edits.
|
/// [`custom_path`]). Same shape the API returns and the web console edits.
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
|
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
|
||||||
pub struct CustomEntry {
|
pub struct CustomEntry {
|
||||||
/// Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path).
|
/// Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path).
|
||||||
@@ -72,16 +72,13 @@ impl From<CustomEntry> for GameEntry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn config_dir() -> PathBuf {
|
|
||||||
std::env::var_os("XDG_CONFIG_HOME")
|
|
||||||
.map(PathBuf::from)
|
|
||||||
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
|
|
||||||
.unwrap_or_else(|| PathBuf::from("."))
|
|
||||||
.join("punktfunk")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn custom_path() -> PathBuf {
|
fn custom_path() -> PathBuf {
|
||||||
config_dir().join("library.json")
|
// The shared, hardened host config dir (`%ProgramData%\punktfunk` / `~/.config/punktfunk`, with
|
||||||
|
// the `PUNKTFUNK_CONFIG_DIR` override) — NOT a bespoke XDG/HOME resolver with a CWD-relative
|
||||||
|
// fallback. This file drives operator `prep`/`launch` command execution, so it must live where the
|
||||||
|
// rest of the privileged host config does and be DACL/0600-locked against a non-privileged local
|
||||||
|
// user planting one (security-review 2026-07-17). Matches hooks.json / the mgmt token.
|
||||||
|
pf_paths::config_dir().join("library.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load the custom entries (empty + non-fatal if the file is absent or malformed).
|
/// Load the custom entries (empty + non-fatal if the file is absent or malformed).
|
||||||
@@ -96,12 +93,18 @@ pub fn load_custom() -> Vec<CustomEntry> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn save_custom(entries: &[CustomEntry]) -> Result<()> {
|
fn save_custom(entries: &[CustomEntry]) -> Result<()> {
|
||||||
let dir = config_dir();
|
let dir = pf_paths::config_dir();
|
||||||
std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
|
// Owner-private dir (0700 / SYSTEM+Admins DACL) so a non-privileged local user can't plant a
|
||||||
|
// library.json whose `prep`/`launch` commands the host would later execute — the same trust
|
||||||
|
// boundary hooks.json and the mgmt token already use.
|
||||||
|
pf_paths::create_private_dir(&dir).with_context(|| format!("create {}", dir.display()))?;
|
||||||
let json = serde_json::to_string_pretty(entries)?;
|
let json = serde_json::to_string_pretty(entries)?;
|
||||||
// Write-then-rename so a crash mid-write never truncates the catalog.
|
// Write-then-rename so a crash mid-write never truncates the catalog; `write_secret_file` gives
|
||||||
|
// the temp file its restrictive perms (0600 / SYSTEM+Admins DACL) before the rename carries them
|
||||||
|
// to the final path.
|
||||||
let tmp = custom_path().with_extension("json.tmp");
|
let tmp = custom_path().with_extension("json.tmp");
|
||||||
std::fs::write(&tmp, json).with_context(|| format!("write {}", tmp.display()))?;
|
pf_paths::write_secret_file(&tmp, json.as_bytes())
|
||||||
|
.with_context(|| format!("write {}", tmp.display()))?;
|
||||||
std::fs::rename(&tmp, custom_path()).context("rename library.json")?;
|
std::fs::rename(&tmp, custom_path()).context("rename library.json")?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,8 +67,29 @@ fn gog_games() -> Vec<GameEntry> {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Join a manifest-supplied relative path onto `install`, rejecting anything that could escape (or
|
||||||
|
/// replace) it: a drive prefix (`C:`), a root (`\`), or a `..` component — any of which `Path::join`
|
||||||
|
/// lets REPLACE or climb out of `install` on Windows. Keeps a crafted `goggame-*.info` from pointing
|
||||||
|
/// the play task's exe or working dir at an arbitrary program (security-review 2026-07-17). `None`
|
||||||
|
/// ⇒ the path is out of bounds and the caller refuses it.
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn confined_join(install: &str, rel: &str) -> Option<PathBuf> {
|
||||||
|
use std::path::Component;
|
||||||
|
let rp = Path::new(rel);
|
||||||
|
if rp.components().any(|c| {
|
||||||
|
matches!(
|
||||||
|
c,
|
||||||
|
Component::Prefix(_) | Component::RootDir | Component::ParentDir
|
||||||
|
)
|
||||||
|
}) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(Path::new(install).join(rp))
|
||||||
|
}
|
||||||
|
|
||||||
/// The primary play task from `<install>\goggame-<id>.info`: `(absolute exe, args, working dir)`.
|
/// The primary play task from `<install>\goggame-<id>.info`: `(absolute exe, args, working dir)`.
|
||||||
/// Prefers `isPrimary` + `FileTask`, else the first `FileTask`. Paths are resolved against `install`.
|
/// Prefers `isPrimary` + `FileTask`, else the first `FileTask`. Paths are resolved against `install`
|
||||||
|
/// and confined to it ([`confined_join`]).
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
fn gog_play_task(install: &str, id: &str) -> Option<(String, String, String)> {
|
fn gog_play_task(install: &str, id: &str) -> Option<(String, String, String)> {
|
||||||
let text =
|
let text =
|
||||||
@@ -87,7 +108,8 @@ fn gog_play_task(install: &str, id: &str) -> Option<(String, String, String)> {
|
|||||||
})
|
})
|
||||||
.or_else(|| tasks.iter().find(|t| is_file(t)))?;
|
.or_else(|| tasks.iter().find(|t| is_file(t)))?;
|
||||||
let rel = pick.get("path").and_then(|s| s.as_str())?;
|
let rel = pick.get("path").and_then(|s| s.as_str())?;
|
||||||
let exe = Path::new(install).join(rel);
|
// Refuse the launch outright if the manifest's exe path escapes the install dir.
|
||||||
|
let exe = confined_join(install, rel)?;
|
||||||
let args = pick
|
let args = pick
|
||||||
.get("arguments")
|
.get("arguments")
|
||||||
.and_then(|s| s.as_str())
|
.and_then(|s| s.as_str())
|
||||||
@@ -96,7 +118,8 @@ fn gog_play_task(install: &str, id: &str) -> Option<(String, String, String)> {
|
|||||||
let workdir = pick
|
let workdir = pick
|
||||||
.get("workingDir")
|
.get("workingDir")
|
||||||
.and_then(|s| s.as_str())
|
.and_then(|s| s.as_str())
|
||||||
.map(|w| Path::new(install).join(w))
|
// A working dir that escapes falls back to the install root (safe) rather than failing.
|
||||||
|
.and_then(|w| confined_join(install, w))
|
||||||
.unwrap_or_else(|| Path::new(install).to_path_buf());
|
.unwrap_or_else(|| Path::new(install).to_path_buf());
|
||||||
Some((
|
Some((
|
||||||
exe.to_string_lossy().into_owned(),
|
exe.to_string_lossy().into_owned(),
|
||||||
|
|||||||
@@ -102,6 +102,19 @@ fn lutris_art(slug: &str) -> Artwork {
|
|||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
fn lutris_image(kind: &str, slug: &str) -> Option<String> {
|
fn lutris_image(kind: &str, slug: &str) -> Option<String> {
|
||||||
use base64::Engine as _;
|
use base64::Engine as _;
|
||||||
|
// `slug` comes verbatim from Lutris's `pga.db` (untrusted at this layer). Reject any path
|
||||||
|
// separator, parent ref, or NUL so a crafted slug can't escape the art roots and read an
|
||||||
|
// arbitrary `<slug>.jpg` off disk — the bytes are base64-inlined into the `/api/v1/library`
|
||||||
|
// JSON a paired client can GET, so an escape is an arbitrary-file-read exfil primitive
|
||||||
|
// (security-review 2026-07-17). Real Lutris slugs are `[a-z0-9-]`.
|
||||||
|
if slug.is_empty()
|
||||||
|
|| slug.contains('/')
|
||||||
|
|| slug.contains('\\')
|
||||||
|
|| slug.contains("..")
|
||||||
|
|| slug.contains('\0')
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
let home = std::env::var_os("HOME").map(PathBuf::from)?;
|
let home = std::env::var_os("HOME").map(PathBuf::from)?;
|
||||||
let roots = [
|
let roots = [
|
||||||
home.join(".local/share/lutris"),
|
home.join(".local/share/lutris"),
|
||||||
|
|||||||
@@ -37,6 +37,14 @@ fn xbox_games() -> Vec<GameEntry> {
|
|||||||
if !cfg.is_file() {
|
if !cfg.is_file() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// Cap the read like the other untrusted on-disk manifests (Epic `read_capped`, Lutris
|
||||||
|
// art) — a planted multi-GB MicrosoftGame.config under `<drive>:\XboxGames\…\Content\`
|
||||||
|
// must not OOM the privileged host during enumeration (security-review 2026-07-17). A
|
||||||
|
// real GDK manifest is a few KB.
|
||||||
|
match cfg.metadata() {
|
||||||
|
Ok(m) if m.len() <= 1024 * 1024 => {}
|
||||||
|
_ => continue,
|
||||||
|
}
|
||||||
let Ok(text) = std::fs::read_to_string(&cfg) else {
|
let Ok(text) = std::fs::read_to_string(&cfg) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -105,8 +105,11 @@ pub(crate) fn cert_may_access(method: &Method, path: &str) -> bool {
|
|||||||
"/api/v1/host"
|
"/api/v1/host"
|
||||||
| "/api/v1/compositors"
|
| "/api/v1/compositors"
|
||||||
| "/api/v1/status"
|
| "/api/v1/status"
|
||||||
| "/api/v1/clients"
|
// The paired-client ROSTERS (`/clients`, `/native/clients`) are deliberately NOT on
|
||||||
| "/api/v1/native/clients"
|
// 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
|
// 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
|
// library MUTATIONS (POST/PUT/DELETE /library/custom) stay token-only via the exact
|
||||||
// GET-path match above.
|
// GET-path match above.
|
||||||
|
|||||||
@@ -47,7 +47,12 @@ pub(crate) struct SubmitPin {
|
|||||||
pub(crate) async fn list_paired_clients(
|
pub(crate) async fn list_paired_clients(
|
||||||
State(st): State<Arc<MgmtState>>,
|
State(st): State<Arc<MgmtState>>,
|
||||||
) -> Json<Vec<PairedClient>> {
|
) -> 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())
|
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",
|
"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();
|
let before = paired.len();
|
||||||
paired.retain(|der| !hex::encode(Sha256::digest(der)).eq_ignore_ascii_case(&fingerprint));
|
paired.retain(|der| !hex::encode(Sha256::digest(der)).eq_ignore_ascii_case(&fingerprint));
|
||||||
if paired.len() < before {
|
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> {
|
pub(crate) async fn get_status(State(st): State<Arc<MgmtState>>) -> Json<RuntimeStatus> {
|
||||||
// GameStream plane (set by RTSP/nvhttp on the compat path).
|
// GameStream plane (set by RTSP/nvhttp on the compat path).
|
||||||
let gs_launch = *st.app.launch.lock().unwrap();
|
let gs_launch = *st.app.launch.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
let gs_stream = *st.app.stream.lock().unwrap();
|
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_video = st.app.streaming.load(Ordering::SeqCst);
|
||||||
let gs_audio = st.app.audio_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
|
// 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(),
|
video_streaming: gs_video || !native.is_empty(),
|
||||||
audio_streaming: gs_audio || !native.is_empty(),
|
audio_streaming: gs_audio || !native.is_empty(),
|
||||||
pin_pending: st.app.pairing.pin.awaiting_pin(),
|
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),
|
active_sessions: native.len() as u32 + u32::from(gs_video),
|
||||||
session,
|
session,
|
||||||
stream,
|
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),
|
video_streaming: st.app.streaming.load(Ordering::SeqCst),
|
||||||
audio_streaming: st.app.audio_streaming.load(Ordering::SeqCst),
|
audio_streaming: st.app.audio_streaming.load(Ordering::SeqCst),
|
||||||
session,
|
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,
|
native_paired_clients,
|
||||||
pin_pending: st.app.pairing.pin.awaiting_pin(),
|
pin_pending: st.app.pairing.pin.awaiting_pin(),
|
||||||
pending_approvals,
|
pending_approvals,
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ use std::sync::atomic::Ordering;
|
|||||||
pub(crate) async fn stop_session(State(st): State<Arc<MgmtState>>) -> StatusCode {
|
pub(crate) async fn stop_session(State(st): State<Arc<MgmtState>>) -> StatusCode {
|
||||||
let was_streaming = st.app.streaming.swap(false, Ordering::SeqCst);
|
let was_streaming = st.app.streaming.swap(false, Ordering::SeqCst);
|
||||||
st.app.audio_streaming.store(false, Ordering::SeqCst);
|
st.app.audio_streaming.store(false, Ordering::SeqCst);
|
||||||
*st.app.launch.lock().unwrap() = None;
|
*st.app.launch.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||||
*st.app.stream.lock().unwrap() = 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
|
// 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.
|
// session registry), so signal every live native session to tear down too.
|
||||||
let native = crate::session_status::count();
|
let native = crate::session_status::count();
|
||||||
|
|||||||
@@ -121,8 +121,6 @@ async fn cert_auth_is_a_read_only_allowlist() {
|
|||||||
"/api/v1/host",
|
"/api/v1/host",
|
||||||
"/api/v1/status",
|
"/api/v1/status",
|
||||||
"/api/v1/compositors",
|
"/api/v1/compositors",
|
||||||
"/api/v1/clients",
|
|
||||||
"/api/v1/native/clients",
|
|
||||||
"/api/v1/library",
|
"/api/v1/library",
|
||||||
] {
|
] {
|
||||||
assert_ne!(
|
assert_ne!(
|
||||||
@@ -131,6 +129,15 @@ async fn cert_auth_is_a_read_only_allowlist() {
|
|||||||
"a paired streaming cert should authorize GET {p}"
|
"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).
|
// PIN-exposing GET + state-changing routes → token-only (cert rejected without a bearer).
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
send_cert(&app, get_req("/api/v1/native/pair"), fp).await,
|
send_cert(&app, get_req("/api/v1/native/pair"), fp).await,
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ pub use approval::{PairingDecision, PendingRequest};
|
|||||||
pub use arming::PinAttempt;
|
pub use arming::PinAttempt;
|
||||||
pub use store::PairedClient;
|
pub use store::PairedClient;
|
||||||
|
|
||||||
|
/// Re-exported for the stream marker's quoting (its `imp` is `cfg(unix)` — gate alike, or the
|
||||||
|
/// Windows build trips `-D unused-imports`).
|
||||||
|
#[cfg(unix)]
|
||||||
|
pub(crate) use sanitize::is_spoofy_char;
|
||||||
/// The untrusted-device-name sanitizer lives in its own module (plan §W5); re-exported so
|
/// The untrusted-device-name sanitizer lives in its own module (plan §W5); re-exported so
|
||||||
/// `crate::native_pairing::sanitize_device_name` stays stable (the `native` accept loop
|
/// `crate::native_pairing::sanitize_device_name` stays stable (the `native` accept loop
|
||||||
/// reaches it there).
|
/// reaches it there).
|
||||||
|
|||||||
@@ -13,16 +13,7 @@ pub(crate) fn sanitize_device_name(name: &str, fp_hex: &str) -> String {
|
|||||||
let cleaned: String = name
|
let cleaned: String = name
|
||||||
.chars()
|
.chars()
|
||||||
.map(|c| if c == '\t' || c == '\n' { ' ' } else { c })
|
.map(|c| if c == '\t' || c == '\n' { ' ' } else { c })
|
||||||
.filter(|&c| {
|
.filter(|&c| !c.is_control() && !is_spoofy_char(c))
|
||||||
!c.is_control()
|
|
||||||
// Bidi/format controls that could spoof or reorder the displayed name.
|
|
||||||
&& !('\u{202A}'..='\u{202E}').contains(&c) // LRE..RLO/PDF
|
|
||||||
&& !('\u{2066}'..='\u{2069}').contains(&c) // LRI..PDI
|
|
||||||
&& c != '\u{200E}' // LRM
|
|
||||||
&& c != '\u{200F}' // RLM
|
|
||||||
&& c != '\u{061C}' // ALM
|
|
||||||
&& c != '\u{FEFF}' // BOM / zero-width no-break space
|
|
||||||
})
|
|
||||||
.collect();
|
.collect();
|
||||||
// Collapse internal whitespace runs, trim, cap at the wire limit.
|
// Collapse internal whitespace runs, trim, cap at the wire limit.
|
||||||
let collapsed = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
|
let collapsed = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||||
@@ -42,6 +33,19 @@ pub(crate) fn sanitize_device_name(name: &str, fp_hex: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A Unicode bidi/format control that could spoof or reorder a displayed name (an `RLO` making a
|
||||||
|
/// hostile device read like a trusted one). The canonical set — shared by every place that scrubs an
|
||||||
|
/// untrusted client name before display/storage (device names here, the stream marker) so the set
|
||||||
|
/// can't drift. Does NOT include C0/C1 controls; callers combine this with `char::is_control`.
|
||||||
|
pub(crate) fn is_spoofy_char(c: char) -> bool {
|
||||||
|
('\u{202A}'..='\u{202E}').contains(&c) // LRE..RLO/PDF
|
||||||
|
|| ('\u{2066}'..='\u{2069}').contains(&c) // LRI..PDI
|
||||||
|
|| c == '\u{200E}' // LRM
|
||||||
|
|| c == '\u{200F}' // RLM
|
||||||
|
|| c == '\u{061C}' // ALM
|
||||||
|
|| c == '\u{FEFF}' // BOM / zero-width no-break space
|
||||||
|
}
|
||||||
|
|
||||||
/// Max stored device-name length (matches the `Hello` wire cap, `quic::HELLO_NAME_MAX`).
|
/// Max stored device-name length (matches the `Hello` wire cap, `quic::HELLO_NAME_MAX`).
|
||||||
const NAME_MAX: usize = 64;
|
const NAME_MAX: usize = 64;
|
||||||
|
|
||||||
|
|||||||
@@ -157,11 +157,12 @@ mod imp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Make a client name safe to place inside single quotes in a sourceable file: drop the single
|
/// Make a client name safe to place inside single quotes in a sourceable file: drop the single
|
||||||
/// quote itself and any control characters (newlines especially), and cap the length so a hostile
|
/// quote itself, any control characters (newlines especially), and any bidi/format control that
|
||||||
/// or absurd name can't bloat the file. Empty stays empty.
|
/// could spoof the displayed name (the shared [`crate::native_pairing::is_spoofy_char`] set), and
|
||||||
|
/// cap the length so a hostile or absurd name can't bloat the file. Empty stays empty.
|
||||||
fn sanitize(name: &str) -> String {
|
fn sanitize(name: &str) -> String {
|
||||||
name.chars()
|
name.chars()
|
||||||
.filter(|c| *c != '\'' && !c.is_control())
|
.filter(|c| *c != '\'' && !c.is_control() && !crate::native_pairing::is_spoofy_char(*c))
|
||||||
.take(96)
|
.take(96)
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ fn uninstall_gamepad() -> Result<()> {
|
|||||||
// Devnodes first (incl. phantoms — the same ghost-device complaint the vdisplay uninstall
|
// Devnodes first (incl. phantoms — the same ghost-device complaint the vdisplay uninstall
|
||||||
// fixed), then the store packages.
|
// fixed), then the store packages.
|
||||||
remove_pad_devnodes();
|
remove_pad_devnodes();
|
||||||
delete_store_drivers(&["pf_dualsense", "pf_dualshock4", "pf_xusb"]);
|
delete_store_drivers(&["pf_dualsense", "pf_dualshock4", "pf_xusb", "pf_mouse"]);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -979,7 +979,14 @@ fn set_fw_public_marker(allow_public: bool) {
|
|||||||
/// `Get-NetConnectionProfile` (per-interface category: Public / Private / DomainAuthenticated).
|
/// `Get-NetConnectionProfile` (per-interface category: Public / Private / DomainAuthenticated).
|
||||||
/// `None` when it can't be determined — the caller then skips the warning.
|
/// `None` when it can't be determined — the caller then skips the warning.
|
||||||
fn active_network_is_public() -> Option<bool> {
|
fn active_network_is_public() -> Option<bool> {
|
||||||
let out = std::process::Command::new("powershell")
|
// Resolve powershell by full System32 path — this runs in the SYSTEM service, which must not trust
|
||||||
|
// PATH / the CreateProcess search (it checks the launching EXE's OWN directory first, so a planted
|
||||||
|
// `powershell.exe` next to the host binary would run as SYSTEM). Matches the pf_vdisplay resolver
|
||||||
|
// and the "a privileged service must not trust PATH" rule (security-review 2026-07-17).
|
||||||
|
let ps = std::env::var("SystemRoot")
|
||||||
|
.map(|r| format!(r"{r}\System32\WindowsPowerShell\v1.0\powershell.exe"))
|
||||||
|
.unwrap_or_else(|_| "powershell.exe".to_string());
|
||||||
|
let out = std::process::Command::new(&ps)
|
||||||
.args([
|
.args([
|
||||||
"-NoProfile",
|
"-NoProfile",
|
||||||
"-NonInteractive",
|
"-NonInteractive",
|
||||||
|
|||||||
@@ -29,10 +29,13 @@ fn file_appender() -> Option<&'static std::sync::Mutex<std::fs::File>> {
|
|||||||
if !file_log_enabled() {
|
if !file_log_enabled() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
// WUDFHost's own (LocalService) temp dir — NOT world-writable/readable `C:\Users\Public`,
|
||||||
|
// where a non-admin could pre-create/hold the file or read the diagnostics
|
||||||
|
// (security-review 2026-07-17). Opt-in/debug only.
|
||||||
std::fs::OpenOptions::new()
|
std::fs::OpenOptions::new()
|
||||||
.create(true)
|
.create(true)
|
||||||
.append(true)
|
.append(true)
|
||||||
.open("C:\\Users\\Public\\pfvd-driver.log")
|
.open(std::env::temp_dir().join("pfvd-driver.log"))
|
||||||
.ok()
|
.ok()
|
||||||
.map(std::sync::Mutex::new)
|
.map(std::sync::Mutex::new)
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user