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:20:15 +02:00
co-authored by Claude Fable 5
parent 4d89dcd3d7
commit 600693914f
21 changed files with 292 additions and 75 deletions
+52
View File
@@ -150,6 +150,19 @@ impl HooksConfig {
if !url.starts_with("https://") && !url.starts_with("http://") {
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 {
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>) {
// 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).