Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 44 additions & 20 deletions src/web/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,20 +81,14 @@ pub(crate) fn content_security_policy(config: &WebServerConfig) -> String {

// LiveKit Cloud answers `/settings/regions` with a regional hostname and
// the SDK retries there, so the configured host alone is not enough.
// Self-hosted deployments have no such indirection and keep their exact
// origins.
//
// One pass, so a third source of endpoints is added to the iterator above
// and both the exact origins and the wildcard follow from it. The wildcard
// names the domain it was granted for, because a deployment on one cloud
// domain has no reason to reach the other.
let mut cloud: Vec<&'static str> = Vec::new();
// Keeping the wildcard beside the endpoint's two exact origins means a
// future endpoint source cannot add only the initial signaling host and
// bring this browser-only failure back.
for url in endpoints {
connect.extend(livekit_origins(url));
if let Some(domain) = livekit_cloud_domain(url).filter(|it| !cloud.contains(it)) {
cloud.push(domain);
connect.push(format!("https://*.{domain}"));
connect.push(format!("wss://*.{domain}"));
for source in livekit_connect_sources(url) {
if !connect.contains(&source) {
connect.push(source);
}
}
}
if !config.production {
Expand Down Expand Up @@ -141,10 +135,7 @@ pub(crate) fn content_security_policy(config: &WebServerConfig) -> String {
/// HTTPS calls to the same host, region settings among them. Naming only the
/// `wss://` origin lets the socket open and then blocks those, which surfaces
/// as a connection that fails for no stated reason.
pub(crate) fn livekit_origins(url: &str) -> Vec<String> {
let Some(origin) = url_origin(url) else {
return Vec::new();
};
fn livekit_origins_for_csp_origin(origin: &str) -> Vec<String> {
let Some((scheme, host)) = origin.split_once("://") else {
return Vec::new();
};
Expand All @@ -153,9 +144,42 @@ pub(crate) fn livekit_origins(url: &str) -> Vec<String> {
"ws" => "http",
"https" => "wss",
"http" => "ws",
_ => return vec![origin],
_ => return vec![origin.to_string()],
};
vec![format!("{sibling}://{host}"), origin.to_string()]
}

/// The origin spelling CSP accepts. A LiveKit URL may carry userinfo for a
/// server-side client, but credentials are not part of a CSP host source and
/// would make the browser discard the source that needs to admit the socket.
///
/// Keep the authority deliberately smaller than a general URL authority. The
/// configured endpoint becomes header syntax below, so separators that can
/// start another source or directive must cost only this endpoint rather than
/// weaken or invalidate the whole policy.
fn csp_origin(url: &str) -> Option<String> {
let origin = url_origin(url)?;
let (scheme, authority) = origin.split_once("://")?;
let host = authority.rsplit('@').next()?;
let csp_host_byte =
|byte: u8| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b':' | b'[' | b']');
(!host.is_empty() && host.bytes().all(csp_host_byte)).then(|| format!("{scheme}://{host}"))
}

/// Every CSP source a LiveKit endpoint needs. Cloud projects redirect the
/// browser to a regional hostname, while a self-hosted project has only the
/// exact HTTP and WebSocket origins from the CSP-safe endpoint.
fn livekit_connect_sources(url: &str) -> Vec<String> {
let Some(origin) = csp_origin(url) else {
return Vec::new();
};
vec![format!("{sibling}://{host}"), origin]

let mut sources = livekit_origins_for_csp_origin(&origin);
if let Some(domain) = livekit_cloud_domain(&origin) {
sources.push(format!("https://*.{domain}"));
sources.push(format!("wss://*.{domain}"));
}
sources
}

/// The two domains `livekit-client.js` treats as LiveKit Cloud, which is what
Expand Down Expand Up @@ -207,7 +231,7 @@ pub(crate) fn livekit_http_origin(url: &str) -> Option<String> {
/// configure and the server will start on. Every reader below then matches the
/// scheme exactly, and each one fails differently on the uppercase form: the
/// quota probe finds no HTTP origin and treats the project as available, so an
/// exhausted project is never passed over, and `livekit_origins` finds no
/// exhausted project is never passed over, and the CSP origin pairing finds no
/// sibling, so the CSP omits the `https://` origin the SDK needs for
/// `/settings/regions` and the socket opens onto blocked requests. One
/// normalization at the boundary is the alternative to three readers each
Expand Down
78 changes: 54 additions & 24 deletions tests/unit/web/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,19 +56,31 @@ fn the_policy_names_the_host_the_avatar_model_is_fetched_from() {
assert!(connect.contains(AVATAR_MODEL_ORIGIN), "{connect}");
}

/// both fail over to a regional host and both need the wildcard. Each gets
/// its own: a deployment on one has no reason to reach the other.
/// Both cloud domains fail over to regional hosts and need their own wildcard.
/// A deployment on one has no reason to reach the other.
#[test]
fn each_cloud_domain_gets_only_its_own_wildcard() {
let cloud = policy_for("wss://example.livekit.cloud");
assert!(cloud.contains("https://*.livekit.cloud"), "{cloud}");
assert!(cloud.contains("wss://*.livekit.cloud"), "{cloud}");
assert!(!cloud.contains("*.livekit.run"), "{cloud}");
fn cloud_domains_admit_regional_hosts_without_widening_to_each_other() {
for (url, domain, other_domain) in [
(
"wss://example.livekit.cloud",
"livekit.cloud",
"livekit.run",
),
("wss://example.livekit.run", "livekit.run", "livekit.cloud"),
] {
let policy = policy_for(url);
assert!(policy.contains(&format!("https://*.{domain}")), "{policy}");
assert!(policy.contains(&format!("wss://*.{domain}")), "{policy}");
assert!(!policy.contains(&format!("*.{other_domain}")), "{policy}");
}

let run = policy_for("wss://example.livekit.run");
assert!(run.contains("https://*.livekit.run"), "{run}");
assert!(run.contains("wss://*.livekit.run"), "{run}");
assert!(!run.contains("*.livekit.cloud"), "{run}");
// Regional signaling hosts have labels below both the project and cloud
// domains. The configured project's wildcard must therefore cover this
// shape, not merely a sibling project directly under `livekit.cloud`.
assert_eq!(
livekit_cloud_domain("wss://conversation-xxx.otokyo1b.production.livekit.cloud"),
Some("livekit.cloud")
);
}

/// A self-hosted server has no region indirection, so widening its policy
Expand Down Expand Up @@ -132,6 +144,34 @@ fn a_malformed_url_is_refused_one_term_at_a_time() {
);
}

/// A server-side URL may need userinfo, but a CSP source is only a scheme and
/// host. Leaving the userinfo in makes the browser discard the entry, which is
/// a silent connection failure for a self-hosted deployment. CSP delimiters
/// are refused rather than becoming syntax in the policy.
#[test]
fn csp_origins_strip_userinfo_and_refuse_policy_syntax() {
let policy = policy_for("wss://key:secret@project.example:7880");
assert!(policy.contains("https://project.example:7880"), "{policy}");
assert!(policy.contains("wss://project.example:7880"), "{policy}");
assert!(!policy.contains("key:secret"), "{policy}");

for url in [
"wss://project.example;script-src=*",
"wss://project.example'",
"wss://project.example,evil.example",
"wss://project.example*",
] {
let policy = policy_for(url);
assert!(!policy.contains("project.example"), "{url}: {policy}");
}

let policy = policy_for("wss://project.livekit.cloud;script-src=*");
assert!(
!policy.contains("project.livekit.cloud") && !policy.contains("*.livekit.cloud"),
"a rejected endpoint must not earn a Cloud wildcard: {policy}"
);
}

/// `validate_livekit_url` accepts the scheme case-insensitively, so an
/// uppercase one is a URL the server starts on. Every reader here matches
/// the scheme exactly, and each fails differently: the quota probe finds no
Expand All @@ -149,14 +189,6 @@ fn an_uppercase_scheme_is_normalized_before_anything_matches_on_it() {
Some("https://host.example".to_string()),
"without this the quota probe never runs and the project is assumed available"
);
assert_eq!(
livekit_origins("WSS://host.example"),
vec![
"https://host.example".to_string(),
"wss://host.example".to_string()
],
"the sibling origin is what the SDK reaches for regions"
);

// All four schemes, not just the one a cloud deployment uses. A self-hosted
// LiveKit is reached over `ws://` in development, and the arm that pairs it
Expand All @@ -168,11 +200,9 @@ fn an_uppercase_scheme_is_normalized_before_anything_matches_on_it() {
("https://host.example", "wss://host.example"),
("http://host.example", "ws://host.example"),
] {
assert_eq!(
livekit_origins(configured),
vec![sibling.to_string(), configured.to_string()],
"{configured} must also name {sibling}"
);
let policy = policy_for(configured);
assert!(policy.contains(sibling), "{configured}: {policy}");
assert!(policy.contains(configured), "{configured}: {policy}");
}

let policy = policy_for("WSS://uppercase-scheme.livekit.cloud");
Expand Down