//! Prosody-compatible HTTP app mount and advertised URL configuration. use crate::config::{Config, Options}; use anyhow::{Context, Result, ensure}; use std::collections::{BTreeMap, BTreeSet}; #[derive(Clone, Debug)] pub struct HttpConfig { pub host: String, pub default_host: Option, pub websocket_path: String, pub bosh_path: String, pub file_share_path: String, pub altconnect_path: String, pub consider_websocket_secure: bool, pub consider_bosh_secure: bool, pub websocket_origins: Vec, pub bosh_origins: Vec, known_hosts: BTreeSet, external: Option, } fn string(options: &Options, key: &str) -> Result> { options .get(key) .map(|v| { v.as_str() .map(str::to_owned) .with_context(|| format!("{key} must be a string")) }) .transpose() } fn valid_authority(authority: &axum::http::uri::Authority) -> bool { let raw = authority.as_str(); if raw.contains('@') { return false; } let suffix = if raw.starts_with('[') { let Some(end) = raw.find(']') else { return false; }; &raw[end + 1..] } else { raw.find(':').map(|i| &raw[i..]).unwrap_or("") }; if suffix.is_empty() { return true; } suffix.strip_prefix(':').is_some_and(|port| { !port.is_empty() && port.bytes().all(|c| c.is_ascii_digit()) && port.parse::().is_ok() }) } fn host(value: &str) -> Result { ensure!( !value.is_empty() && value.is_ascii() && value .bytes() .all(|c| c.is_ascii_alphanumeric() || matches!(c, b'.' | b'-')), "invalid HTTP host: {value}" ); Ok(value.to_ascii_lowercase()) } /// Mount paths are literals, never axum route patterns. Reject ambiguous or unsafe forms. pub fn normalize_path(value: &str) -> Result { ensure!( value.is_ascii(), "HTTP paths must use percent encoding for non-ASCII characters" ); // Validate pchars directly. No value is passed to a route-pattern parser. let bytes = value.as_bytes(); let mut decoded = Vec::with_capacity(bytes.len()); let mut offset = 0; while offset < bytes.len() { let byte = bytes[offset]; if byte == b'%' { ensure!( offset + 2 < bytes.len() && bytes[offset + 1].is_ascii_hexdigit() && bytes[offset + 2].is_ascii_hexdigit(), "malformed percent escape in HTTP path" ); let byte = u8::from_str_radix(&value[offset + 1..offset + 3], 16)?; ensure!( byte != b'/' && byte != b'\\' && !byte.is_ascii_control(), "ambiguous percent escape in HTTP path" ); decoded.push(byte); offset += 3; } else { ensure!( byte.is_ascii_alphanumeric() || b"/-._~!&'()*+,;=:@".contains(&byte), "HTTP path must contain only literal URL path characters" ); decoded.push(byte); offset += 1; } } ensure!( !value.contains("//") && !decoded .split(|b| *b == b'/') .any(|s| s == b"." || s == b".."), "invalid HTTP path" ); let value = if value.starts_with('/') { value.to_owned() } else { format!("/{value}") }; let value = value.trim_end_matches('/'); Ok(if value.is_empty() { "/".into() } else { value.to_owned() }) } fn paths(options: &Options) -> Result> { let Some(value) = options.get("http_paths") else { return Ok(BTreeMap::new()); }; let table = value.as_object().context("http_paths must be a table")?; table .iter() .map(|(k, v)| { Ok(( k.clone(), v.as_str() .context("http_paths entries must be strings")? .to_owned(), )) }) .collect() } impl HttpConfig { pub fn for_host(config: &Config, domain: &str) -> Result { let local = &config .hosts .get(domain) .context("unknown HTTP virtual host")? .options; let global_paths = paths(&config.global)?; let local_paths = paths(local)?; let mount = |name: &str, default: &str| -> Result { normalize_path( &local_paths .get(name) .or_else(|| global_paths.get(name)) .map(String::as_str) .unwrap_or(default) .replace("$host", domain), ) }; let effective_host = |name: &str| -> Result { let h = &config.hosts.get(name).context("unknown HTTP host")?.options; host( &string(h, "http_host")? .or(string(&config.global, "http_host")?) .unwrap_or_else(|| name.into()), ) }; let mut known_hosts = BTreeSet::new(); for name in config.hosts.keys() { known_hosts.insert(effective_host(name)?); } let default_host = string(&config.global, "http_default_host")? .map(|s| host(&s)) .transpose()?; let origins = |module: &str| -> Result> { let value = config .option(domain, "http_cors_override") .and_then(|v| v.get(module)) .and_then(|v| v.get("origins")) .or_else(|| config.option(domain, "access_control_allow_origins")); match value { None => Ok(vec![]), Some(v) if v.as_object().is_some_and(|m| m.is_empty()) => Ok(vec![]), Some(v) if v.as_str() == Some("*") => Ok(vec!["*".into()]), Some(v) => v .as_array() .context("HTTP origins must be a list")? .iter() .map(|v| { v.as_str() .map(str::to_owned) .context("HTTP origins entries must be strings") }) .collect(), } }; let boolean = |key: &str| -> Result { config .option(domain, key) .map(|v| { v.as_bool() .with_context(|| format!("{key} must be boolean")) }) .unwrap_or(Ok(false)) }; let external = string(local, "http_external_url")?.or(string(&config.global, "http_external_url")?); if let Some(url) = &external { parse_external(url)?; } Ok(Self { host: effective_host(domain)?, default_host, websocket_path: mount("websocket", "/xmpp-websocket")?, bosh_path: mount("bosh", "/http-bind")?, file_share_path: mount("file_share", "/file_share")?, altconnect_path: mount("altconnect", "/.well-known")?, consider_websocket_secure: boolean("consider_websocket_secure")?, consider_bosh_secure: boolean("consider_bosh_secure")?, websocket_origins: origins("websocket")?, bosh_origins: origins("bosh")?, known_hosts, external, }) } pub fn accepts_host(&self, authority: &str) -> bool { let Ok(authority) = authority.parse::() else { return false; }; if !valid_authority(&authority) { return false; } let requested = authority.host().to_ascii_lowercase(); requested == self.host || (!self.known_hosts.contains(&requested) && self.default_host.as_deref() == Some(self.host.as_str())) } /// External reverse-proxy prefixes affect discovery only, never local mounts. pub fn external_url(&self, app: &str, secure: bool, port: u16) -> Result { let path = match app { "websocket" => &self.websocket_path, "bosh" => &self.bosh_path, "file_share" => &self.file_share_path, "altconnect" => &self.altconnect_path, _ => anyhow::bail!("unknown HTTP app"), }; if let Some(external) = &self.external { let uri = parse_external(external)?; let scheme = uri.scheme_str().unwrap(); let authority = uri.authority().unwrap(); let authority = if authority.port_u16() == Some(if scheme == "https" { 443 } else { 80 }) { authority.host().to_owned() } else { authority.to_string() }; return Ok(format!( "{scheme}://{authority}{}{path}", uri.path().trim_end_matches('/') )); } let scheme = if secure { "https" } else { "http" }; let port = if port == if secure { 443 } else { 80 } { String::new() } else { format!(":{port}") }; Ok(format!("{scheme}://{}{port}{path}", self.host)) } } fn parse_external(value: &str) -> Result { let uri: axum::http::Uri = value.parse().context("invalid http_external_url")?; ensure!( matches!(uri.scheme_str(), Some("http" | "https")) && uri.authority().is_some() && !uri.authority().unwrap().as_str().contains('@') && uri.query().is_none() && !value.contains('#'), "http_external_url must be an absolute HTTP(S) URL without credentials, query or fragment" ); ensure!( valid_authority(uri.authority().unwrap()), "http_external_url port must be in 0..65535" ); Ok(uri) } pub fn validate(config: &Config) -> Result<()> { for options in std::iter::once(&config.global).chain(config.hosts.values().map(|h| &h.options)) { for value in paths(options)?.values() { normalize_path(&value.replace("$host", "example.test"))?; } } for domain in config.hosts.keys() { let http = HttpConfig::for_host(config, domain)?; let modules = config.modules(domain)?; ensure!( !(modules.contains("websocket") && modules.contains("bosh") && http.websocket_path == http.bosh_path), "websocket and bosh HTTP paths conflict for {domain}" ); if modules.contains("http_altconnect") { for suffix in ["host-meta", "host-meta.json"] { let path = format!("{}/{suffix}", http.altconnect_path.trim_end_matches('/')); ensure!( !(modules.contains("websocket") && http.websocket_path == path || modules.contains("bosh") && http.bosh_path == path), "http_altconnect path conflicts with XMPP transport for {domain}" ); } } } Ok(()) } #[cfg(test)] mod tests { use super::*; fn config(value: serde_json::Value) -> Config { serde_json::from_value(value).unwrap() } #[test] fn mounts_inherit_per_app_and_expand_virtual_host() { let c = config( serde_json::json!({"global":{"http_paths":{"websocket":"global/$host/","bosh":"bind/"}},"hosts":{"a.test":{"options":{"http_paths":{"websocket":"custom/$host"}}}}}), ); let h = HttpConfig::for_host(&c, "a.test").unwrap(); assert_eq!(h.websocket_path, "/custom/a.test"); assert_eq!(h.bosh_path, "/bind"); } #[test] fn routes_hosts_and_default_without_overriding_known_hosts() { let c = config( serde_json::json!({"global":{"http_default_host":"web.test"},"hosts":{"a.test":{"options":{"http_host":"web.test"}},"b.test":{"options":{}}}}), ); let h = HttpConfig::for_host(&c, "a.test").unwrap(); assert!(h.accepts_host("WEB.TEST:5280")); assert!(h.accepts_host("unknown.test")); assert!(!h.accepts_host("b.test")); assert!(!h.accepts_host("bad host")); } #[test] fn external_prefix_and_standard_port() { let c = config( serde_json::json!({"global":{"http_external_url":"https://proxy.test:443/prefix/"},"hosts":{"a.test":{"options":{}}}}), ); assert_eq!( HttpConfig::for_host(&c, "a.test") .unwrap() .external_url("bosh", false, 5280) .unwrap(), "https://proxy.test/prefix/http-bind" ); } #[test] fn literal_pchars_and_percent_encoding_are_supported() { for path in [ "/chat/v1:ws", "/bind%20endpoint", "/a/*", "/a@b!&'()+,;=:_~-.%41", ] { assert_eq!(normalize_path(path).unwrap(), path); } } #[test] fn invalid_paths_types_urls_and_conflicts_are_errors() { for path in [ "/a/{capture}", "/../b", "/a?x", "/a#b", "/a//b", "/$unknown", "/a%2fb", "/a%5cb", "/a%00b", "/%2e%2E/b", "/a%", "/a%xx", "/a b", "/a\\b", ] { assert!(normalize_path(path).is_err(), "{path}"); } for global in [ serde_json::json!({"http_paths":false}), serde_json::json!({"http_paths":{"bosh":false}}), serde_json::json!({"http_external_url":"https://u:p@a.test"}), serde_json::json!({"http_external_url":"relative/path"}), serde_json::json!({"modules_enabled":["websocket","bosh"],"http_paths":{"websocket":"same/","bosh":"/same"}}), ] { assert!( validate(&config( serde_json::json!({"global":global,"hosts":{"a.test":{"options":{}}}}) )) .is_err() ); } } }