mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
fix(self-host): tie the public address to a single origin (#2605)
This commit is contained in:
+223
-9
@@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user