Files
rust-xmpp/src/http_config.rs
T
2026-10-20 15:59:55 +07:00

396 lines
14 KiB
Rust

//! 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<String>,
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<String>,
pub bosh_origins: Vec<String>,
known_hosts: BTreeSet<String>,
external: Option<String>,
}
fn string(options: &Options, key: &str) -> Result<Option<String>> {
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::<u16>().is_ok()
})
}
fn host(value: &str) -> Result<String> {
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<String> {
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<BTreeMap<String, String>> {
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<Self> {
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<String> {
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<String> {
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<Vec<String>> {
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<bool> {
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::<axum::http::uri::Authority>() 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<String> {
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<axum::http::Uri> {
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()
);
}
}
}