diff --git a/Cargo.lock b/Cargo.lock index b3225dfb..e53dfca9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4917,6 +4917,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.18", + "trusted-proxies", "url", ] diff --git a/knot2/README.md b/knot2/README.md index 30ed55ed..56127308 100644 --- a/knot2/README.md +++ b/knot2/README.md @@ -193,7 +193,7 @@ That talks to the knot by container name, which needs both containers on a singl Set `xrpc.trusted_proxy_header = "x-forwarded-for"` when doing this, otherwise every client looks like it comes from the proxy and the ratelimiter wil treat them as one very busy mister. Only set it behind a proxy the operator controls, since a direct client can like, invent that header. -Add `xrpc.trusted_proxies = ["fd00:1::4", "10.89.0.4"]` for example, one entry per address that the proxy connects from, so the knot honors that header from the proxy alone & ratelimits anyone else by the address they connected from. +Add `xrpc.trusted_proxies = ["fd00:1::4", "10.89.0.4"]` for example, one entry per address that the proxy connects from, so the knot honors that header from the proxy alone & ratelimits anyone else by the address they connected from. A CIDR block will work too, `["173.245.48.0/20"]` covers a whole provider's edge. If several proxies you control are in the path, list them all. Mister knot will read the chain right -> left, iterate over every entry the list covers, and take the first entry it doesn't. It will read 32 entries at most, and the knot will ratelimit by the address the request connected from when the list covers all 32. The knot can also terminate TLS itself (and that's the only way to get its HTTP3 support) because a plain TCP frontend can't proxy QUIC. Using a certificate the operator already manages: diff --git a/knot2/crates/knot-config/src/lib.rs b/knot2/crates/knot-config/src/lib.rs index b5adee8a..d1f4cdfe 100644 --- a/knot2/crates/knot-config/src/lib.rs +++ b/knot2/crates/knot-config/src/lib.rs @@ -1,5 +1,5 @@ use std::fmt; -use std::net::{AddrParseError, IpAddr, SocketAddr}; +use std::net::SocketAddr; use std::path::{Path, PathBuf}; use std::sync::OnceLock; use std::time::Duration; @@ -7,7 +7,9 @@ use std::time::Duration; use base64::Engine; use confique::Config; use knot_runtime::HttpLimits; -use knot_types::{AccountDid, AdmissionPolicy, AppviewEndpoint}; +use knot_types::{ + AccountDid, AdmissionPolicy, AppviewEndpoint, ProxyNetError, TrustedProxies, comma_separated, +}; use url::Url; #[derive(Debug, Config)] @@ -300,27 +302,37 @@ pub struct XrpcConfig { pub fork_fetch_timeout_ms: u64, /// When the knot runs behind a trusted reverse proxy that terminates TLS, - /// set this to the header the proxy appends the client address to, for - /// example x-forwarded-for. The rightmost entry is used. Leave unset when - /// the knot is directly exposed so the socket peer address is used. Only set - /// this when a trusted proxy overwrites or appends the header, since a client - /// can forge it otherwise. + /// set this to the header the proxy appends the client address to, + /// for example x-forwarded-for. + /// The knot will read the chain right -> left + /// and take the first entry that `trusted_proxies` doesn't cover. + /// Leave unset when the knot is directly exposed so the socket peer address is used. + /// Only set this when a trusted proxy overwrites or appends the header, + /// since a client can forge it otherwise. #[config(env = "KNOT_XRPC_TRUSTED_PROXY_HEADER")] pub trusted_proxy_header: Option, - /// IP addresses whose `trusted_proxy_header` the knot honors, - /// without a port, + /// Addresses whose `trusted_proxy_header` the knot honors, + /// each a bare IP without a port or a CIDR block such as 173.245.48.0/20, /// for ex the loopback address of a reverse proxy on the same host. - /// The knot rate-limits a request from any other address - /// by its own socket address and ignores the header. - /// Leave empty to honor the header from every peer, - /// which is safe *only* if nothing but the proxy can reach this knot. + /// The knot will rate-limit a request from any other address + /// by its own socket address and ignore the header. + /// These same addresses are hops the knot will iterate over when it reads + /// the header, so list every proxy you control in the path. + /// A proxy that the knot doesn't know about becomes the entry it keys on, + /// and everyone that proxy serves will then share one rate-limit bucket. + /// The knot will read the last 32 entries of the chain, at most. + /// When the list covers all 32, the knot + /// will rate-limit by the address the request connected from. + /// Leave empty to honor the header from every peer and take its rightmost + /// entry, which is safe *only* while every route to this knot passes + /// through the proxy. #[config( env = "KNOT_XRPC_TRUSTED_PROXIES", - parse_env = parse_trusted_proxies, + parse_env = comma_separated, default = [] )] - pub trusted_proxies: Vec, + pub trusted_proxies: Vec, #[config(env = "KNOT_XRPC_EVENTS_REPLAY_BUFFER", default = 4096)] pub events_replay_buffer: u32, @@ -439,19 +451,15 @@ fn parse_admins(raw: &str) -> Result, knot_types::ParseError> { .collect() } -fn parse_trusted_proxies(raw: &str) -> Result, AddrParseError> { - raw.split(',') - .map(str::trim) - .filter(|item| !item.is_empty()) - .map(str::parse) - .collect() -} - impl KnotConfig { pub fn object_format(&self) -> Option { knot_types::ObjectFormat::from_capability(&self.git.object_format) } + pub fn trusted_proxies(&self) -> Result { + TrustedProxies::parse(self.xrpc.trusted_proxies.iter().map(String::as_str)) + } + pub fn tls_enabled(&self) -> bool { self.static_cert_enabled() || self.tls.acme_enabled } @@ -834,6 +842,9 @@ impl KnotConfig { self.xrpc.trusted_proxy_header.is_some() || self.xrpc.trusted_proxies.is_empty(), "xrpc.trusted_proxies needs xrpc.trusted_proxy_header, the header the knot honors from those addresses", ), + self.trusted_proxies() + .err() + .map(|error| format!("xrpc.trusted_proxies: {error}")), self.acl .legacy_admin_secret_env .as_deref() @@ -1576,7 +1587,7 @@ mod tests { ), ( "trusted_proxies_without_the_header_the_knot_honors", - |config| config.xrpc.trusted_proxies = vec!["127.0.0.1".parse().unwrap()], + |config| config.xrpc.trusted_proxies = vec!["127.0.0.1".to_owned()], "needs xrpc.trusted_proxy_header", ), ]; @@ -1648,19 +1659,36 @@ mod tests { } #[test] - fn trusted_proxies_parse_from_comma_separated_env() { - assert_eq!( - parse_trusted_proxies("127.0.0.1, ::1").unwrap(), - vec![ - "127.0.0.1".parse::().unwrap(), - "::1".parse::().unwrap() - ] - ); - assert!(parse_trusted_proxies("").unwrap().is_empty()); - assert!( - parse_trusted_proxies("127.0.0.1:5555").is_err(), - "xrpc.trusted_proxies takes bare IP addresses, so a port must fail to parse" - ); + fn a_trusted_proxy_entry_takes_an_address_or_a_cidr_block() { + let listing = |entries: &[&str]| { + let mut config = sample(); + config.xrpc.trusted_proxy_header = Some("x-forwarded-for".to_owned()); + config.xrpc.trusted_proxies = entries.iter().map(|&e| e.to_owned()).collect(); + config + }; + let config = listing(&["127.0.0.1", "173.245.48.0/20", "2400:cb00::/32"]); + assert!(config.validate().is_ok()); + let proxies = config.trusted_proxies().unwrap(); + assert!(proxies.contains("173.245.48.7".parse().unwrap())); + assert!(proxies.contains("2400:cb00::1".parse().unwrap())); + + [ + ( + "127.0.0.1:5555", + "127.0.0.1:5555", + "xrpc.trusted_proxies takes a bare address or a CIDR block, so the failure must quote the rejected entry", + ), + ( + " ", + "blank entry", + "parse refuses a blank in the file instead of reading a list the operator filled in as empty, because the knot honors the header from every peer while the list is empty. `comma_separated` discards the same blank from the env var, since it can't tell that blank from the gap a trailing separator leaves", + ), + ] + .iter() + .for_each(|&(entry, quoted, why)| { + let report = listing(&[entry]).validate().unwrap_err().to_string(); + assert!(report.contains(quoted), "{why}: {report}"); + }); } #[test] diff --git a/knot2/crates/knot-edge/src/robustness.rs b/knot2/crates/knot-edge/src/robustness.rs index 7357d3cb..97fd8fea 100644 --- a/knot2/crates/knot-edge/src/robustness.rs +++ b/knot2/crates/knot-edge/src/robustness.rs @@ -316,7 +316,7 @@ mod tests { fn trusting_loopback() -> ProxyTrust { ProxyTrust::new( Some(forwarded_for()), - knot_types::TrustedProxies::new(["127.0.0.1".parse::().unwrap()]), + knot_types::TrustedProxies::parse(["127.0.0.1"]).unwrap(), ) } diff --git a/knot2/crates/knot-server/src/main.rs b/knot2/crates/knot-server/src/main.rs index b22f80e9..c0243c3d 100644 --- a/knot2/crates/knot-server/src/main.rs +++ b/knot2/crates/knot-server/src/main.rs @@ -290,9 +290,7 @@ async fn main() -> anyhow::Result<()> { .map(|header| axum::http::HeaderName::from_bytes(header.as_bytes())) .transpose() .context("xrpc.trusted_proxy_header isn't a valid HTTP header name")?; - let trusted_proxies = - knot_types::TrustedProxies::new(config.xrpc.trusted_proxies.iter().copied()); - let proxy_trust = knot_types::ProxyTrust::new(trusted_proxy_header, trusted_proxies); + let proxy_trust = knot_types::ProxyTrust::new(trusted_proxy_header, config.trusted_proxies()?); if proxy_trust.trusts_any_peer() && !http_addr.ip().is_loopback() { tracing::warn!( bind = %http_addr, diff --git a/knot2/crates/knot-types/Cargo.toml b/knot2/crates/knot-types/Cargo.toml index 82a40dc6..9cf08553 100644 --- a/knot2/crates/knot-types/Cargo.toml +++ b/knot2/crates/knot-types/Cargo.toml @@ -12,6 +12,7 @@ http = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } +trusted-proxies = { workspace = true } url = { workspace = true } [dev-dependencies] diff --git a/knot2/crates/knot-types/src/lib.rs b/knot2/crates/knot-types/src/lib.rs index 4a51440d..8ab93700 100644 --- a/knot2/crates/knot-types/src/lib.rs +++ b/knot2/crates/knot-types/src/lib.rs @@ -20,7 +20,7 @@ mod hex; pub use hex::{decode_hex, lowercase_hex}; mod net; -pub use net::{PeerKey, ProxyTrust, TrustedProxies}; +pub use net::{PeerKey, ProxyNetError, ProxyTrust, TrustedProxies, comma_separated}; pub use jacquard_common::CowStr; pub use jacquard_common::DefaultStr; diff --git a/knot2/crates/knot-types/src/net.rs b/knot2/crates/knot-types/src/net.rs index a081033b..18219aae 100644 --- a/knot2/crates/knot-types/src/net.rs +++ b/knot2/crates/knot-types/src/net.rs @@ -1,29 +1,7 @@ -use std::collections::BTreeSet; use std::net::IpAddr; use http::{HeaderMap, HeaderName}; - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct TrustedProxies(BTreeSet); - -impl TrustedProxies { - pub fn new(addresses: impl IntoIterator) -> Self { - Self( - addresses - .into_iter() - .map(|peer| peer.to_canonical()) - .collect(), - ) - } - - pub fn trusts(&self, peer: Option) -> bool { - match peer { - _ if self.0.is_empty() => true, - Some(peer) => self.0.contains(&peer.to_canonical()), - None => false, - } - } -} +pub use trusted_proxies::{ProxyNetError, TrustedProxies, comma_separated}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PeerKey { @@ -63,7 +41,15 @@ impl ProxyTrust { } pub fn trusts_any_peer(&self) -> bool { - self.header.is_some() && self.proxies.trusts(None) + self.header.is_some() && self.proxies.is_empty() + } + + fn trusts(&self, socket: Option) -> bool { + match socket { + _ if self.proxies.is_empty() => true, + Some(socket) => self.proxies.contains(socket), + None => false, + } } pub fn peer_key(&self, headers: &HeaderMap, socket: Option) -> PeerKey { @@ -90,27 +76,26 @@ impl ProxyTrust { fn relayed_peer(&self, headers: &HeaderMap, socket: Option) -> Option { self.header .as_ref() - .filter(|_| self.proxies.trusts(socket)) - .and_then(|header| forwarded_peer(headers, header)) + .filter(|_| self.trusts(socket)) + .and_then(|header| { + self.proxies.rightmost_untrusted( + headers + .get_all(header) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(',')), + ) + }) } fn ignores_header_from(&self, headers: &HeaderMap, socket: IpAddr) -> bool { self.header .as_ref() .is_some_and(|header| headers.contains_key(header)) - && !self.proxies.trusts(Some(socket)) + && !self.trusts(Some(socket)) } } -fn forwarded_peer(headers: &HeaderMap, header: &HeaderName) -> Option { - headers - .get(header) - .and_then(|value| value.to_str().ok()) - .and_then(|value| value.rsplit(',').next()) - .map(str::trim) - .and_then(|candidate| candidate.parse::().ok()) -} - #[cfg(test)] mod tests { use super::*; @@ -133,53 +118,85 @@ mod tests { value.parse().unwrap() } + fn trusting<'a>(entries: impl IntoIterator) -> TrustedProxies { + TrustedProxies::parse(entries).unwrap() + } + + fn relaying<'a>(entries: impl IntoIterator) -> ProxyTrust { + ProxyTrust::new(Some(forwarded_for()), trusting(entries)) + } + #[test] - fn forwarded_peer_takes_the_rightmost_parseable_entry() { + fn the_knot_reads_the_rightmost_entry_from_any_peer_with_an_empty_allowlist() { + let anyone = ProxyTrust::new(Some(forwarded_for()), TrustedProxies::default()); [ - (Some("203.0.113.7, 198.51.100.4"), Some("198.51.100.4")), - (Some(" 192.0.2.1 "), Some("192.0.2.1")), - (Some("not-an-ip"), None), - (None, None), + (Some("203.0.113.7, 198.51.100.4"), "198.51.100.4"), + (Some(" 192.0.2.1 "), "192.0.2.1"), + (Some("not-an-ip"), "192.0.2.9"), + (None, "192.0.2.9"), ] .iter() .for_each(|&(header, expected)| { assert_eq!( - forwarded_peer(&headers(header), &forwarded_for()), - expected.map(ip), + anyone.client_peer_of(&headers(header), ip("192.0.2.9")), + ip(expected), "{header:?}" ); }); } #[test] - fn an_empty_allowlist_trusts_every_peer() { - let anyone = TrustedProxies::default(); - assert!(anyone.trusts(Some(ip("203.0.113.7")))); - assert!(anyone.trusts(None)); + fn the_knot_joins_every_line_of_a_repeated_header_into_one_chain() { + let mut map = HeaderMap::new(); + map.append(forwarded_for(), "203.0.113.7".parse().unwrap()); + map.append(forwarded_for(), "198.51.100.4".parse().unwrap()); + assert_eq!( + relaying(["127.0.0.1"]).client_peer(&map, Some(ip("127.0.0.1"))), + Some(ip("198.51.100.4")), + "a proxy that appends a second header line puts the address we want in the last line" + ); } #[test] fn the_header_applies_only_to_a_peer_on_the_allowlist() { - let proxy = ip("127.0.0.1"); - let forged = headers(Some("198.51.100.4")); - let trust = ProxyTrust::new(Some(forwarded_for()), TrustedProxies::new([proxy])); - let peer = |socket| trust.client_peer(&forged, Some(socket)); - - assert_eq!( - peer(proxy), - Some(ip("198.51.100.4")), - "a request relayed by the listed proxy is limited by the address the proxy recorded" - ); + let relayed = headers(Some("198.51.100.4")); + [ + (&["127.0.0.1"][..], "127.0.0.1", "198.51.100.4", + "a request relayed by the listed proxy is limited by the address the proxy recorded"), + (&["127.0.0.1"], "203.0.113.7", "203.0.113.7", + "a client reaching the knot directly forged the header and must answer for its socket"), + (&["127.0.0.1", "::1"], "::1", "198.51.100.4", + "a second listed entry relays as readily as the first"), + (&["127.0.0.1"], "::ffff:127.0.0.1", "198.51.100.4", + "binding [::] turns an IPv4 proxy into ::ffff:127.0.0.1 and the allowlist must still match it"), + (&["::ffff:127.0.0.1"], "127.0.0.1", "198.51.100.4", + "an operator who writes the mapped form must match a plain IPv4 peer too"), + (&["127.0.0.1"], "::1", "::1", + "that peer answers for the socket it connected from, since the IPv6 loopback is a different address from the IPv4 loopback"), + (&["127.0.0.1"], "::ffff:203.0.113.7", "203.0.113.7", + "an ignored header still keys the peer on its socket, canonical so the warning and the bucket agree"), + ] + .iter() + .for_each(|&(listed, socket, expected, why)| { + assert_eq!( + relaying(listed.iter().copied()).client_peer(&relayed, Some(ip(socket))), + Some(ip(expected)), + "{why}: {listed:?} saw {socket}" + ); + }); assert_eq!( - peer(ip("203.0.113.7")), - Some(ip("203.0.113.7")), - "a client reaching the knot directly forged the header and must answer for its socket" + relaying(["127.0.0.1"]).client_peer( + &headers(Some("203.0.113.7, not-an-ip")), + Some(ip("127.0.0.1")) + ), + Some(ip("127.0.0.1")), + "the knot keys on the listed proxy's own socket when it can't read past the chain" ); } #[test] fn a_caller_with_a_socket_address_gets_the_same_answer_without_an_option() { - let listed = TrustedProxies::new([ip("127.0.0.1")]); + let listed = trusting(["127.0.0.1"]); [ (ProxyTrust::default(), Some("198.51.100.4")), ( @@ -207,24 +224,7 @@ mod tests { } #[test] - fn a_listed_ipv4_proxy_still_matches_the_v4_mapped_address_a_dual_stack_listener_reports() { - let mapped = ip("::ffff:127.0.0.1"); - assert!( - TrustedProxies::new([ip("127.0.0.1")]).trusts(Some(mapped)), - "binding [::] turns an IPv4 proxy into ::ffff:127.0.0.1 and the allowlist must still match it" - ); - assert!( - TrustedProxies::new([mapped]).trusts(Some(ip("127.0.0.1"))), - "an operator who writes the mapped form must match a plain IPv4 peer too" - ); - assert!( - !TrustedProxies::new([ip("127.0.0.1")]).trusts(Some(ip("::1"))), - "the IPv6 loopback is a different address from the IPv4 one" - ); - } - - #[test] - fn client_peer_falls_back_to_the_socket_whenever_no_header_applies() { + fn client_peer_falls_back_to_the_socket_whenever_the_header_doesnt_apply() { let socket = ip("203.0.113.7"); [ (None, Some("198.51.100.4")), @@ -239,47 +239,26 @@ mod tests { Some(socket), "{header_name:?} with {header_value:?}" ); + assert_eq!( + trust.client_peer(&headers(header_value), Some(ip("::ffff:203.0.113.7"))), + Some(socket), + "a v4-mapped socket and the plain v4 address are one client, so they share a key" + ); }); } - #[test] - fn one_address_gets_one_bucket_however_the_listener_spelled_it() { - let trust = ProxyTrust::default(); - assert_eq!( - trust.client_peer(&headers(None), Some(ip("::ffff:203.0.113.7"))), - trust.client_peer(&headers(None), Some(ip("203.0.113.7"))), - "a v4-mapped socket and the plain v4 address are one client, so they share a key" - ); - } - - #[test] - fn client_peer_reports_no_peer_when_an_allowlist_leaves_it_with_neither_source() { - let trust = ProxyTrust::new( - Some(forwarded_for()), - TrustedProxies::new([ip("127.0.0.1")]), - ); - assert_eq!( - trust.client_peer(&headers(Some("198.51.100.4")), None), - None, - "with no socket to check against the allowlist there is no client to key on" - ); - } - #[test] fn the_peer_key_separates_an_ignored_header_from_a_request_that_never_sent_one() { - let listed = ProxyTrust::new( - Some(forwarded_for()), - TrustedProxies::new([ip("127.0.0.1")]), - ); + let listed = ProxyTrust::new(Some(forwarded_for()), trusting(["127.0.0.1"])); assert_eq!( listed.peer_key(&headers(Some("198.51.100.4")), Some(ip("203.0.113.7"))), PeerKey::SocketWithIgnoredHeader(ip("203.0.113.7")), - "an unlisted peer sent the header, which is the address an operator has to see" + "an operator has to see the address of an unlisted peer that sent the header" ); assert_eq!( listed.peer_key(&headers(None), Some(ip("203.0.113.7"))), PeerKey::Socket(ip("203.0.113.7")), - "a request without the header says nothing about the allowlist" + "the allowlist stays untested when a request arrives without the header" ); assert_eq!( listed.peer_key(&headers(Some("198.51.100.4")), Some(ip("127.0.0.1"))), @@ -290,10 +269,15 @@ mod tests { listed.peer_key(&headers(Some("198.51.100.4")), None), PeerKey::Unidentified ); + assert_eq!( + listed.client_peer(&headers(Some("198.51.100.4")), None), + None, + "the knot won't key on a client until it has a socket to check against the allowlist" + ); } #[test] - fn only_an_ignored_header_reports_an_address_to_warn_about() { + fn only_an_ignored_header_has_an_address_to_warn_about() { assert_eq!( PeerKey::SocketWithIgnoredHeader(ip("203.0.113.7")).ignored_header(), Some(ip("203.0.113.7")) @@ -308,47 +292,14 @@ mod tests { assert_eq!( key.ignored_header(), None, - "{key:?} is not a misconfigured allowlist" + "{key:?} isn't a misconfigured allowlist" ); }); } #[test] - fn an_ignored_header_still_keys_the_peer_on_its_socket() { - let listed = ProxyTrust::new( - Some(forwarded_for()), - TrustedProxies::new([ip("127.0.0.1")]), - ); - let forged = headers(Some("198.51.100.4")); - assert_eq!( - listed.client_peer(&forged, Some(ip("203.0.113.7"))), - Some(ip("203.0.113.7")) - ); - assert_eq!( - listed.client_peer_of(&forged, ip("::ffff:203.0.113.7")), - ip("203.0.113.7"), - "the reported address stays canonical so the warning and the bucket agree" - ); - } - - #[test] - fn a_populated_allowlist_trusts_only_the_addresses_it_lists() { - let proxies = TrustedProxies::new([ip("127.0.0.1"), ip("::1")]); - assert!(proxies.trusts(Some(ip("127.0.0.1")))); - assert!(proxies.trusts(Some(ip("::1")))); - assert!( - !proxies.trusts(Some(ip("203.0.113.7"))), - "a client reaching the knot directly would pick its own rate-limit bucket" - ); - assert!( - !proxies.trusts(None), - "a peer of None has no address to match against the list" - ); - } - - #[test] - fn only_a_header_without_an_allowlist_trusts_any_peer() { - let listed = TrustedProxies::new([ip("127.0.0.1")]); + fn only_a_header_without_an_allowlist_makes_the_knot_trust_any_peer() { + let listed = trusting(["127.0.0.1"]); assert!( ProxyTrust::new(Some(forwarded_for()), TrustedProxies::default()).trusts_any_peer() ); diff --git a/knot2/example.toml b/knot2/example.toml index ab2088d1..9d8482e3 100644 --- a/knot2/example.toml +++ b/knot2/example.toml @@ -264,22 +264,32 @@ #fork_fetch_timeout_ms = 600000 # When the knot runs behind a trusted reverse proxy that terminates TLS, -# set this to the header the proxy appends the client address to, for -# example x-forwarded-for. The rightmost entry is used. Leave unset when -# the knot is directly exposed so the socket peer address is used. Only set -# this when a trusted proxy overwrites or appends the header, since a client -# can forge it otherwise. +# set this to the header the proxy appends the client address to, +# for example x-forwarded-for. +# The knot will read the chain right -> left +# and take the first entry that `trusted_proxies` doesn't cover. +# Leave unset when the knot is directly exposed so the socket peer address is used. +# Only set this when a trusted proxy overwrites or appends the header, +# since a client can forge it otherwise. # # Can also be specified via environment variable `KNOT_XRPC_TRUSTED_PROXY_HEADER`. #trusted_proxy_header = -# IP addresses whose `trusted_proxy_header` the knot honors, -# without a port, +# Addresses whose `trusted_proxy_header` the knot honors, +# each a bare IP without a port or a CIDR block such as 173.245.48.0/20, # for ex the loopback address of a reverse proxy on the same host. -# The knot rate-limits a request from any other address -# by its own socket address and ignores the header. -# Leave empty to honor the header from every peer, -# which is safe *only* if nothing but the proxy can reach this knot. +# The knot will rate-limit a request from any other address +# by its own socket address and ignore the header. +# These same addresses are hops the knot will iterate over when it reads +# the header, so list every proxy you control in the path. +# A proxy that the knot doesn't know about becomes the entry it keys on, +# and everyone that proxy serves will then share one rate-limit bucket. +# The knot will read the last 32 entries of the chain, at most. +# When the list covers all 32, the knot +# will rate-limit by the address the request connected from. +# Leave empty to honor the header from every peer and take its rightmost +# entry, which is safe *only* while every route to this knot passes +# through the proxy. # # Can also be specified via environment variable `KNOT_XRPC_TRUSTED_PROXIES`. #