diff --git a/doc/bin/relay/auth.md b/doc/bin/relay/auth.md index 966b57cd803..74c4c1370f6 100644 --- a/doc/bin/relay/auth.md +++ b/doc/bin/relay/auth.md @@ -43,6 +43,11 @@ never filled from a `Host` header or a WebTransport authority. The client still the relay serves, so a hostname-scoped server binds the tenant to the credential, not to `server_name` alone. +`host` is the host the client dialed, without its port: the `Host` header or +URL authority on HTTP, WebSocket, and WebTransport, and the SNI on raw QUIC and +`tls://`. Unlike `server_name` it is present over plaintext, so a server that +scopes tenants by hostname can read `host`; it is just as client-chosen. + **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`, `expired`, or `malformed` (the token does not parse); @@ -497,7 +502,7 @@ patterns: `--auth-public 'PREFIX/**'`. | `--auth-mtls-tier LABEL` | `--tier LABEL` (one tier per server) | | `listen.tls.root` alone admitting a peer unscoped | `--mtls-publish '**' --mtls-subscribe '**' --mtls-link peer` | | `--auth-api` (token or proxy mode), `Cache-Control` | `--auth-url` pointed at any server answering the contract; `revalidate` and `expires` in the grant | -| `--auth-domain` | your server reads `server_name` and decides | +| `--auth-domain` | your server reads `host` (present over plaintext too) and decides | Both on one host: diff --git a/js/auth/api/index.api.md b/js/auth/api/index.api.md index 2600ab9da18..31ba791c200 100644 --- a/js/auth/api/index.api.md +++ b/js/auth/api/index.api.md @@ -681,6 +681,7 @@ export const RequestSchema: ZodMiniDiscriminatedUnion<[ZodMiniObject<{ alpn: ZodMiniOptional>; auth: ZodMiniOptional>; event: ZodMiniLiteral<"connect">; + host: ZodMiniOptional>; id: ZodMiniString; implementation: ZodMiniOptional>; local: ZodMiniOptional>; @@ -720,6 +721,7 @@ export const RequestSchema: ZodMiniDiscriminatedUnion<[ZodMiniObject<{ alpn: ZodMiniOptional>; auth: ZodMiniString; event: ZodMiniLiteral<"attach">; + host: ZodMiniOptional>; id: ZodMiniString; implementation: ZodMiniOptional>; local: ZodMiniOptional>; @@ -759,6 +761,7 @@ export const RequestSchema: ZodMiniDiscriminatedUnion<[ZodMiniObject<{ alpn: ZodMiniOptional>; auth: ZodMiniOptional>; event: ZodMiniLiteral<"revalidate">; + host: ZodMiniOptional>; id: ZodMiniString; implementation: ZodMiniOptional>; local: ZodMiniOptional>; @@ -803,6 +806,7 @@ export const RequestSchema: ZodMiniDiscriminatedUnion<[ZodMiniObject<{ }, $strip>; duration: ZodMiniNumber; event: ZodMiniLiteral<"end">; + host: ZodMiniOptional>; id: ZodMiniString; implementation: ZodMiniOptional>; local: ZodMiniOptional>; diff --git a/js/auth/src/contract.test.ts b/js/auth/src/contract.test.ts index 8aeeb0c37f3..092a34cf596 100644 --- a/js/auth/src/contract.test.ts +++ b/js/auth/src/contract.test.ts @@ -13,6 +13,7 @@ test("a connect request parses with every field", () => { remote: "203.0.113.9:4433", local: "[::1]:443", server_name: "relay.example", + host: "relay.example", alpn: "moq-lite-05", path: "/demo/room", query: "jwt=abc", diff --git a/js/auth/src/contract.ts b/js/auth/src/contract.ts index 6515038714c..a05eaaa87b7 100644 --- a/js/auth/src/contract.ts +++ b/js/auth/src/contract.ts @@ -108,6 +108,12 @@ const BaseRequestSchema = z.object({ * server binds the tenant to the credential, not to this alone. */ server_name: z.optional(z.string()), + /** + * The host the client dialed, without its port: the `Host` header or URL authority on + * HTTP, WebSocket, and WebTransport, the SNI on raw QUIC and `tls://`. Client-chosen + * even over plaintext, so bind the tenant to the credential, not to this alone. + */ + host: z.optional(z.string()), /** The negotiated application protocol, including the moq version. */ alpn: z.optional(z.string()), /** The path exactly as dialed. */ diff --git a/rs/moq-auth/CHANGELOG.md b/rs/moq-auth/CHANGELOG.md index c8ba20461a6..1b9145403d9 100644 --- a/rs/moq-auth/CHANGELOG.md +++ b/rs/moq-auth/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Auth requests carry `host`, the host the client dialed: the `Host` header on HTTP and WebSocket, where `server_name` stays the TLS SNI only. A server that scopes tenants by hostname can now scope plaintext sessions. - Token claims and grants carry optional bitrate caps again, as additive `publish_rate` and `subscribe_rate` (`Rate`, bits per second) beside the patterns. A capped role is written as `{"path": [...], "rate": bps}`, which released verifiers refuse rather than drop the cap; key scopes still refuse it. ### Changed diff --git a/rs/moq-auth/src/request.rs b/rs/moq-auth/src/request.rs index 5445257251a..f7079f0201a 100644 --- a/rs/moq-auth/src/request.rs +++ b/rs/moq-auth/src/request.rs @@ -46,6 +46,11 @@ pub struct Request { /// server binds the tenant to the credential, not to this alone. pub server_name: Option, + /// The host the client dialed, without its port: the `Host` header or URL authority on + /// HTTP, WebSocket, and WebTransport, the SNI on raw QUIC and `tls://`. Client-chosen + /// even over plaintext, so bind the tenant to the credential, not to this alone. + pub host: Option, + /// The negotiated application protocol, including the moq version. pub alpn: Option, @@ -79,6 +84,7 @@ impl std::fmt::Debug for Request { .field("remote", &self.remote) .field("local", &self.local) .field("server_name", &self.server_name) + .field("host", &self.host) .field("alpn", &self.alpn) .field("path", &self.path) // The contract permits credentials under any query key. @@ -109,6 +115,7 @@ impl Request { remote: None, local: None, server_name: None, + host: None, alpn: None, path: path.into(), query: None, @@ -278,6 +285,7 @@ mod tests { request.remote = Some("203.0.113.9:4433".parse().unwrap()); request.local = Some("[::1]:443".parse().unwrap()); request.server_name = Some("relay.example".into()); + request.host = Some("relay.example".into()); request.alpn = Some("moq-lite-05".into()); request.query = Some("jwt=abc".into()); request.role = Some(Role::Publisher); diff --git a/rs/moq-relay/src/auth.rs b/rs/moq-relay/src/auth.rs index 5223eca2c82..a9502dab417 100644 --- a/rs/moq-relay/src/auth.rs +++ b/rs/moq-relay/src/auth.rs @@ -1254,6 +1254,7 @@ pub fn request_for(auth: &Auth, request: &moq_tokio::server::Request) -> Request // 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.host = request.authority().map(str::to_owned); out.alpn = request.alpn().map(str::to_owned); out.role = request.role().and_then(role); out.implementation = request.peer_implementation().map(str::to_owned); diff --git a/rs/moq-relay/src/uring.rs b/rs/moq-relay/src/uring.rs index 6ed8487d8ae..96432287c1a 100644 --- a/rs/moq-relay/src/uring.rs +++ b/rs/moq-relay/src/uring.rs @@ -815,6 +815,12 @@ impl crate::incoming::Sealed for Request { request.local = Some(self.local); // Only the SNI, like the tokio listener: the CONNECT host selected no certificate. request.server_name = self.sni.clone(); + request.host = self + .url + .as_ref() + .and_then(|url| url.host_str()) + .map(str::to_owned) + .or_else(|| self.sni.clone()); request.alpn = self.alpn.clone(); request.tls = self.identity.as_ref().and_then(auth::peer); request diff --git a/rs/moq-relay/src/web.rs b/rs/moq-relay/src/web.rs index 04366628f08..c2c900fc712 100644 --- a/rs/moq-relay/src/web.rs +++ b/rs/moq-relay/src/web.rs @@ -914,15 +914,20 @@ impl<'de> serde::Deserialize<'de> for FetchGroup { } /// Admit a one-shot HTTP read: the auth server sees the path, the raw query, the -/// SNI on HTTPS, the peer, and any certificate. Only a grant that may +/// SNI on HTTPS, the dialed host, 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")] +#[expect( + clippy::too_many_arguments, + reason = "one fact per axum extractor the handlers pass through" +)] async fn admit_http( state: &WebState, path: String, query: AuthQuery, uri: &http::Uri, + headers: &http::HeaderMap, server_name: Option>, remote: net::SocketAddr, mtls: Option>, @@ -937,6 +942,7 @@ async fn admit_http( .map(str::to_owned) .or_else(|| query.jwt.map(|jwt| format!("jwt={jwt}"))); request.server_name = server_name.map(|Extension(ServerName(name))| name); + request.host = dialed_host(uri, headers); request.remote = Some(remote); request.tls = mtls.and_then(|Extension(MtlsPeer(identity))| auth::peer(&identity)); // The listener's timeout bounds admission as it bounds the request headers. An @@ -959,9 +965,28 @@ async fn admit_http( Ok((lease.with_stats(stats), origin)) } +/// The host an HTTP request dialed, without its port: the HTTP/2 `:authority`, else +/// the `Host` header. Lowercased to match the URL-derived transports, whose +/// `Url::host_str` lowercases a domain. +fn dialed_host(uri: &http::Uri, headers: &http::HeaderMap) -> Option { + let header = || { + headers + .get(http::header::HOST) + .and_then(|host| host.to_str().ok()) + .and_then(|host| host.parse::().ok()) + }; + let authority = uri.authority().cloned().or_else(header)?; + let host = authority.host(); + (!host.is_empty()).then(|| host.to_ascii_lowercase()) +} + /// Serve the announced broadcasts for a given prefix. // 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")] +#[expect( + clippy::too_many_arguments, + reason = "one axum extractor per fact the admission needs" +)] async fn serve_announced( path: Option>, Query(query): Query, @@ -969,6 +994,7 @@ async fn serve_announced( server_name: Option>, ConnectInfo(remote): ConnectInfo, uri: http::Uri, + headers: http::HeaderMap, State(state): State>, ) -> axum::response::Result { let prefix = match path { @@ -976,7 +1002,7 @@ async fn serve_announced( None => String::new(), }; - let (lease, origin) = admit_http(&state, prefix, query, &uri, server_name, remote, mtls).await?; + let (lease, origin) = admit_http(&state, prefix, query, &uri, &headers, server_name, remote, mtls).await?; let mut announced = origin.routes(); let mut broadcasts = Vec::new(); @@ -1000,6 +1026,10 @@ async fn serve_announced( /// Serve the given group for a given track // 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")] +#[expect( + clippy::too_many_arguments, + reason = "one axum extractor per fact the admission needs" +)] async fn serve_fetch( Path(path): Path, Query(params): Query, @@ -1007,6 +1037,7 @@ async fn serve_fetch( server_name: Option>, ConnectInfo(remote): ConnectInfo, uri: http::Uri, + headers: http::HeaderMap, State(state): State>, ) -> axum::response::Result { // The path containts a broadcast/track @@ -1018,7 +1049,17 @@ async fn serve_fetch( return Err(StatusCode::BAD_REQUEST.into()); } - let (lease, origin) = admit_http(&state, path.join("/"), params.auth, &uri, server_name, remote, mtls).await?; + let (lease, origin) = admit_http( + &state, + path.join("/"), + params.auth, + &uri, + &headers, + server_name, + remote, + mtls, + ) + .await?; // The token's root is the canonical (alias-resolved) broadcast path. let broadcast = lease.token().root.to_string(); @@ -1301,6 +1342,7 @@ mod tests { None, remote, "/fetch/room/video".parse().unwrap(), + http::HeaderMap::new(), State(state.clone()), ) .await @@ -1315,6 +1357,7 @@ mod tests { None, remote, "/announced/room".parse().unwrap(), + http::HeaderMap::new(), State(state.clone()), ) .await @@ -1367,6 +1410,7 @@ mod tests { None, ConnectInfo("127.0.0.1:1234".parse().unwrap()), "/fetch/room/video".parse().unwrap(), + http::HeaderMap::new(), State(state.clone()), ) .await; @@ -1378,6 +1422,7 @@ mod tests { None, ConnectInfo("127.0.0.1:1234".parse().unwrap()), "/announced/room".parse().unwrap(), + http::HeaderMap::new(), State(state.clone()), ) .await; @@ -1452,6 +1497,7 @@ mod tests { None, ConnectInfo("127.0.0.1:1234".parse().unwrap()), format!("/fetch/room/video?tier={tier}").parse().unwrap(), + http::HeaderMap::new(), State(state.clone()), ) .await @@ -1468,6 +1514,7 @@ mod tests { None, ConnectInfo("127.0.0.1:1234".parse().unwrap()), format!("/announced/room?tier={tier}").parse().unwrap(), + http::HeaderMap::new(), State(state.clone()), ) .await @@ -1563,6 +1610,7 @@ mod tests { None, ConnectInfo("127.0.0.1:1234".parse().unwrap()), "/fetch/room/video?group=0".parse().unwrap(), + http::HeaderMap::new(), State(state), ) .await @@ -1604,6 +1652,7 @@ mod tests { None, peer(), "/fetch/room/video".parse().unwrap(), + http::HeaderMap::new(), State(state.clone()), ) .await; @@ -1615,6 +1664,7 @@ mod tests { None, peer(), "/announced/room".parse().unwrap(), + http::HeaderMap::new(), State(state.clone()), ) .await; @@ -1968,7 +2018,8 @@ mod tests { /// `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. + /// reports none rather than the header. `host` carries the header on both, lowercased and + /// without its port. 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() { @@ -2015,19 +2066,24 @@ mod tests { 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") + .write_all(b"GET /announced HTTP/1.1\r\nHost: Tenant.Example:8443\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}"); + assert_eq!( + admission.request.host.as_deref(), + Some("tenant.example"), + "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") + .write_all(b"GET /room HTTP/1.1\r\nHost: Tenant.Example:8443\r\n") .await .unwrap(); let client = tokio::spawn(drain::upgrade_while_draining(stream, true)); @@ -2038,6 +2094,11 @@ mod tests { expected, "WebSocket, tls={tls}" ); + assert_eq!( + admission.request.host.as_deref(), + Some("tenant.example"), + "WebSocket, tls={tls}" + ); admission.grant(moq_auth::lease::Consumer::fixed(moq_auth::Grant::new( moq_auth::Pattern::all(), moq_auth::Pattern::all(), diff --git a/test/interop/interop.sh b/test/interop/interop.sh index 520f3541633..81dd928a6ec 100755 --- a/test/interop/interop.sh +++ b/test/interop/interop.sh @@ -2546,8 +2546,8 @@ run_catalog_fanout() { done for reader in "${readers[@]}"; do harness_wait "$reader" || true; done for version in "${CATALOG_VERSIONS[@]}"; do - want="catalog compress=false video=hd,sd audio=en,fr" - [[ "$version" != moq-lite-07-wip ]] || want="catalog compress=true video=hd,sd audio=en,fr" + want="catalog compression=none video=hd,sd audio=en,fr" + [[ "$version" != moq-lite-07-wip ]] || want="catalog compression=deflate video=hd,sd audio=en,fr" # Like run_native, a verified result survives the NAPI addon's exit crash. grep -qx "$want" "$LOGS/$name-$version.log" || why="${why:-$version reader did not report: $want}" done