mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
feat(push): allow listed hosts to resolve to private addresses (#3228)
This commit is contained in:
@@ -219,6 +219,9 @@ FLUXER_VAPID_PRIVATE_KEY=CHANGE_ME
|
|||||||
#FLUXER_PUSH_SERVICE_FCM_BASE_URL=https://fcm.googleapis.com
|
#FLUXER_PUSH_SERVICE_FCM_BASE_URL=https://fcm.googleapis.com
|
||||||
#FLUXER_PUSH_SERVICE_MANAGED_RELAY_HOSTS=push.fluxer.com
|
#FLUXER_PUSH_SERVICE_MANAGED_RELAY_HOSTS=push.fluxer.com
|
||||||
#FLUXER_PUSH_SERVICE_OWN_RELAY_HOSTS=
|
#FLUXER_PUSH_SERVICE_OWN_RELAY_HOSTS=
|
||||||
|
# Push hosts on your own network, such as a ntfy server, that may resolve to
|
||||||
|
# private addresses. Comma separated.
|
||||||
|
#FLUXER_PUSH_SERVICE_PRIVATE_HOSTS=ntfy.example.com
|
||||||
#FLUXER_PUSH_SERVICE_RELAY_CONSENT_ACCEPTED=false
|
#FLUXER_PUSH_SERVICE_RELAY_CONSENT_ACCEPTED=false
|
||||||
|
|
||||||
# Direct mobile push through your own APNs and FCM credentials, off by default.
|
# Direct mobile push through your own APNs and FCM credentials, off by default.
|
||||||
|
|||||||
@@ -615,6 +615,7 @@ services:
|
|||||||
FLUXER_PUSH_SERVICE_FCM_BASE_URL: ${FLUXER_PUSH_SERVICE_FCM_BASE_URL:-}
|
FLUXER_PUSH_SERVICE_FCM_BASE_URL: ${FLUXER_PUSH_SERVICE_FCM_BASE_URL:-}
|
||||||
FLUXER_PUSH_SERVICE_MANAGED_RELAY_HOSTS: ${FLUXER_PUSH_SERVICE_MANAGED_RELAY_HOSTS:-}
|
FLUXER_PUSH_SERVICE_MANAGED_RELAY_HOSTS: ${FLUXER_PUSH_SERVICE_MANAGED_RELAY_HOSTS:-}
|
||||||
FLUXER_PUSH_SERVICE_OWN_RELAY_HOSTS: ${FLUXER_PUSH_SERVICE_OWN_RELAY_HOSTS:-}
|
FLUXER_PUSH_SERVICE_OWN_RELAY_HOSTS: ${FLUXER_PUSH_SERVICE_OWN_RELAY_HOSTS:-}
|
||||||
|
FLUXER_PUSH_SERVICE_PRIVATE_HOSTS: ${FLUXER_PUSH_SERVICE_PRIVATE_HOSTS:-}
|
||||||
FLUXER_PUSH_SERVICE_RELAY_CONSENT_ACCEPTED: ${FLUXER_PUSH_SERVICE_RELAY_CONSENT_ACCEPTED:-}
|
FLUXER_PUSH_SERVICE_RELAY_CONSENT_ACCEPTED: ${FLUXER_PUSH_SERVICE_RELAY_CONSENT_ACCEPTED:-}
|
||||||
FLUXER_PUSH_APNS_DEFAULT_ENVIRONMENT: ${FLUXER_PUSH_APNS_DEFAULT_ENVIRONMENT:-}
|
FLUXER_PUSH_APNS_DEFAULT_ENVIRONMENT: ${FLUXER_PUSH_APNS_DEFAULT_ENVIRONMENT:-}
|
||||||
FLUXER_PUSH_FCM_ENABLED: ${FLUXER_PUSH_FCM_ENABLED:-}
|
FLUXER_PUSH_FCM_ENABLED: ${FLUXER_PUSH_FCM_ENABLED:-}
|
||||||
|
|||||||
@@ -928,6 +928,10 @@ Default `push.fluxer.com`. Hostnames, comma separated, of the Fluxer-run push re
|
|||||||
|
|
||||||
Default empty. Hostnames, comma separated, of a push relay this instance runs itself. `push` delivers a browser push endpoint on one of them straight through its own APNs or FCM credentials, with no HTTP hop. Compose forwards it from `.env`.
|
Default empty. Hostnames, comma separated, of a push relay this instance runs itself. `push` delivers a browser push endpoint on one of them straight through its own APNs or FCM credentials, with no HTTP hop. Compose forwards it from `.env`.
|
||||||
|
|
||||||
|
#### `FLUXER_PUSH_SERVICE_PRIVATE_HOSTS`
|
||||||
|
|
||||||
|
Default empty. Hostnames, comma separated, that a push endpoint may use even when they resolve to private, loopback, or other non-public addresses, such as a ntfy server on your own network. `push` refuses every other such endpoint and logs `blocked_address`. Compose forwards it from `.env`.
|
||||||
|
|
||||||
`push` also runs as a relay under `--mode relay`, which the stack does not use. Only that mode reads `FLUXER_PUSH_RELAY_MAX_CONCURRENT`, default `1024`, `FLUXER_PUSH_RELAY_MAX_BODY_BYTES`, default `2816`, `FLUXER_PUSH_RELAY_TRUSTED_PROXY_HOPS`, default `1`, and `FLUXER_PUSH_RELAY_SOURCE_BUCKET_ENABLED`, default `false`. Compose does not forward them, and [Names the stack has no use for](#names-the-stack-has-no-use-for) lists them.
|
`push` also runs as a relay under `--mode relay`, which the stack does not use. Only that mode reads `FLUXER_PUSH_RELAY_MAX_CONCURRENT`, default `1024`, `FLUXER_PUSH_RELAY_MAX_BODY_BYTES`, default `2816`, `FLUXER_PUSH_RELAY_TRUSTED_PROXY_HOPS`, default `1`, and `FLUXER_PUSH_RELAY_SOURCE_BUCKET_ENABLED`, default `false`. Compose does not forward them, and [Names the stack has no use for](#names-the-stack-has-no-use-for) lists them.
|
||||||
|
|
||||||
## Payments
|
## Payments
|
||||||
|
|||||||
@@ -185,6 +185,7 @@ pub struct DeliveryConfig {
|
|||||||
pub apns: Option<ApnsConfig>,
|
pub apns: Option<ApnsConfig>,
|
||||||
pub fcm: Option<FcmConfig>,
|
pub fcm: Option<FcmConfig>,
|
||||||
pub own_relay_hosts: Vec<String>,
|
pub own_relay_hosts: Vec<String>,
|
||||||
|
pub private_hosts: Vec<String>,
|
||||||
pub managed_relay_hosts: Vec<String>,
|
pub managed_relay_hosts: Vec<String>,
|
||||||
pub relay_consent_accepted: bool,
|
pub relay_consent_accepted: bool,
|
||||||
}
|
}
|
||||||
@@ -266,7 +267,8 @@ impl DeliveryConfig {
|
|||||||
vapid: vapid_config(&env)?,
|
vapid: vapid_config(&env)?,
|
||||||
apns: apns_config(&env)?,
|
apns: apns_config(&env)?,
|
||||||
fcm: fcm_config(&env)?,
|
fcm: fcm_config(&env)?,
|
||||||
own_relay_hosts: own_relay_hosts(&env),
|
own_relay_hosts: host_list(&env, "FLUXER_PUSH_SERVICE_OWN_RELAY_HOSTS"),
|
||||||
|
private_hosts: host_list(&env, "FLUXER_PUSH_SERVICE_PRIVATE_HOSTS"),
|
||||||
managed_relay_hosts: managed_relay_hosts(&env),
|
managed_relay_hosts: managed_relay_hosts(&env),
|
||||||
relay_consent_accepted: parse_bool(
|
relay_consent_accepted: parse_bool(
|
||||||
"FLUXER_PUSH_SERVICE_RELAY_CONSENT_ACCEPTED",
|
"FLUXER_PUSH_SERVICE_RELAY_CONSENT_ACCEPTED",
|
||||||
@@ -277,8 +279,8 @@ impl DeliveryConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn own_relay_hosts(env: &Env) -> Vec<String> {
|
fn host_list(env: &Env, key: &str) -> Vec<String> {
|
||||||
env.get("FLUXER_PUSH_SERVICE_OWN_RELAY_HOSTS")
|
env.get(key)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.split(',')
|
.split(',')
|
||||||
.map(|host| host.trim().to_ascii_lowercase())
|
.map(|host| host.trim().to_ascii_lowercase())
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ struct Record {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn seal(state: &AppState, sub: &Subscription, envelope: &Value) -> Result<Record, SendOutcome> {
|
fn seal(state: &AppState, sub: &Subscription, envelope: &Value) -> Result<Record, SendOutcome> {
|
||||||
if !endpoint_is_allowed(&sub.endpoint) {
|
if !endpoint_is_allowed(&sub.endpoint, &state.cfg.private_hosts) {
|
||||||
return Err(SendOutcome::permanent("endpoint_rejected"));
|
return Err(SendOutcome::permanent("endpoint_rejected"));
|
||||||
}
|
}
|
||||||
let (Some(p256dh), Some(auth)) = (sub.p256dh_key.as_deref(), sub.auth_key.as_deref()) else {
|
let (Some(p256dh), Some(auth)) = (sub.p256dh_key.as_deref(), sub.auth_key.as_deref()) else {
|
||||||
@@ -223,7 +223,7 @@ fn classify(status: u16) -> SendOutcome {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn endpoint_is_allowed(endpoint: &str) -> bool {
|
fn endpoint_is_allowed(endpoint: &str, private_hosts: &[String]) -> bool {
|
||||||
let Ok(url) = Url::parse(endpoint) else {
|
let Ok(url) = Url::parse(endpoint) else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
@@ -237,7 +237,9 @@ fn endpoint_is_allowed(endpoint: &str) -> bool {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
match url.host() {
|
match url.host() {
|
||||||
Some(Host::Domain(host)) => is_public_hostname(host),
|
Some(Host::Domain(host)) => {
|
||||||
|
resolver::is_private_host(host, private_hosts) || is_public_hostname(host)
|
||||||
|
}
|
||||||
Some(Host::Ipv4(ip)) => !resolver::is_blocked(IpAddr::V4(ip)),
|
Some(Host::Ipv4(ip)) => !resolver::is_blocked(IpAddr::V4(ip)),
|
||||||
Some(Host::Ipv6(ip)) => !resolver::is_blocked(IpAddr::V6(ip)),
|
Some(Host::Ipv6(ip)) => !resolver::is_blocked(IpAddr::V6(ip)),
|
||||||
None => false,
|
None => false,
|
||||||
|
|||||||
@@ -2,10 +2,13 @@
|
|||||||
|
|
||||||
use reqwest::dns::{Addrs, Name, Resolve, Resolving};
|
use reqwest::dns::{Addrs, Name, Resolve, Resolving};
|
||||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
|
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||||
|
use std::sync::Arc;
|
||||||
use tokio::net::lookup_host;
|
use tokio::net::lookup_host;
|
||||||
|
|
||||||
type ResolveError = Box<dyn std::error::Error + Send + Sync>;
|
type ResolveError = Box<dyn std::error::Error + Send + Sync>;
|
||||||
|
|
||||||
|
pub const BLOCKED_ADDRESS_ERROR: &str = "host resolved into blocked address space";
|
||||||
|
|
||||||
const BLOCKED_V4: &[(Ipv4Addr, u32)] = &[
|
const BLOCKED_V4: &[(Ipv4Addr, u32)] = &[
|
||||||
(Ipv4Addr::new(0, 0, 0, 0), 8),
|
(Ipv4Addr::new(0, 0, 0, 0), 8),
|
||||||
(Ipv4Addr::new(10, 0, 0, 0), 8),
|
(Ipv4Addr::new(10, 0, 0, 0), 8),
|
||||||
@@ -36,26 +39,42 @@ const BLOCKED_V6: &[(Ipv6Addr, u32)] = &[
|
|||||||
const NAT64_PREFIX: [u8; 4] = [0x00, 0x64, 0xff, 0x9b];
|
const NAT64_PREFIX: [u8; 4] = [0x00, 0x64, 0xff, 0x9b];
|
||||||
const SIXTOFOUR_PREFIX: [u8; 2] = [0x20, 0x02];
|
const SIXTOFOUR_PREFIX: [u8; 2] = [0x20, 0x02];
|
||||||
|
|
||||||
pub struct PublicOnlyResolver;
|
pub struct PublicOnlyResolver {
|
||||||
|
private_hosts: Arc<[String]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PublicOnlyResolver {
|
||||||
|
pub fn new(private_hosts: &[String]) -> Self {
|
||||||
|
Self {
|
||||||
|
private_hosts: private_hosts.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Resolve for PublicOnlyResolver {
|
impl Resolve for PublicOnlyResolver {
|
||||||
fn resolve(&self, name: Name) -> Resolving {
|
fn resolve(&self, name: Name) -> Resolving {
|
||||||
let host = name.as_str().to_owned();
|
let host = name.as_str().to_owned();
|
||||||
Box::pin(async move { resolve_public(&host).await })
|
let allow_private = is_private_host(&host, &self.private_hosts);
|
||||||
|
Box::pin(async move {
|
||||||
|
let resolved: Vec<SocketAddr> = lookup_host((host.as_str(), 0)).await?.collect();
|
||||||
|
screen(resolved, allow_private)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn resolve_public(host: &str) -> Result<Addrs, ResolveError> {
|
pub fn is_private_host(host: &str, private_hosts: &[String]) -> bool {
|
||||||
let resolved: Vec<SocketAddr> = lookup_host((host, 0)).await?.collect();
|
let host = host.strip_suffix('.').unwrap_or(host);
|
||||||
screen(resolved)
|
private_hosts
|
||||||
|
.iter()
|
||||||
|
.any(|private| private.eq_ignore_ascii_case(host))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn screen(resolved: Vec<SocketAddr>) -> Result<Addrs, ResolveError> {
|
fn screen(resolved: Vec<SocketAddr>, allow_private: bool) -> Result<Addrs, ResolveError> {
|
||||||
if resolved.is_empty() {
|
if resolved.is_empty() {
|
||||||
return Err("host resolved to no addresses".into());
|
return Err("host resolved to no addresses".into());
|
||||||
}
|
}
|
||||||
if resolved.iter().any(|addr| is_blocked(addr.ip())) {
|
if !allow_private && resolved.iter().any(|addr| is_blocked(addr.ip())) {
|
||||||
return Err("host resolved into blocked address space".into());
|
return Err(BLOCKED_ADDRESS_ERROR.into());
|
||||||
}
|
}
|
||||||
Ok(Box::new(resolved.into_iter()))
|
Ok(Box::new(resolved.into_iter()))
|
||||||
}
|
}
|
||||||
@@ -122,3 +141,26 @@ fn embedded_v4(ip: Ipv6Addr) -> Option<Ipv4Addr> {
|
|||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn lan_address() -> Vec<SocketAddr> {
|
||||||
|
vec![SocketAddr::from(([192, 168, 1, 20], 0))]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_private_address_is_refused_by_default() {
|
||||||
|
let error = screen(lan_address(), false).err().unwrap();
|
||||||
|
assert_eq!(error.to_string(), BLOCKED_ADDRESS_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_listed_private_host_may_resolve_to_a_private_address() {
|
||||||
|
let hosts = vec!["ntfy.example.com".to_owned()];
|
||||||
|
assert!(is_private_host("NTFY.example.com.", &hosts));
|
||||||
|
assert!(!is_private_host("other.example.com", &hosts));
|
||||||
|
assert!(screen(lan_address(), true).is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ impl AppState {
|
|||||||
rpc: RpcClient::new(&cfg.rpc, http.clone(), Arc::clone(&metrics)),
|
rpc: RpcClient::new(&cfg.rpc, http.clone(), Arc::clone(&metrics)),
|
||||||
relay_consent,
|
relay_consent,
|
||||||
sidecar: Arc::new(Sidecar::new(Arc::clone(&metrics))),
|
sidecar: Arc::new(Sidecar::new(Arc::clone(&metrics))),
|
||||||
web_push_http: vendor::web_push_http_client()?,
|
web_push_http: vendor::web_push_http_client(&cfg.private_hosts)?,
|
||||||
apns_http: vendor::apns_http_client()?,
|
apns_http: vendor::apns_http_client()?,
|
||||||
tokens: TokenCache::new(),
|
tokens: TokenCache::new(),
|
||||||
draining: watch::Sender::new(false),
|
draining: watch::Sender::new(false),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
use crate::config::{ApnsConfig, FcmConfig, ProviderEnvironment};
|
use crate::config::{ApnsConfig, FcmConfig, ProviderEnvironment};
|
||||||
use crate::metrics::Metrics;
|
use crate::metrics::Metrics;
|
||||||
use crate::resolver::PublicOnlyResolver;
|
use crate::resolver::{BLOCKED_ADDRESS_ERROR, PublicOnlyResolver};
|
||||||
use crate::tokens::{TokenCache, TokenError};
|
use crate::tokens::{TokenCache, TokenError};
|
||||||
use reqwest::header::{AUTHORIZATION, CONTENT_TYPE};
|
use reqwest::header::{AUTHORIZATION, CONTENT_TYPE};
|
||||||
use reqwest::redirect::Policy;
|
use reqwest::redirect::Policy;
|
||||||
@@ -30,10 +30,10 @@ pub fn http_client() -> reqwest::Result<reqwest::Client> {
|
|||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn web_push_http_client() -> reqwest::Result<reqwest::Client> {
|
pub fn web_push_http_client(private_hosts: &[String]) -> reqwest::Result<reqwest::Client> {
|
||||||
reqwest::Client::builder()
|
reqwest::Client::builder()
|
||||||
.redirect(Policy::none())
|
.redirect(Policy::none())
|
||||||
.dns_resolver(PublicOnlyResolver)
|
.dns_resolver(PublicOnlyResolver::new(private_hosts))
|
||||||
.connect_timeout(CONNECT_TIMEOUT)
|
.connect_timeout(CONNECT_TIMEOUT)
|
||||||
.timeout(HTTP_TIMEOUT)
|
.timeout(HTTP_TIMEOUT)
|
||||||
.build()
|
.build()
|
||||||
@@ -65,6 +65,7 @@ pub enum VendorOutcome {
|
|||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub enum Unreachable {
|
pub enum Unreachable {
|
||||||
|
BlockedAddress,
|
||||||
Dns,
|
Dns,
|
||||||
Transport,
|
Transport,
|
||||||
}
|
}
|
||||||
@@ -72,17 +73,20 @@ pub enum Unreachable {
|
|||||||
impl Unreachable {
|
impl Unreachable {
|
||||||
pub fn label(self) -> &'static str {
|
pub fn label(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
|
Self::BlockedAddress => "blocked_address",
|
||||||
Self::Dns => "dns",
|
Self::Dns => "dns",
|
||||||
Self::Transport => "transport",
|
Self::Transport => "transport",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_permanent(self) -> bool {
|
pub fn is_permanent(self) -> bool {
|
||||||
matches!(self, Self::Dns)
|
matches!(self, Self::BlockedAddress | Self::Dns)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn of(error: &reqwest::Error) -> Self {
|
pub fn of(error: &reqwest::Error) -> Self {
|
||||||
if names_no_host(error) {
|
if source_mentions(error, BLOCKED_ADDRESS_ERROR) {
|
||||||
|
Self::BlockedAddress
|
||||||
|
} else if source_mentions(error, DNS_ERROR_MARKER) {
|
||||||
Self::Dns
|
Self::Dns
|
||||||
} else {
|
} else {
|
||||||
Self::Transport
|
Self::Transport
|
||||||
@@ -90,10 +94,10 @@ impl Unreachable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn names_no_host(error: &reqwest::Error) -> bool {
|
fn source_mentions(error: &reqwest::Error, marker: &str) -> bool {
|
||||||
let mut current = std::error::Error::source(error);
|
let mut current = std::error::Error::source(error);
|
||||||
while let Some(error) = current {
|
while let Some(error) = current {
|
||||||
if error.to_string().contains(DNS_ERROR_MARKER) {
|
if error.to_string().contains(marker) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
current = error.source();
|
current = error.source();
|
||||||
@@ -320,6 +324,29 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_host_in_blocked_address_space_is_named_as_such() {
|
||||||
|
let error = web_push_http_client(&[])
|
||||||
|
.expect("the http client builds")
|
||||||
|
.post("https://localhost/push")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("the request cannot complete");
|
||||||
|
assert_eq!(Unreachable::of(&error), Unreachable::BlockedAddress);
|
||||||
|
assert!(Unreachable::of(&error).is_permanent());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_listed_private_host_gets_through_to_connect() {
|
||||||
|
let error = web_push_http_client(&["localhost".to_owned()])
|
||||||
|
.expect("the http client builds")
|
||||||
|
.post("https://localhost:1/push")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("the request cannot complete");
|
||||||
|
assert_eq!(Unreachable::of(&error), Unreachable::Transport);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn a_refused_connection_stays_retryable() {
|
async fn a_refused_connection_stays_retryable() {
|
||||||
let error = error_for("http://127.0.0.1:1/").await;
|
let error = error_for("http://127.0.0.1:1/").await;
|
||||||
|
|||||||
Reference in New Issue
Block a user