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