fix(self-host): tie the public address to a single origin (#2605)

This commit is contained in:
Hampus
2026-09-08 22:17:39 +02:00
committed by GitHub
parent 6c36d934f7
commit 8cc485cf81
17 changed files with 903 additions and 61 deletions
+223 -9
View File
@@ -207,9 +207,85 @@ fn strip_trailing_dot(host: &str) -> &str {
host.strip_suffix('.').unwrap_or(host)
}
fn canonicalize_domain(value: &str) -> String {
strip_trailing_dot(value.trim().to_lowercase().as_str()).to_owned()
}
fn default_port(scheme: &str) -> u16 {
if scheme == "https" { 443 } else { 80 }
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PublicOrigin {
pub scheme: String,
pub domain: String,
pub port: u16,
}
pub fn parse_public_origin(origin: &str) -> Option<PublicOrigin> {
let trimmed = origin.trim();
if trimmed.is_empty() {
return None;
}
let parsed = reqwest::Url::parse(trimmed).ok()?;
let scheme = parsed.scheme();
if scheme != "http" && scheme != "https" {
return None;
}
if parsed.path() != "/" || parsed.query().is_some() || parsed.fragment().is_some() {
return None;
}
if !parsed.username().is_empty() || parsed.password().is_some() {
return None;
}
let domain = canonicalize_domain(parsed.host_str()?);
if domain.is_empty() {
return None;
}
Some(PublicOrigin {
scheme: scheme.to_owned(),
domain,
port: parsed.port().unwrap_or_else(|| default_port(scheme)),
})
}
pub fn resolve_public_domain_and_port<F>(mut read_var: F) -> anyhow::Result<(String, Option<u16>)>
where
F: FnMut(&str) -> Option<String>,
{
let mut non_empty_var = |name: &str| {
read_var(name)
.map(|value| value.trim().to_owned())
.filter(|value| !value.is_empty())
};
let configured_port = match non_empty_var("FLUXER_PUBLIC_PORT") {
None => None,
Some(raw) => Some(
raw.parse::<u16>()
.ok()
.filter(|port| *port != 0)
.ok_or_else(|| {
anyhow::anyhow!(
"FLUXER_PUBLIC_PORT must be a port between 1 and 65535, got {raw}"
)
})?,
),
};
let configured_domain =
non_empty_var("FLUXER_BASE_DOMAIN").map(|value| canonicalize_domain(&value));
let Some(origin) = non_empty_var("FLUXER_PUBLIC_ORIGIN") else {
return Ok((configured_domain.unwrap_or_default(), configured_port));
};
let parsed = parse_public_origin(&origin).ok_or_else(|| {
anyhow::anyhow!(
"FLUXER_PUBLIC_ORIGIN must be a scheme, host and optional port such as https://chat.example.com:8443, got {origin}"
)
})?;
Ok((parsed.domain, Some(parsed.port)))
}
pub fn normalize_public_endpoint(url: &str, base_domain: &str, public_port: Option<u16>) -> String {
let domain = base_domain.trim().to_lowercase();
let domain = strip_trailing_dot(&domain);
let domain = canonicalize_domain(base_domain);
let Some(port) = public_port.filter(|port| *port != 0) else {
return url.to_owned();
};
@@ -219,8 +295,7 @@ pub fn normalize_public_endpoint(url: &str, base_domain: &str, public_port: Opti
let Ok(parsed) = reqwest::Url::parse(url) else {
return url.to_owned();
};
let host = parsed.host_str().unwrap_or_default().to_lowercase();
if strip_trailing_dot(&host) != domain {
if canonicalize_domain(parsed.host_str().unwrap_or_default()) != domain {
return url.to_owned();
}
if is_default_port(parsed.scheme(), port) {
@@ -244,12 +319,13 @@ pub fn normalize_public_endpoint(url: &str, base_domain: &str, public_port: Opti
format!("{}:{port}{}", &url[..authority_end], &url[authority_end..])
}
pub fn try_normalize_public_endpoint_from_env(url: &str) -> anyhow::Result<String> {
let (base_domain, public_port) = resolve_public_domain_and_port(|name| env::var(name).ok())?;
Ok(normalize_public_endpoint(url, &base_domain, public_port))
}
pub fn normalize_public_endpoint_from_env(url: &str) -> String {
normalize_public_endpoint(
url,
&read_env("FLUXER_BASE_DOMAIN", ""),
non_empty_env("FLUXER_PUBLIC_PORT").and_then(|port| port.parse().ok()),
)
try_normalize_public_endpoint_from_env(url).unwrap_or_else(|error| panic!("{error}"))
}
#[cfg(test)]
@@ -582,6 +658,144 @@ mod tests {
}
}
fn reader(vars: &[(&str, &str)]) -> impl FnMut(&str) -> Option<String> {
let vars: Vec<(String, String)> = vars
.iter()
.map(|(name, value)| ((*name).to_owned(), (*value).to_owned()))
.collect();
move |name: &str| {
vars.iter()
.find_map(|(key, value)| (key == name).then(|| value.clone()))
}
}
#[test]
fn parses_a_public_origin() {
assert_eq!(
Some(PublicOrigin {
scheme: "https".to_owned(),
domain: "chat.example.com".to_owned(),
port: 8443,
}),
parse_public_origin("https://chat.example.com:8443")
);
assert_eq!(
Some(PublicOrigin {
scheme: "http".to_owned(),
domain: "chat.example.com".to_owned(),
port: 80,
}),
parse_public_origin("http://chat.example.com")
);
assert_eq!(
Some(PublicOrigin {
scheme: "http".to_owned(),
domain: "[::1]".to_owned(),
port: 19080,
}),
parse_public_origin("http://[::1]:19080")
);
}
#[test]
fn an_explicit_default_port_normalizes_to_the_portless_form() {
let origin = parse_public_origin("https://chat.example.com:443").expect("origin parses");
assert_eq!(443, origin.port);
assert_eq!(
"https://chat.example.com/admin/oauth2_callback",
normalize_public_endpoint(
"https://chat.example.com/admin/oauth2_callback",
&origin.domain,
Some(origin.port)
)
);
assert_eq!(
80,
parse_public_origin("http://chat.example.com:80")
.expect("origin parses")
.port
);
}
#[test]
fn rejects_anything_that_is_not_a_bare_origin() {
for origin in [
"",
" ",
"not a url",
"chat.example.com:8443",
"wss://chat.example.com",
"https://chat.example.com/media",
"https://chat.example.com?a=1",
"https://user:[email protected]",
] {
assert_eq!(None, parse_public_origin(origin), "origin {origin}");
}
}
#[test]
fn the_origin_supplies_the_domain_and_the_port() {
let (domain, port) = resolve_public_domain_and_port(reader(&[(
"FLUXER_PUBLIC_ORIGIN",
"https://chat.example.com:29080",
)]))
.expect("origin resolves");
assert_eq!("chat.example.com", domain);
assert_eq!(Some(29080), port);
}
#[test]
fn the_origin_wins_over_the_named_variables() {
let (domain, port) = resolve_public_domain_and_port(reader(&[
("FLUXER_PUBLIC_ORIGIN", "https://chat.example.com:29080"),
("FLUXER_BASE_DOMAIN", "other.example.com"),
("FLUXER_PUBLIC_SCHEME", "http"),
("FLUXER_PUBLIC_PORT", "443"),
]))
.expect("the origin resolves");
assert_eq!("chat.example.com", domain);
assert_eq!(Some(29080), port);
}
#[test]
fn a_malformed_public_port_is_loud() {
for raw in ["abc", "0", "70000", "-1"] {
let error = resolve_public_domain_and_port(reader(&[
("FLUXER_BASE_DOMAIN", "chat.example.com"),
("FLUXER_PUBLIC_PORT", raw),
]))
.expect_err("a malformed port is refused");
assert!(
error.to_string().contains("FLUXER_PUBLIC_PORT"),
"port {raw}"
);
}
}
#[test]
fn a_malformed_public_origin_is_loud() {
let error = resolve_public_domain_and_port(reader(&[(
"FLUXER_PUBLIC_ORIGIN",
"https://chat.example.com/app",
)]))
.expect_err("a malformed origin is refused");
assert!(error.to_string().contains("FLUXER_PUBLIC_ORIGIN"));
}
#[test]
fn without_an_origin_the_named_variables_are_used_as_they_are() {
let (domain, port) = resolve_public_domain_and_port(reader(&[
("FLUXER_BASE_DOMAIN", "Chat.Example.com."),
("FLUXER_PUBLIC_PORT", "19080"),
]))
.expect("named variables resolve");
assert_eq!("chat.example.com", domain);
assert_eq!(Some(19080), port);
let (domain, port) = resolve_public_domain_and_port(reader(&[])).expect("empty resolves");
assert_eq!("", domain);
assert_eq!(None, port);
}
#[test]
fn trim_trailing_slash_works() {
assert_eq!(