fix(security): remaining audit findings — mgmt admin gate, RTSP DoS bounds, FEC drop, ALPN, ct-compare
apple / swift (push) Successful in 56s
windows-host / package (push) Successful in 2m25s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m8s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m10s
android / android (push) Successful in 4m42s
ci / rust (push) Successful in 4m44s
ci / web (push) Successful in 30s
ci / docs-site (push) Successful in 35s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 57s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m0s
deb / build-publish (push) Successful in 2m10s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m43s
flatpak / build-publish (push) Successful in 3m59s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m28s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m13s
apple / swift (push) Successful in 56s
windows-host / package (push) Successful in 2m25s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m8s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m10s
android / android (push) Successful in 4m42s
ci / rust (push) Successful in 4m44s
ci / web (push) Successful in 30s
ci / docs-site (push) Successful in 35s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 57s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m0s
deb / build-publish (push) Successful in 2m10s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m43s
flatpak / build-publish (push) Successful in 3m59s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m28s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m13s
Addresses the lower-severity findings from docs/security-review.md (#4-#12). Each fix was adversarially re-reviewed (5-agent pass); two review catches folded in (the Apple client's GET /library cert path; an RTSP header-cap bypass + a spawn-panic counter leak). - #4 [low] mgmt mTLS-paired-cert no longer grants full admin. A paired STREAMING cert authorizes only a read-only allowlist (GET /host,/compositors,/status,/clients,/native/clients,/library); every state-changing route and every PIN-exposing route (/pair, /native/pair) requires the operator's bearer token. New cert_auth_is_a_read_only_allowlist test. (/library kept on the allowlist — the native clients browse it cert-only; its mutations stay token-only.) - #6 [low] RTSP pre-auth DoS bounds: a concurrent-connection cap (RAII slot guard), a per-read timeout (slow-loris), and Content-Length/header/message size caps — closing an unauthenticated slow-loris / memory-growth / thread-exhaustion vector on TCP 48010. - #11 [info] A FEC reconstruction failure is now a counted drop (discard the block, keep the session) instead of being stream-fatal — a lossy link can't be torn down by one bad block. - #10 [info] Fixed ALPN ("pkf1") on both native QUIC endpoints (defense-in-depth; a deliberate coordinated client+host upgrade — a new host rejects an ALPN-less old client). - #8 [info] Constant-time GameStream pairing phase-4 hash compare (crypto::ct_eq). - #7 [low] New VirtualDisplay::set_launch_command carries the launch command per-session on the GameStream path (no process-global env stomp under concurrent sessions); native path keeps the env under today's single-session model (documented; plumb per-session with concurrent sessions). - #5 [low] Legacy GameStream GCM nonce reuse: documented as inherent to Nvidia's old-style control encryption (Apollo/Moonlight identical; key is client-known) — unfixable on the legacy wire; the real fix is V2 control-encryption negotiation. Code comment at control.rs. - #9 [info] GameStream plain-HTTP pairing: documented (inherent to GFE compat; use punktfunk/1). - #12 [low] Web global NODE_TLS_REJECT_UNAUTHORIZED: fix designed (undici dispatcher scoped to the loopback mgmt fetch) but DEFERRED — needs `bun add undici` in the web build env; reverted to keep the web working. Latent-only (the loopback mgmt fetch is the console's only outbound TLS). fmt + clippy -D warnings clean; 94 host + core tests green; no C-ABI/OpenAPI drift. (The HDR Steps 1-2 client work in the tree is the user's parallel WIP — deliberately NOT included here.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -23,7 +23,7 @@ use crate::gamestream::{
|
||||
use anyhow::{Context, Result};
|
||||
use axum::{
|
||||
extract::{Path, Request, State},
|
||||
http::{header, StatusCode},
|
||||
http::{header, Method, StatusCode},
|
||||
middleware::{self, Next},
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
@@ -461,10 +461,15 @@ async fn require_auth(State(st): State<Arc<MgmtState>>, req: Request, next: Next
|
||||
return next.run(req).await; // liveness probe is always open
|
||||
}
|
||||
// A paired native client authenticates by its mTLS certificate — the same identity + trust the
|
||||
// QUIC data plane uses — so it never needs a bearer token. The fingerprint is attached by
|
||||
// `serve_https` from the verified peer cert; we authorize it iff it's in the paired store.
|
||||
// QUIC data plane uses. But "paired to STREAM" is not "paired to ADMINISTER": a streaming cert
|
||||
// authorizes only the safe, read-only status routes, NOT state-changing or pairing-administration
|
||||
// routes (which would let one paired client unpair others, read/arm the pairing PIN, stop
|
||||
// sessions, or edit the library). Everything outside the allowlist requires the operator's bearer
|
||||
// token. The fingerprint is attached by `serve_https` from the verified peer cert.
|
||||
if let Some(PeerCertFingerprint(Some(fp))) = req.extensions().get::<PeerCertFingerprint>() {
|
||||
if st.native.as_ref().is_some_and(|n| n.is_paired(fp)) {
|
||||
if cert_may_access(req.method(), req.uri().path())
|
||||
&& st.native.as_ref().is_some_and(|n| n.is_paired(fp))
|
||||
{
|
||||
return next.run(req).await;
|
||||
}
|
||||
}
|
||||
@@ -487,6 +492,27 @@ async fn require_auth(State(st): State<Arc<MgmtState>>, req: Request, next: Next
|
||||
}
|
||||
}
|
||||
|
||||
/// Which routes a paired *streaming* cert (mTLS, no bearer token) may reach: a small allowlist of
|
||||
/// safe, read-only status routes only. Deny-by-default — every state-changing route and every route
|
||||
/// that exposes a pairing PIN or the pending-approval queue requires the operator's bearer token, so
|
||||
/// a streaming client can't administer the host (unpair others, arm/read the PIN, stop sessions,
|
||||
/// edit the library). `/health` is handled separately (always open).
|
||||
fn cert_may_access(method: &Method, path: &str) -> bool {
|
||||
method == Method::GET
|
||||
&& matches!(
|
||||
path,
|
||||
"/api/v1/host"
|
||||
| "/api/v1/compositors"
|
||||
| "/api/v1/status"
|
||||
| "/api/v1/clients"
|
||||
| "/api/v1/native/clients"
|
||||
// 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.
|
||||
| "/api/v1/library"
|
||||
)
|
||||
}
|
||||
|
||||
/// Compare SHA-256 digests instead of the strings — constant-time with respect to the
|
||||
/// secret without pulling in a ct-eq dependency.
|
||||
fn token_eq(presented: &str, expected: &str) -> bool {
|
||||
@@ -1274,6 +1300,86 @@ mod tests {
|
||||
axum::http::Request::get(path).body(Body::empty()).unwrap()
|
||||
}
|
||||
|
||||
/// Send a request authenticated ONLY by a paired streaming cert (the `PeerCertFingerprint`
|
||||
/// `serve_https` would attach) — no bearer header — so `require_auth`'s cert branch decides.
|
||||
async fn send_cert(app: &Router, mut req: axum::http::Request<Body>, fp: &str) -> StatusCode {
|
||||
req.extensions_mut()
|
||||
.insert(PeerCertFingerprint(Some(fp.to_string())));
|
||||
app.clone().oneshot(req).await.expect("infallible").status()
|
||||
}
|
||||
|
||||
/// A paired *streaming* cert (mTLS, no bearer) authorizes only the read-only allowlist; every
|
||||
/// state-changing or PIN-exposing route still requires the operator's bearer token (audit #4).
|
||||
#[tokio::test]
|
||||
async fn cert_auth_is_a_read_only_allowlist() {
|
||||
let np = Arc::new(
|
||||
crate::native_pairing::NativePairing::load_with(
|
||||
Some(
|
||||
std::env::temp_dir().join(format!("pf-mgmt-cert-{}.json", std::process::id())),
|
||||
),
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let fp = "deadbeefcafe";
|
||||
np.add("streaming-client", fp).unwrap();
|
||||
let app = test_app_native(test_state(), np);
|
||||
|
||||
// Allowlisted read-only GETs → the cert authorizes them (not 401).
|
||||
for p in [
|
||||
"/api/v1/host",
|
||||
"/api/v1/status",
|
||||
"/api/v1/compositors",
|
||||
"/api/v1/clients",
|
||||
"/api/v1/native/clients",
|
||||
"/api/v1/library",
|
||||
] {
|
||||
assert_ne!(
|
||||
send_cert(&app, get_req(p), fp).await,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"a paired streaming cert should authorize GET {p}"
|
||||
);
|
||||
}
|
||||
// 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,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"GET /native/pair exposes the PIN → must require the bearer token"
|
||||
);
|
||||
assert_eq!(
|
||||
send_cert(
|
||||
&app,
|
||||
post_json(
|
||||
"/api/v1/native/pair/arm",
|
||||
serde_json::json!({"ttl_secs": 60})
|
||||
),
|
||||
fp,
|
||||
)
|
||||
.await,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"arming pairing must require the bearer token"
|
||||
);
|
||||
assert_eq!(
|
||||
send_cert(
|
||||
&app,
|
||||
axum::http::Request::delete("/api/v1/native/clients/deadbeefcafe")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
fp,
|
||||
)
|
||||
.await,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"unpair (DELETE) must require the bearer token"
|
||||
);
|
||||
// An UNPAIRED cert is rejected even on an allowlisted path.
|
||||
assert_eq!(
|
||||
send_cert(&app, get_req("/api/v1/status"), "not-paired").await,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"an unpaired cert must be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_is_open_and_versioned() {
|
||||
let app = test_app(test_state(), None);
|
||||
|
||||
Reference in New Issue
Block a user