Files
punktfunk/crates/punktfunk-host/src/mgmt/clients.rs
T
enricobuehler 600693914f 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>
2026-07-17 17:20:15 +02:00

180 lines
6.5 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Client/pairing-tagged management endpoints: paired Moonlight clients and the GameStream
//! pairing PIN flow. Split out of the `mgmt` facade (plan §W5).
use super::shared::*;
use sha2::{Digest, Sha256};
/// A paired (certificate-pinned) Moonlight client.
#[derive(Serialize, ToSchema)]
pub(crate) struct PairedClient {
/// Lowercase hex SHA-256 of the client certificate DER — the client's stable id here.
#[schema(example = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08")]
fingerprint: String,
/// Certificate subject (e.g. `CN=NVIDIA GameStream Client`), if the DER parses.
subject: Option<String>,
/// Certificate validity start (unix seconds).
not_before_unix: Option<i64>,
/// Certificate validity end (unix seconds).
not_after_unix: Option<i64>,
}
/// Pairing-flow status.
#[derive(Serialize, ToSchema)]
pub(crate) struct PairingStatus {
/// True while a pairing handshake is parked waiting for the user's PIN.
pin_pending: bool,
}
/// The PIN Moonlight displays during pairing.
#[derive(Deserialize, ToSchema)]
pub(crate) struct SubmitPin {
/// 116 ASCII digits (Moonlight shows 4).
#[schema(example = "1234")]
pin: String,
}
/// List paired clients
#[utoipa::path(
get,
path = "/clients",
tag = "clients",
operation_id = "listPairedClients",
responses(
(status = OK, description = "All certificate-pinned clients", body = [PairedClient]),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn list_paired_clients(
State(st): State<Arc<MgmtState>>,
) -> Json<Vec<PairedClient>> {
let ders = st
.app
.paired
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone();
Json(ders.iter().map(|der| client_info(der)).collect())
}
pub(crate) fn client_info(der: &[u8]) -> PairedClient {
let fingerprint = hex::encode(Sha256::digest(der));
match x509_parser::parse_x509_certificate(der) {
Ok((_, x509)) => PairedClient {
fingerprint,
subject: Some(x509.subject().to_string()),
not_before_unix: Some(x509.validity().not_before.timestamp()),
not_after_unix: Some(x509.validity().not_after.timestamp()),
},
Err(_) => PairedClient {
fingerprint,
subject: None,
not_before_unix: None,
not_after_unix: None,
},
}
}
/// Unpair a client
///
/// Removes the client's certificate from the pairing store. Caveat: the nvhttp TLS layer
/// does not yet reject unlisted certificates (`gamestream/tls.rs` accepts any well-formed
/// client cert — a planned hardening step), so until that lands this removes the client
/// from the listing without severing its ability to reconnect.
#[utoipa::path(
delete,
path = "/clients/{fingerprint}",
tag = "clients",
operation_id = "unpairClient",
params(
("fingerprint" = String, Path,
description = "Hex SHA-256 fingerprint of the client certificate DER (64 chars, case-insensitive)")
),
responses(
(status = NO_CONTENT, description = "Client unpaired"),
(status = BAD_REQUEST, description = "Malformed fingerprint", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = NOT_FOUND, description = "No paired client with that fingerprint", body = ApiError),
)
)]
pub(crate) async fn unpair_client(
State(st): State<Arc<MgmtState>>,
Path(fingerprint): Path<String>,
) -> Response {
if fingerprint.len() != 64 || !fingerprint.bytes().all(|b| b.is_ascii_hexdigit()) {
return api_error(
StatusCode::BAD_REQUEST,
"fingerprint must be the 64-char hex SHA-256 of the client certificate DER",
);
}
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 {
tracing::info!(fingerprint, "management API: client unpaired");
StatusCode::NO_CONTENT.into_response()
} else {
api_error(
StatusCode::NOT_FOUND,
"no paired client with that fingerprint",
)
}
}
/// Pairing-flow status
///
/// Poll this to know when to prompt the user for the PIN Moonlight displays.
#[utoipa::path(
get,
path = "/pair",
tag = "pairing",
operation_id = "getPairingStatus",
responses(
(status = OK, description = "Whether a pairing handshake is waiting for a PIN", body = PairingStatus),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn get_pairing_status(State(st): State<Arc<MgmtState>>) -> Json<PairingStatus> {
Json(PairingStatus {
pin_pending: st.app.pairing.pin.awaiting_pin(),
})
}
/// Submit the pairing PIN
///
/// Delivers the PIN the Moonlight client is displaying, completing the out-of-band half
/// of the pairing handshake.
#[utoipa::path(
post,
path = "/pair/pin",
tag = "pairing",
operation_id = "submitPairingPin",
request_body = SubmitPin,
responses(
(status = NO_CONTENT, description = "PIN delivered to the waiting handshake"),
(status = BAD_REQUEST, description = "Malformed PIN or unparseable JSON body", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = CONFLICT, description = "No pairing handshake is waiting for a PIN", body = ApiError),
(status = UNSUPPORTED_MEDIA_TYPE, description = "Body is not application/json", body = ApiError),
(status = UNPROCESSABLE_ENTITY, description = "JSON body does not match the schema", body = ApiError),
)
)]
pub(crate) async fn submit_pairing_pin(
State(st): State<Arc<MgmtState>>,
ApiJson(req): ApiJson<SubmitPin>,
) -> Response {
let pin = req.pin.trim();
if pin.is_empty() || pin.len() > 16 || !pin.bytes().all(|b| b.is_ascii_digit()) {
return api_error(StatusCode::BAD_REQUEST, "pin must be 1-16 ASCII digits");
}
if !st.app.pairing.pin.awaiting_pin() {
// Refusing (rather than parking the PIN) prevents a stale PIN from silently
// satisfying a *future* pairing attempt.
return api_error(
StatusCode::CONFLICT,
"no pairing handshake is waiting for a PIN",
);
}
st.app.pairing.pin.submit(pin.to_string());
StatusCode::NO_CONTENT.into_response()
}