From f8a5ff603235fee12532d4f022dcc0cd30a5c10d Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 22 Oct 2026 22:21:09 -0700 Subject: [PATCH 1/5] chore(quest): claim quest/m1/auth-contract.md Quest-Claim: b5cdac5f54b62195 From f058d2047732bdba68ef96fd13ce27c06c87a1d2 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 22 Oct 2026 23:08:15 -0700 Subject: [PATCH 2/5] fix(relay): report the certificate-selecting SNI and keep certificates a fact The auth request's `server_name` is now the SNI that selected the relay's certificate on every transport: HTTPS reads and WebSocket upgrades take it from the TLS stream, and neither the `Host` header, `:authority`, nor a WebTransport CONNECT host stands in for a missing one, on either listener stack. Hidden-route discovery follows the grant's `peer` flag instead of any verified certificate, and the auth client bounds the reply it reads. Co-Authored-By: Claude Opus 5.5 --- doc/bin/relay/auth.md | 10 +- doc/bin/relay/cluster.md | 4 +- js/auth/src/contract.ts | 12 +- quest/m1/README.md | 1 - quest/m1/auth-contract.md | 29 --- rs/moq-auth/src/client.rs | 53 ++++- rs/moq-auth/src/grant.rs | 3 +- rs/moq-auth/src/request.rs | 5 +- rs/moq-relay/MIGRATE.md | 10 + rs/moq-relay/src/auth.rs | 7 +- rs/moq-relay/src/cluster.rs | 49 ++++- rs/moq-relay/src/session.rs | 2 +- rs/moq-relay/src/uring.rs | 9 +- rs/moq-relay/src/web.rs | 194 +++++++++++------- rs/moq-relay/src/websocket.rs | 4 +- .../tests/integration/auth_lifetime.rs | 6 +- .../tests/integration/runtime_uring.rs | 18 +- 17 files changed, 275 insertions(+), 141 deletions(-) delete mode 100644 quest/m1/auth-contract.md diff --git a/doc/bin/relay/auth.md b/doc/bin/relay/auth.md index b68c85d730e..4aff97a4b59 100644 --- a/doc/bin/relay/auth.md +++ b/doc/bin/relay/auth.md @@ -33,6 +33,12 @@ mark another relay and an [upstream link](/bin/relay/cluster#upstream-links). The fields are documented on `moq_auth::Request` and `moq_auth::Grant` ([Rust](/lib/rs/moq-auth), [TypeScript](/lib/js/auth)). +`server_name` is the SNI that selected the relay's certificate. It is absent +over plaintext and for an IP-literal dial, and never filled from a `Host` +header or a WebTransport authority. The client still picks it among the names +the relay serves, so a hostname-scoped server binds the tenant to the +credential, not to `server_name` alone. + **Connect.** A 2xx with a grant admits. A 403 can carry JSON such as `{"reason":"expired","root":"rooms/123","tier":"edge"}`. The reason is `refused`, `forbidden`, or `expired`; unknown reason strings read as `refused`. @@ -275,7 +281,9 @@ Cluster peers are admitted the same way, so a mesh runs `moq auth serve --mtls-publish '**' --mtls-subscribe '**' --mtls-peer` (or a server granting its cluster CA everything with `peer: true`); see [Clustering](/bin/relay/cluster). `--mtls-peer` marks every certificate as -another relay; leave it off when certificates identify clients. +another relay; leave it off when certificates identify clients. Without +`peer`, a certificate is a client's: it routes like one and sees +[hidden](/concept/moq-lite#hidden-broadcasts) routes only by asking. `--mtls-upstream`, which needs `--mtls-peer`, also marks them [upstream](/bin/relay/cluster#upstream-links). Changing either flag ends each live mTLS session at its next re-check (the `--revalidate` cadence or a diff --git a/doc/bin/relay/cluster.md b/doc/bin/relay/cluster.md index 6b922e590d2..cab3f6f79c6 100644 --- a/doc/bin/relay/cluster.md +++ b/doc/bin/relay/cluster.md @@ -253,7 +253,9 @@ needs an auth server that grants the cluster CA, such as `moq auth serve --mtls-publish '**' --mtls-subscribe '**' --mtls-peer`. An accepted peer counts as a cluster peer only when its grant sets `peer: true`, as `--mtls-peer` does; otherwise what it announces counts as ingest here, like -a client's. Dials retry forever with capped backoff, so a rejected peer is loud +a client's, and it lists [hidden](/concept/moq-lite#hidden-broadcasts) routes +only by asking, which a relay below moq-lite-07-wip, or on moq-transport +without the hidden extension, cannot. Dials retry forever with capped backoff, so a rejected peer is loud in the logs rather than fatal. See [Authentication](/bin/relay/auth#mtls). The `/nodes` [internal endpoint](/bin/relay/http#get-nodes) lists the peers diff --git a/js/auth/src/contract.ts b/js/auth/src/contract.ts index 62f9a03651b..59a3fb829b2 100644 --- a/js/auth/src/contract.ts +++ b/js/auth/src/contract.ts @@ -105,7 +105,11 @@ const BaseRequestSchema = z.object({ remote: z.optional(z.string()), /** The relay's socket address the session arrived on, absent likewise. */ local: z.optional(z.string()), - /** The SNI the client presented, when the transport carried TLS. */ + /** + * The SNI that selected the relay's certificate, absent without TLS or SNI. + * The client picks it among the names the relay serves, so a hostname-scoped + * server binds the tenant to the credential, not to this alone. + */ server_name: z.optional(z.string()), /** The negotiated application protocol, including the moq version. */ alpn: z.optional(z.string()), @@ -176,7 +180,11 @@ export const GrantSchema = z tier: z.optional(z.string()), /** The literal path this session's stats record under, independent of its root. Absent records under the empty label. */ label: z.optional(PathSchema), - /** The session is a cluster peer (another relay): what it announces entered the cluster elsewhere. */ + /** + * The session is a cluster peer (another relay): what it announces entered the cluster elsewhere, + * and it discovers hidden routes without asking, for peers that predate the opt-in. + * A client certificate alone is not. + */ peer: z.optional(z.boolean()), /** The peer is upstream: the relay never offers it a route learned from another upstream. Requires `peer`. */ upstream: z.optional(z.boolean()), diff --git a/quest/m1/README.md b/quest/m1/README.md index c0bb4fbd6cf..28ce90ef616 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -165,7 +165,6 @@ tracks, so per-group stats stop costing cluster announces. - [Compile time audit](/quest/m1/audit-compile-time.md) - build-time hotspots are measured against the m0 baselines and the worst are fixed or planned - [Peer count bounds](/quest/m1/peer-count-bounds.md) - namespace interests, a legacy announce list, a WebSocket upgrade, and public HTTP admission are bounded by what the peer sent or by an existing budget - [Caption store bounds](/quest/m1/caption-store-bounds.md) - a hostile caption track cannot grow a viewer's cue and region store or its per-frame work without bound -- [Auth contract](/quest/m1/auth-contract.md) - the auth server receives the certificate-selecting SNI, documented as client-chosen, and a certificate grants nothing alone - [Nightly benchmark regressions](/quest/m1/bench-ci.md) - each nightly compares the Rust Criterion benches against the previous nightly's commit on the same hosted runner, publishes it to the git.moq.dev report, and alerts on a regression - [Interop audit](/quest/m1/audit-interop.md) - our interop runner entries match what we ship, and every red interop cell with a moq.dev side is fixed, reported, or noted as down diff --git a/quest/m1/auth-contract.md b/quest/m1/auth-contract.md deleted file mode 100644 index 42f5b89734c..00000000000 --- a/quest/m1/auth-contract.md +++ /dev/null @@ -1,29 +0,0 @@ -# [M] The auth request reports what the connection negotiated - -## Goal - -The auth request reports what the connection actually negotiated, and says which of it the client chose: - -- `moq_auth::Request::server_name` is the SNI that selected the relay's certificate on every transport, as its contract says, not the client's `Host` or `:authority`. The SNI is still client-chosen, among the names the relay serves, and the doc says so: a hostname-scoped auth server binds tenancy to the token, not to `server_name` alone. -- A verified client certificate stays a fact unless the auth server's grant says `peer`; it never reveals hidden routes on its own. - -Today `admit_http` fills `server_name` from `request_host(uri, headers)`, and the WebSocket bridge builds its URL from the same header. A client can therefore report a host the connection never negotiated TLS for. `cluster_peer` also treats any certificate as a peer for hidden-route discovery. - -## Plan - -Decided 2026-10-22 from the security audit, with the maintainer: - -- Add `server_name()` to the `HttpStream` trait and return `TlsStream::get_ref().1.server_name()`; use it in `admit_http` and in the WebSocket session's link. On the plain-HTTP listener leave `server_name` absent rather than filling it from `Host`, since the auth request has no field that would tell an auth server the value is client-chosen; say so in the doc. -- Remove the same fallback from WebTransport on both listener stacks: `request_for` substitutes the request authority (`rs/moq-relay/src/auth.rs`) and the io_uring path the URL host (`rs/moq-relay/src/uring.rs`) when the client sent no SNI. -- Derive hidden-route visibility from the grant's `peer` flag, not `request.tls.is_some()`. Until peers set it, document the exemption where operators read it. -- Bound the `moq_auth::Client` reply body. -- Update `doc/bin/relay/auth.md` so `server_name` (the certificate-selecting SNI, client-chosen), `peer`, and the certificate-is-a-fact rule match what the code does. - -Tests: an HTTP/1.1 request with a forged `Host` still reports the SNI; the same over WebSocket; a WebTransport session without SNI reports none on both stacks; a certificate-only session sees no hidden routes. - -Public API: one method on an internal trait. Wire: none. - -## Related - -- [In-band auth](/quest/m1/auth/README.md) - the request and grant contract this corrects -- [Auth credential redirects](/quest/m1/release-audit/auth-redirects.md) - owns the auth client's redirect policy diff --git a/rs/moq-auth/src/client.rs b/rs/moq-auth/src/client.rs index e0fdb9ffc9c..e7677c0b5f4 100644 --- a/rs/moq-auth/src/client.rs +++ b/rs/moq-auth/src/client.rs @@ -12,6 +12,11 @@ use crate::{Bytes, Error, Event, Grant, Request}; /// Every request is bounded so a hung server refuses rather than parks the session. const TIMEOUT: Duration = Duration::from_secs(10); +/// The largest reply read from the auth server. Each one is held whole before it is +/// parsed, once per admission in flight, so the size bounds that memory; a grant +/// with thousands of patterns still fits. +const MAX_REPLY: usize = 1 << 20; + /// The HTTP side of the contract: one JSON POST per event to an auth server. /// /// `connect` admits a session and hands back the [`lease::Consumer`] it holds; a task @@ -271,12 +276,14 @@ impl Post for Client { let response = self.http.post(self.url.clone()).json(request).send().await?; let status = response.status(); if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { - return Err(Error::Refused(response.json().await.unwrap_or_default())); + let refusal = reply(response).await.ok().and_then(|body| serde_json::from_slice(&body).ok()); + return Err(Error::Refused(refusal.unwrap_or_default())); } if !status.is_success() { return Err(Error::Unavailable(format!("auth server answered {status}"))); } - Ok(response.json().await?) + serde_json::from_slice(&reply(response).await?) + .map_err(|err| Error::Unavailable(format!("auth server answered an invalid grant: {err}"))) } async fn post_end(&self, request: &Request) -> crate::Result<()> { @@ -290,6 +297,18 @@ impl Post for Client { } } +/// Read a reply body of at most [`MAX_REPLY`] bytes, refusing a longer one as it arrives. +async fn reply(mut response: reqwest::Response) -> crate::Result> { + let mut body = Vec::new(); + while let Some(chunk) = response.chunk().await? { + if body.len() + chunk.len() > MAX_REPLY { + return Err(Error::Unavailable(format!("auth server reply exceeds {MAX_REPLY} bytes"))); + } + body.extend_from_slice(&chunk); + } + Ok(body) +} + /// Admit a session through `server` and spawn the driver behind its lease. /// /// The connection's own `connect` runs under the caller's deadline; an attached @@ -996,6 +1015,36 @@ mod tests { assert!(matches!(err, Error::Refused(_)), "{err}"); } + /// A reply of exactly `MAX_REPLY` bytes admits; one byte more is an outage, or a + /// plain refusal on a 403, never read whole. + #[tokio::test] + async fn replies_are_bounded() { + let padded = |status: u16, body: &str, len: usize| { + // JSON allows trailing whitespace, so the padded reply still parses. + let body = format!("{body}{}", " ".repeat(len - body.len())); + ResponseTemplate::new(status).set_body_raw(body, "application/json") + }; + let grant = serde_json::to_string(&grant(None, None)).unwrap(); + let refusal = r#"{"reason":"expired","root":"tenant"}"#; + + let fits = server(Log::default(), { + let grant = grant.clone(); + move |_| padded(200, &grant, MAX_REPLY) + }) + .await; + client(&fits).connect(request()).await.expect("a reply at the bound admits"); + + let over = server(Log::default(), move |_| padded(200, &grant, MAX_REPLY + 1)).await; + let err = client(&over).connect(request()).await.unwrap_err(); + assert!(matches!(&err, Error::Unavailable(message) if message.contains("exceeds")), "{err}"); + + let refused = server(Log::default(), move |_| padded(403, refusal, MAX_REPLY + 1)).await; + let Error::Refused(actual) = client(&refused).connect(request()).await.unwrap_err() else { + panic!("expected refusal") + }; + assert_eq!(actual, crate::Refusal::default()); + } + #[tokio::test] async fn typed_refusal_preserves_reason_root_and_tier() { use crate::refusal::Reason; diff --git a/rs/moq-auth/src/grant.rs b/rs/moq-auth/src/grant.rs index 033c370da1c..0b9b2399476 100644 --- a/rs/moq-auth/src/grant.rs +++ b/rs/moq-auth/src/grant.rs @@ -49,7 +49,8 @@ pub struct Grant { pub label: Option, /// The session is a cluster peer (another relay): what it announces entered - /// the cluster elsewhere, not here. + /// the cluster elsewhere, not here, and it discovers hidden routes without + /// asking, for peers that predate the opt-in. A client certificate alone is not. #[serde(skip_serializing_if = "std::ops::Not::not")] pub peer: bool, diff --git a/rs/moq-auth/src/request.rs b/rs/moq-auth/src/request.rs index 77f06509ab3..c9da30f8e11 100644 --- a/rs/moq-auth/src/request.rs +++ b/rs/moq-auth/src/request.rs @@ -36,7 +36,10 @@ pub struct Request { /// The relay's socket address the session arrived on, absent likewise. pub local: Option, - /// The SNI the client presented, when the transport carried TLS. + /// The SNI that selected the relay's certificate, absent without TLS or SNI. + /// + /// The client picks it among the names the relay serves, so a hostname-scoped + /// server binds the tenant to the credential, not to this alone. pub server_name: Option, /// The negotiated application protocol, including the moq version. diff --git a/rs/moq-relay/MIGRATE.md b/rs/moq-relay/MIGRATE.md index 866a7e1d2d0..308b198b41b 100644 --- a/rs/moq-relay/MIGRATE.md +++ b/rs/moq-relay/MIGRATE.md @@ -5,6 +5,16 @@ Rolling a cluster across a release also follows the [upgrade guide](https://moq. ## Unreleased +### Auth `server_name` is the SNI only (#6509) + +The auth request's `server_name` no longer falls back to the HTTP `Host` header, `:authority`, or the WebTransport CONNECT host: it is absent over plain HTTP and WebSocket and for an IP-literal dial. +An auth server that scopes tenants by hostname must see them over TLS, and still binds the tenant to the credential. + +### A client certificate alone lists no hidden routes (#6509) + +Only a grant with `peer: true` lists `.`-named routes to a session that did not ask for them. +Have the auth server mark cluster peers as `peer` (`moq auth serve --mtls-peer`) before upgrading, or a peer below moq-lite-07-wip loses those paths. + ### `receive_window` has a floor (#5878) The `[quic]` and `[peer.quic]` `receive_window` must be at least 67108880 bytes (64 MiB plus 16), or the relay refuses to start; the `[quic]` default is now 128 MiB. diff --git a/rs/moq-relay/src/auth.rs b/rs/moq-relay/src/auth.rs index 09fda630dfe..b992736dbb7 100644 --- a/rs/moq-relay/src/auth.rs +++ b/rs/moq-relay/src/auth.rs @@ -1064,10 +1064,9 @@ pub fn request_for(auth: &Auth, request: &moq_tokio::server::Request) -> Request }); out.remote = request.remote_addr(); out.local = request.local_addr(); - out.server_name = request - .server_name() - .map(str::to_owned) - .or_else(|| request.authority().map(str::to_owned)); + // Only the SNI: a WebTransport CONNECT authority or WebSocket `Host` is client-chosen + // without having selected a certificate, so a session without SNI reports none. + out.server_name = request.server_name().map(str::to_owned); out.alpn = request.alpn().map(str::to_owned); out.role = request.role().and_then(role); out.tls = request.peer_identity().as_ref().and_then(peer); diff --git a/rs/moq-relay/src/cluster.rs b/rs/moq-relay/src/cluster.rs index 21486367b99..19853000b14 100644 --- a/rs/moq-relay/src/cluster.rs +++ b/rs/moq-relay/src/cluster.rs @@ -1306,7 +1306,7 @@ impl Cluster { admitted.subscriber = self.subscriber(&bound).map(|origin| { origin .consume() - .with_hidden(request.tls.is_some() || Self::is_lan_path(&request.path)) + .with_hidden(bound.peer) .with_stats(admitted.stats.clone()) }); } @@ -1345,15 +1345,15 @@ impl Cluster { Some(moq_auth::Role::Subscriber) => None, _ => publisher.map(|origin| origin.with_stats(stats.clone())), }; - // An authenticated cluster peer (a verified client certificate or the LAN - // credential) discovers hidden routes whether or not it asks. A peer that - // predates the hidden opt-in (below moq-lite-07-wip, or moq-transport without - // MoQ Hidden) would otherwise lose every dot path during a rolling upgrade. + // A cluster peer (a `peer` grant, which the LAN credential also gets) discovers + // hidden routes whether or not it asks. A peer that predates the hidden opt-in + // (below moq-lite-07-wip, or moq-transport without MoQ Hidden) would otherwise + // lose every dot path during a rolling upgrade. A client certificate alone is a + // fact for the auth server, never this exemption. // TODO: drop the exemption once deployed peers all opt in. - let cluster_peer = request.tls.is_some() || Self::is_lan_path(&request.path); let subscriber = match request.role { Some(moq_auth::Role::Publisher) => None, - _ => subscriber.map(|origin| origin.consume().with_hidden(cluster_peer).with_stats(stats.clone())), + _ => subscriber.map(|origin| origin.consume().with_hidden(token.peer).with_stats(stats.clone())), }; Ok(Admitted { lease: lease.with_stats(stats.clone()), @@ -2344,6 +2344,41 @@ mod tests { } } + /// A verified certificate is a fact for the auth server: only a `peer` grant + /// discovers hidden routes without asking, on both scoping paths. + #[tokio::test] + async fn only_a_peer_grant_discovers_hidden_routes() { + let cluster = new_cluster(Config::default()).expect("cluster"); + let _hidden = cluster.origin.publish(".stats/node", origin::Route::default()); + let _visible = cluster.origin.publish("room", origin::Route::default()); + let mut request = moq_auth::Request::new("test", moq_auth::Transport::Quic, "/"); + request.tls = Some(moq_auth::Peer { + name: "edge0".into(), + fingerprint: "ab".repeat(32), + expires: None, + issuer: "CN=cluster".into(), + }); + for peer in [false, true] { + for session in [false, true] { + let mut grant = moq_auth::Grant::new(moq_auth::Pattern::all(), moq_auth::Pattern::all()); + grant.peer = peer; + let lease = auth::Lease::new("/", moq_auth::lease::Consumer::fixed(grant)); + let admitted = match session { + true => cluster.scope_session(lease, &request), + false => cluster.scope(lease, &request), + } + .expect("admitted"); + let mut announced = admitted.subscriber.expect("subscribe grant").announced(); + let mut seen = Vec::new(); + while let Some(route) = try_next_announced(&mut announced) { + seen.push(route.prefix.to_string()); + } + let expected: &[&str] = if peer { &[".stats/node", "room"] } else { &["room"] }; + assert_eq!(seen, expected, "peer={peer}, session={session}"); + } + } + } + /// A millisecond track: the frames written here carry timestamps. fn timed() -> moq_net::track::Info { moq_net::track::Info::default().with_timescale(moq_net::Timescale::MILLI) diff --git a/rs/moq-relay/src/session.rs b/rs/moq-relay/src/session.rs index b32a2fcc6e8..c466f3af864 100644 --- a/rs/moq-relay/src/session.rs +++ b/rs/moq-relay/src/session.rs @@ -428,7 +428,7 @@ pub struct View { pub remote: Option, /// The relay's socket address the session arrived on. pub local: Option, - /// The SNI the client presented. + /// The SNI that selected the relay's certificate. pub server_name: Option, /// The negotiated application protocol. pub alpn: Option, diff --git a/rs/moq-relay/src/uring.rs b/rs/moq-relay/src/uring.rs index 371bea3f427..280bf213c7c 100644 --- a/rs/moq-relay/src/uring.rs +++ b/rs/moq-relay/src/uring.rs @@ -815,13 +815,8 @@ async fn serve_request( } auth_request.remote = Some(remote); auth_request.local = Some(serve.local); - // Like the tokio listener: the SNI, else the host a WebTransport client addressed. - auth_request.server_name = sni.or_else(|| { - url.as_ref() - .and_then(|url| url.host_str()) - .filter(|host| !host.is_empty()) - .map(str::to_owned) - }); + // Only the SNI, like the tokio listener: the CONNECT host selected no certificate. + auth_request.server_name = sni; auth_request.alpn = alpn.clone(); auth_request.tls = identity.as_ref().and_then(crate::auth::peer); let admission = lifecycle.clone(); diff --git a/rs/moq-relay/src/web.rs b/rs/moq-relay/src/web.rs index de58da2f33e..6cbad838132 100644 --- a/rs/moq-relay/src/web.rs +++ b/rs/moq-relay/src/web.rs @@ -635,6 +635,13 @@ pub(crate) struct SocketStats(std::convert::Infallible); #[derive(Clone, Debug)] pub struct MtlsPeer(pub moq_tokio::tls::PeerIdentity); +/// The SNI that selected the HTTPS certificate, inserted as a request extension. +/// +/// Plain HTTP has no handshake to name a host, so it never carries one; the `Host` +/// header and `:authority` are client-chosen and never stand in for it. +#[derive(Clone, Debug)] +pub(crate) struct ServerName(pub(crate) String); + /// Accepts a connection on a public web listener. /// /// Captures the socket, then hands the stream to `inner`: [`DefaultAcceptor`] for @@ -683,6 +690,10 @@ impl WebAcceptor> { trait HttpStream { /// The marker for this connection's verified peer certificate. fn mtls_peer(&self) -> Option; + /// The SNI that selected the certificate, when the connection carried TLS. + fn server_name(&self) -> Option<&str> { + None + } fn admission(&self) -> Option { None } @@ -720,6 +731,9 @@ impl HttpStream for TlsStream { fn acme_challenge(&self) -> bool { self.get_ref().1.alpn_protocol() == Some(rustls_acme::acme::ACME_TLS_ALPN_NAME) } + fn server_name(&self) -> Option<&str> { + self.get_ref().1.server_name() + } fn mtls_peer(&self) -> Option { self.get_ref() .1 @@ -770,6 +784,7 @@ where return Err(std::io::Error::other("answered an ACME TLS-ALPN-01 challenge")); } let peer = stream.mtls_peer(); + let server_name = stream.server_name().map(|name| ServerName(name.to_owned())); let admission = stream.admission(); if stream.is_http2() { // Extended CONNECT is not enabled, so h2 cannot become a WebSocket. @@ -780,6 +795,7 @@ where SetConnectionExtensions { inner: service, peer, + server_name, socket, admission, }, @@ -805,12 +821,13 @@ fn socket_stats(_stream: &I) -> Option { /// Attaches connection facts to each request, handing the first upgrade its admission. /// -/// The mTLS marker and captured socket are replayed on every request. Admission -/// is shared between service clones and consumed only once. +/// The mTLS marker, SNI, and captured socket are replayed on every request. +/// Admission is shared between service clones and consumed only once. #[derive(Clone)] struct SetConnectionExtensions { inner: S, peer: Option, + server_name: Option, socket: Option, admission: Option, } @@ -847,6 +864,9 @@ where if let Some(peer) = self.peer.clone() { req.extensions_mut().insert(peer); } + if let Some(server_name) = self.server_name.clone() { + req.extensions_mut().insert(server_name); + } if let Some(socket) = self.socket.clone() { req.extensions_mut().insert(socket); } @@ -937,30 +957,8 @@ impl<'de> serde::Deserialize<'de> for FetchGroup { } } -/// The host this request was addressed to, reported to the auth server as the -/// session's `server_name` so it can route by hostname. These handlers build their -/// request from a path rather than a URL, so it has to come off the request headers. -fn request_host(uri: &http::Uri, headers: &http::HeaderMap) -> Option { - // HTTP/2 carries the host in `:authority`, which hyper surfaces on the URI and - // usually WITHOUT a `Host` header. The HTTPS listener advertises h2, so reading - // the header alone loses the tenant for ordinary clients. - uri.authority() - .map(|authority| authority.host().to_string()) - .or_else(|| { - headers - .get(http::header::HOST) - .and_then(|host| host.to_str().ok()) - // Parsed rather than split on the last colon, which would truncate a - // bracketed IPv6 literal (`[2001:db8::1]`) at its own separator. - .and_then(|host| host.parse::().ok()) - .map(|authority| authority.host().to_string()) - }) - .map(|host| host.to_ascii_lowercase()) - .filter(|host| !host.is_empty()) -} - /// Admit a one-shot HTTP read: the auth server sees the path, the raw query, the -/// host it was addressed to, the peer, and any certificate. Only a grant that may +/// SNI on HTTPS, the peer, and any certificate. Only a grant that may /// subscribe counts as a request; the returned origin meters reads under its token. // The `Err` is axum's own `ErrorResponse`, so there is nothing here to box. #[expect(clippy::result_large_err, reason = "the error type is axum's, not ours")] @@ -969,7 +967,7 @@ async fn admit_http( path: String, query: AuthQuery, uri: &http::Uri, - headers: &http::HeaderMap, + server_name: Option>, remote: crate::listener::Peer, mtls: Option>, ) -> axum::response::Result<(auth::Lease, moq_net::origin::Consumer)> { @@ -982,7 +980,7 @@ async fn admit_http( .query() .map(str::to_owned) .or_else(|| query.jwt.map(|jwt| format!("jwt={jwt}"))); - request.server_name = request_host(uri, headers); + request.server_name = server_name.map(|Extension(ServerName(name))| name); request.remote = Some(remote.0); request.tls = mtls.and_then(|Extension(MtlsPeer(identity))| auth::peer(&identity)); // The listener's timeout bounds admission as it bounds the request headers. An @@ -1012,9 +1010,9 @@ async fn serve_announced( path: Option>, Query(query): Query, mtls: Option>, + server_name: Option>, ConnectInfo(remote): ConnectInfo, uri: http::Uri, - headers: http::HeaderMap, State(state): State>, ) -> axum::response::Result { let prefix = match path { @@ -1022,7 +1020,7 @@ async fn serve_announced( None => String::new(), }; - let (lease, origin) = admit_http(&state, prefix, query, &uri, &headers, remote, mtls).await?; + let (lease, origin) = admit_http(&state, prefix, query, &uri, server_name, remote, mtls).await?; let mut announced = origin.announced(); let mut broadcasts = Vec::new(); @@ -1050,9 +1048,9 @@ async fn serve_fetch( Path(path): Path, Query(params): Query, mtls: Option>, + server_name: Option>, ConnectInfo(remote): ConnectInfo, uri: http::Uri, - headers: http::HeaderMap, State(state): State>, ) -> axum::response::Result { // The path containts a broadcast/track @@ -1064,7 +1062,7 @@ async fn serve_fetch( return Err(StatusCode::BAD_REQUEST.into()); } - let (lease, origin) = admit_http(&state, path.join("/"), params.auth, &uri, &headers, remote, mtls).await?; + let (lease, origin) = admit_http(&state, path.join("/"), params.auth, &uri, server_name, remote, mtls).await?; // The token's root is the canonical (alias-resolved) broadcast path. let broadcast = lease.token().root.to_string(); @@ -1215,9 +1213,9 @@ mod tests { group: FetchGroup::Latest, }), None, + None, remote, "/fetch/room/video".parse().unwrap(), - http::HeaderMap::new(), State(state.clone()), ) .await @@ -1229,9 +1227,9 @@ mod tests { Some(Path("room".to_owned())), Query(AuthQuery { jwt: None }), None, + None, remote, "/announced/room".parse().unwrap(), - http::HeaderMap::new(), State(state.clone()), ) .await @@ -1281,9 +1279,9 @@ mod tests { group: FetchGroup::Latest, }), None, + None, ConnectInfo(crate::listener::Peer("127.0.0.1:1234".parse().unwrap())), "/fetch/room/video".parse().unwrap(), - http::HeaderMap::new(), State(state.clone()), ) .await; @@ -1292,9 +1290,9 @@ mod tests { Some(Path("room".to_owned())), Query(AuthQuery { jwt: None }), None, + None, ConnectInfo(crate::listener::Peer("127.0.0.1:1234".parse().unwrap())), "/announced/room".parse().unwrap(), - http::HeaderMap::new(), State(state.clone()), ) .await; @@ -1363,9 +1361,9 @@ mod tests { group: selector, }), None, + None, ConnectInfo(crate::listener::Peer("127.0.0.1:1234".parse().unwrap())), format!("/fetch/room/video?tier={tier}").parse().unwrap(), - http::HeaderMap::new(), State(state.clone()), ) .await @@ -1379,9 +1377,9 @@ mod tests { Some(Path("room".into())), Query(AuthQuery { jwt: None }), None, + None, ConnectInfo(crate::listener::Peer("127.0.0.1:1234".parse().unwrap())), format!("/announced/room?tier={tier}").parse().unwrap(), - http::HeaderMap::new(), State(state.clone()), ) .await @@ -1460,9 +1458,9 @@ mod tests { group: FetchGroup::Latest, }), None, + None, peer(), "/fetch/room/video".parse().unwrap(), - http::HeaderMap::new(), State(state.clone()), ) .await; @@ -1471,9 +1469,9 @@ mod tests { Some(Path("room".into())), Query(AuthQuery { jwt: None }), None, + None, peer(), "/announced/room".parse().unwrap(), - http::HeaderMap::new(), State(state.clone()), ) .await; @@ -1540,42 +1538,6 @@ mod tests { ); } - /// HTTP/2 puts the host in `:authority` and usually sends no `Host` header, and - /// the HTTPS listener advertises h2 - so a header-only read loses the tenant. - #[test] - fn request_host_prefers_the_uri_authority() { - let uri: http::Uri = "https://customer.example.com/announced".parse().unwrap(); - assert_eq!( - request_host(&uri, &http::HeaderMap::new()), - Some("customer.example.com".to_string()) - ); - } - - /// HTTP/1.1 leaves no authority on the URI, so the header is the only source. A - /// port is stripped either way, so both paths agree on the host. - #[test] - fn request_host_falls_back_to_the_header() { - let uri: http::Uri = "/announced".parse().unwrap(); - let mut headers = http::HeaderMap::new(); - headers.insert(http::header::HOST, "Customer.Example.com:4443".parse().unwrap()); - assert_eq!(request_host(&uri, &headers), Some("customer.example.com".to_string())); - assert_eq!(request_host(&uri, &http::HeaderMap::new()), None); - } - - /// A bracketed IPv6 literal carries its own colons, so splitting on the last - /// one truncates the address instead of stripping a port. - #[test] - fn request_host_keeps_ipv6_literals_intact() { - let uri: http::Uri = "/announced".parse().unwrap(); - let host = |value: &str| { - let mut headers = http::HeaderMap::new(); - headers.insert(http::header::HOST, value.parse().unwrap()); - request_host(&uri, &headers) - }; - assert_eq!(host("[2001:db8::1]"), Some("[2001:db8::1]".to_string())); - assert_eq!(host("[2001:db8::1]:4443"), Some("[2001:db8::1]".to_string())); - } - #[test] fn https_watch_paths_include_roots() { let cert = PathBuf::from("cert.pem"); @@ -1857,6 +1819,84 @@ mod tests { serving.abort(); } + /// `server_name` is the SNI that selected the certificate, on an HTTP read and a + /// WebSocket alike, never the `Host` the client wrote; plain HTTP has no SNI, so it + /// reports none rather than the header. Real sockets, so this runs on the wall clock. + #[cfg(all(unix, feature = "websocket"))] + #[tokio::test] + async fn server_name_is_the_sni_never_the_host() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + trait Stream: AsyncRead + AsyncWrite + Unpin + Send + 'static {} + impl Stream for T {} + + let dir = TempDir::new().unwrap(); + let (ca, cert, key) = make_certs(&dir); + let mut config = Config::default(); + config.http.listen = Some("127.0.0.1:0".parse().unwrap()); + config.https.listen = Some("127.0.0.1:0".parse().unwrap()); + config.https.cert = vec![cert.clone()]; + config.https.key = vec![key]; + config.ws = true; + let (auth, mut admissions) = crate::auth::Auth::embedded("test"); + let cluster = cluster::Cluster::new(crate::cluster::Options::default()).unwrap(); + let certificates = moq_tokio::tls::Certificates::from_pem(&std::fs::read(&cert).unwrap()).unwrap(); + let web = Web::new(auth, cluster, certificates, config).bind().unwrap(); + let Addrs { + http: Some(http), + https: Some(https), + } = web.addrs() + else { + panic!("both listeners are configured"); + }; + let routes = web.routes(); + let serving = tokio::spawn(web.serve(routes)); + + let connect = async |tls: bool| -> Box { + let tcp = tokio::net::TcpStream::connect(if tls { https } else { http }) + .await + .unwrap(); + if !tls { + return Box::new(tcp) as Box; + } + let name = rustls::pki_types::ServerName::try_from("localhost").unwrap(); + Box::new(tls_connector(&ca).connect(name, tcp).await.expect("TLS handshake")) + }; + + for tls in [true, false] { + let expected = tls.then_some("localhost"); + + let mut stream = connect(tls).await; + stream + .write_all(b"GET /announced HTTP/1.1\r\nHost: tenant.example\r\nConnection: close\r\n\r\n") + .await + .unwrap(); + let admission = admissions.next().await.expect("an HTTP admission"); + assert_eq!(admission.request.transport, moq_auth::Transport::Http); + assert_eq!(admission.request.server_name.as_deref(), expected, "HTTP, tls={tls}"); + // Unanswered, the read fails as unavailable; only the request mattered. + drop(admission); + let _ = stream.read_to_end(&mut Vec::new()).await; + + let mut stream = connect(tls).await; + stream + .write_all(b"GET /room HTTP/1.1\r\nHost: tenant.example\r\n") + .await + .unwrap(); + let client = tokio::spawn(drain::upgrade_while_draining(stream, true)); + let admission = admissions.next().await.expect("a WebSocket admission"); + assert_eq!(admission.request.transport, moq_auth::Transport::WebSocket); + assert_eq!(admission.request.server_name.as_deref(), expected, "WebSocket, tls={tls}"); + admission.grant(moq_auth::lease::Consumer::fixed(moq_auth::Grant::new( + moq_auth::Pattern::all(), + moq_auth::Pattern::all(), + ))); + drop(client.await.expect("the WebSocket session connects")); + } + + serving.abort(); + } + /// HTTPS settings without the HTTPS listener that would read them are refused, /// not ignored. #[tokio::test] @@ -2014,12 +2054,14 @@ mod tests { let mut with_peer = SetConnectionExtensions { inner: EchoExt, peer: Some(MtlsPeer(moq_tokio::tls::PeerIdentity::from_chain(Vec::new()))), + server_name: None, socket: None, admission: None, }; let mut no_peer = SetConnectionExtensions { inner: EchoExt, peer: None, + server_name: None, socket: None, admission: None, }; @@ -2141,7 +2183,7 @@ mod tests { } } - async fn upgrade_while_draining(mut stream: I, head_sent: bool) -> moq_net::Session + pub(super) async fn upgrade_while_draining(mut stream: I, head_sent: bool) -> moq_net::Session where I: AsyncRead + AsyncWrite + Unpin + Send + 'static, { diff --git a/rs/moq-relay/src/websocket.rs b/rs/moq-relay/src/websocket.rs index 0d02b941653..1e6a7b315c1 100644 --- a/rs/moq-relay/src/websocket.rs +++ b/rs/moq-relay/src/websocket.rs @@ -12,7 +12,7 @@ use axum::{ response::Response, }; -use crate::{web::MtlsPeer, web::WebState, web::landing_response}; +use crate::{web::MtlsPeer, web::ServerName, web::WebState, web::landing_response}; // One axum extractor per fact the upgrade needs; there is no struct to fold them into. #[allow(clippy::too_many_arguments)] @@ -23,6 +23,7 @@ pub(crate) async fn serve_ws( OriginalUri(uri): OriginalUri, headers: HeaderMap, mtls: Option>, + server_name: Option>, ConnectInfo(remote): ConnectInfo, socket_stats: Option>, admission: Option>, @@ -61,6 +62,7 @@ pub(crate) async fn serve_ws( let mut options = moq_tokio::websocket::Options::new(url, server, active); options.link.remote = Some(remote.0); options.link.alpn = ws.selected_protocol().and_then(|p| p.to_str().ok()).map(str::to_owned); + options.link.server_name = server_name.map(|Extension(ServerName(name))| name); options.identity = mtls.map(|Extension(MtlsPeer(identity))| identity); options.socket_stats = socket_stats.map(|Extension(stats)| stats.0); options.timeout = state.timeout; diff --git a/rs/moq-relay/tests/integration/auth_lifetime.rs b/rs/moq-relay/tests/integration/auth_lifetime.rs index cd5784fdae3..cda74373b39 100644 --- a/rs/moq-relay/tests/integration/auth_lifetime.rs +++ b/rs/moq-relay/tests/integration/auth_lifetime.rs @@ -467,7 +467,8 @@ async fn forwards_the_setup_token() { assert_eq!(connect.transport, moq_auth::Transport::WebSocket); assert_eq!(connect.path, "/room"); assert_eq!(connect.query.as_deref(), Some("source=setup")); - assert_eq!(connect.server_name.as_deref(), Some("127.0.0.1")); + // Plain `ws://` has no SNI; the `Host` header never stands in for one. + assert_eq!(connect.server_name, None); assert!(connect.remote.is_some_and(|addr| addr.ip().is_loopback())); assert_eq!(connect.alpn.as_deref(), Some("qmux-01.moqt-16")); } @@ -1599,6 +1600,8 @@ async fn next_update(announced: &mut moq_net::announce::Consumer) -> Option<(moq } /// The auth server sees the accepted transport, independent of negotiated MoQ ALPN. +/// An IP-literal dial sends no SNI, so neither reports a `server_name`: the +/// WebTransport CONNECT host selected no certificate and never stands in for one. #[tokio::test] async fn quic_and_webtransport_report_distinct_auth_transports() { let script = Script::new(grant(Duration::from_secs(3600))); @@ -1614,6 +1617,7 @@ async fn quic_and_webtransport_report_distinct_auth_transports() { .unwrap_or_else(|| panic!("auth connect for {path}: {seen:?}")); assert_eq!(request.transport.as_str(), expected, "{scheme}"); assert!(request.alpn.as_ref().is_some_and(|alpn| alpn.starts_with("moq-lite-"))); + assert_eq!(request.server_name, None, "{scheme}"); drop(connections); } relay.abort(); diff --git a/rs/moq-relay/tests/integration/runtime_uring.rs b/rs/moq-relay/tests/integration/runtime_uring.rs index b00d28648ce..f7639576c45 100644 --- a/rs/moq-relay/tests/integration/runtime_uring.rs +++ b/rs/moq-relay/tests/integration/runtime_uring.rs @@ -291,7 +291,8 @@ async fn uring_workers_serve_every_configured_version() { /// A session the workers accept reports its peer address, the listener's /// address, and the name the client dialed, as the tokio listener does: the -/// SNI on raw QUIC and WebTransport alike. +/// SNI on raw QUIC and WebTransport alike, and none for an IP-literal dial, which +/// sends no SNI, rather than the WebTransport CONNECT host. #[tokio::test] async fn uring_workers_report_link_facts() { let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); @@ -309,18 +310,22 @@ async fn uring_workers_report_link_facts() { let mut connections = Vec::new(); for scheme in ["moql", "https"] { - let url: url::Url = format!("{scheme}://localhost:{port}/link").parse().expect("parse url"); - connections.push(connect(client(), url).await); + for host in ["localhost", "127.0.0.1"] { + let url: url::Url = format!("{scheme}://{host}:{port}/link/{host}") + .parse() + .expect("parse url"); + connections.push(connect(client(), url).await); + } } // A client can see its session established before the relay lists it. let deadline = std::time::Instant::now() + TIMEOUT; let views = loop { let views = sessions.list(&Default::default()); - if views.len() == 2 { + if views.len() == 4 { break views; } - assert!(std::time::Instant::now() < deadline, "expected 2 sessions: {views:?}"); + assert!(std::time::Instant::now() < deadline, "expected 4 sessions: {views:?}"); tokio::time::sleep(Duration::from_millis(25)).await; }; for view in &views { @@ -328,7 +333,8 @@ async fn uring_workers_report_link_facts() { assert!(remote.ip().is_loopback() && remote.port() != 0, "peer address {remote}"); assert_ne!(remote, local, "the peer is not the listener"); assert_eq!(view.local, Some(local), "listener address"); - assert_eq!(view.server_name.as_deref(), Some("localhost"), "dialed name"); + let sni = view.path.ends_with("/localhost").then_some("localhost"); + assert_eq!(view.server_name.as_deref(), sni, "dialed name of {}", view.path); } drop(connections); From c65ff40d6dd739322082adefa8d49ddf171a01f1 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 22 Oct 2026 23:23:00 -0700 Subject: [PATCH 3/5] style: rustfmt Co-Authored-By: Claude Opus 5.5 --- rs/moq-auth/src/client.rs | 19 +++++++++++++++---- rs/moq-relay/src/web.rs | 6 +++++- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/rs/moq-auth/src/client.rs b/rs/moq-auth/src/client.rs index e7677c0b5f4..a28562b0379 100644 --- a/rs/moq-auth/src/client.rs +++ b/rs/moq-auth/src/client.rs @@ -276,7 +276,10 @@ impl Post for Client { let response = self.http.post(self.url.clone()).json(request).send().await?; let status = response.status(); if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { - let refusal = reply(response).await.ok().and_then(|body| serde_json::from_slice(&body).ok()); + let refusal = reply(response) + .await + .ok() + .and_then(|body| serde_json::from_slice(&body).ok()); return Err(Error::Refused(refusal.unwrap_or_default())); } if !status.is_success() { @@ -302,7 +305,9 @@ async fn reply(mut response: reqwest::Response) -> crate::Result> { let mut body = Vec::new(); while let Some(chunk) = response.chunk().await? { if body.len() + chunk.len() > MAX_REPLY { - return Err(Error::Unavailable(format!("auth server reply exceeds {MAX_REPLY} bytes"))); + return Err(Error::Unavailable(format!( + "auth server reply exceeds {MAX_REPLY} bytes" + ))); } body.extend_from_slice(&chunk); } @@ -1032,11 +1037,17 @@ mod tests { move |_| padded(200, &grant, MAX_REPLY) }) .await; - client(&fits).connect(request()).await.expect("a reply at the bound admits"); + client(&fits) + .connect(request()) + .await + .expect("a reply at the bound admits"); let over = server(Log::default(), move |_| padded(200, &grant, MAX_REPLY + 1)).await; let err = client(&over).connect(request()).await.unwrap_err(); - assert!(matches!(&err, Error::Unavailable(message) if message.contains("exceeds")), "{err}"); + assert!( + matches!(&err, Error::Unavailable(message) if message.contains("exceeds")), + "{err}" + ); let refused = server(Log::default(), move |_| padded(403, refusal, MAX_REPLY + 1)).await; let Error::Refused(actual) = client(&refused).connect(request()).await.unwrap_err() else { diff --git a/rs/moq-relay/src/web.rs b/rs/moq-relay/src/web.rs index 6cbad838132..211bbc7220a 100644 --- a/rs/moq-relay/src/web.rs +++ b/rs/moq-relay/src/web.rs @@ -1886,7 +1886,11 @@ mod tests { let client = tokio::spawn(drain::upgrade_while_draining(stream, true)); let admission = admissions.next().await.expect("a WebSocket admission"); assert_eq!(admission.request.transport, moq_auth::Transport::WebSocket); - assert_eq!(admission.request.server_name.as_deref(), expected, "WebSocket, tls={tls}"); + assert_eq!( + admission.request.server_name.as_deref(), + expected, + "WebSocket, tls={tls}" + ); admission.grant(moq_auth::lease::Consumer::fixed(moq_auth::Grant::new( moq_auth::Pattern::all(), moq_auth::Pattern::all(), From e64d96898605aafd660c15fa39c7f81f22eff6ec Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 00:07:05 -0700 Subject: [PATCH 4/5] docs(relay): server_name is SNI-only in the filter and migration note Co-Authored-By: Claude Opus 5.5 --- rs/moq-relay/MIGRATE.md | 2 +- rs/moq-relay/src/session.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/rs/moq-relay/MIGRATE.md b/rs/moq-relay/MIGRATE.md index 308b198b41b..f54c6ab8db3 100644 --- a/rs/moq-relay/MIGRATE.md +++ b/rs/moq-relay/MIGRATE.md @@ -7,7 +7,7 @@ Rolling a cluster across a release also follows the [upgrade guide](https://moq. ### Auth `server_name` is the SNI only (#6509) -The auth request's `server_name` no longer falls back to the HTTP `Host` header, `:authority`, or the WebTransport CONNECT host: it is absent over plain HTTP and WebSocket and for an IP-literal dial. +The auth request's `server_name` no longer falls back to the HTTP `Host` header, `:authority`, or the WebTransport CONNECT host: it is absent over plain `http://` and `ws://` and for an IP-literal dial, while HTTPS and `wss://` report the SNI. An auth server that scopes tenants by hostname must see them over TLS, and still binds the tenant to the credential. ### A client certificate alone lists no hidden routes (#6509) diff --git a/rs/moq-relay/src/session.rs b/rs/moq-relay/src/session.rs index c466f3af864..773c42e2f47 100644 --- a/rs/moq-relay/src/session.rs +++ b/rs/moq-relay/src/session.rs @@ -153,7 +153,7 @@ pub struct Filter { pub remote: Option, /// The relay's local socket, exact including port. pub local: Option, - /// SNI or the host the client addressed. + /// The SNI that selected the relay's certificate; sessions without one never match. pub server_name: Option, /// Negotiated application protocol. pub alpn: Option, From 42ebdc369dc8f3c6c445b4655b920bc43e72c5d6 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 00:17:14 -0700 Subject: [PATCH 5/5] docs(relay): tls:// sessions report no server_name Co-Authored-By: Claude Opus 5.5 --- doc/bin/relay/auth.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/bin/relay/auth.md b/doc/bin/relay/auth.md index 003226b29a2..ac8747ee0a3 100644 --- a/doc/bin/relay/auth.md +++ b/doc/bin/relay/auth.md @@ -34,8 +34,8 @@ The fields are documented on `moq_auth::Request` and `moq_auth::Grant` ([Rust](/lib/rs/moq-auth), [TypeScript](/lib/js/auth)). `server_name` is the SNI that selected the relay's certificate. It is absent -over plaintext and for an IP-literal dial, and never filled from a `Host` -header or a WebTransport authority. The client still picks it among the names +over plaintext, on the `tls://` TCP listener, and for an IP-literal dial, and +never filled from a `Host` header or a WebTransport authority. The client still picks it among the names the relay serves, so a hostname-scoped server binds the tenant to the credential, not to `server_name` alone.