Skip to content

Commit 92f0f3e

Browse files
chore: add wildcard trusted proxies
Signed-off-by: Henry <mail@henrygressmann.de>
1 parent 710f812 commit 92f0f3e

6 files changed

Lines changed: 133 additions & 11 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,19 @@ Since this is not a library, this changelog focuses on the changes that are rele
2222

2323
### ⚠️ Breaking Changes
2424

25-
- Client IP detection now defaults to `X-Forwarded-For` from IPv4 and IPv6 loopback proxies only. Other headers and proxies must be configured with the `trusted_headers` and `trusted_proxies` options. See the [configuration reference](https://liwan.dev/reference/configuration/) for details.
25+
Client IP detection now defaults to `X-Forwarded-For` from IPv4 and IPv6 loopback proxies only. Use `trusted_headers` (`LIWAN_TRUSTED_HEADERS`) to configure different headers and `trusted_proxies` (`LIWAN_TRUSTED_PROXIES`) to configure other proxy addresses or networks.
26+
27+
For Docker deployments where the reverse proxy connects to Liwan through a Docker bridge, add the bridge subnet to `trusted_proxies`, for example `trusted_proxies = ["172.17.0.0/16"]`. If only trusted systems can connect to Liwan over an isolated network, you can set `trusted_proxies = "*"` instead.
28+
29+
Without this configuration, forwarded client addresses from these proxies are ignored. This affects visitor grouping, GeoIP lookup, and rate limits for login and event requests. See the [configuration reference](https://liwan.dev/reference/configuration/) for details.
2630

2731
### Features
2832

2933
- Added external authentication with OpenID Connect, Google, and Microsoft Entra ID, including optional user creation and provider access restrictions
3034
- Improved visit duration accuracy by tracking page exits
3135
- Added client IP header presets for Akamai, Cloudflare, CloudFront, Fastly, and Fly
3236
- Added GeoIP header mappings and presets for Akamai, Cloudflare, CloudFront, Netlify, and Vercel, with MaxMind results taking precedence when available
37+
- Added `*` support for `trusted_proxies` to trust forwarding headers from any direct peer
3338
- Tracker requests now avoid CORS preflight requests by sending JSON as `text/plain` and the event API accepts tracker JSON regardless of content type
3439

3540
### Security

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
![GitHub Release](https://img.shields.io/github/v/release/explodingcamera/liwan?style=flat-square)
1212
[![Container](https://img.shields.io/badge/Container-ghcr.io%2Fexplodingcamera%2Fliwan%3Alatest-blue?style=flat-square)](https://github.com/explodingcamera/liwan/pkgs/container/liwan)
1313

14+
[Live demo](https://demo.liwan.dev/p/liwan.dev) · [Getting started](https://liwan.dev/getting-started/) · [Configuration](https://liwan.dev/reference/configuration/) · [Collected data](https://liwan.dev/collected-data/)
15+
1416
</div>
1517

1618
</div>

‎data/config.example.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ listen = 9042 # The port to listen on (http)
1515
# trusted_headers = ["X-Forwarded-For"]
1616

1717
## Trusted reverse proxies (IP or CIDR), defaults to the IPv4 and IPv6 loopback ranges.
18+
## Use "*" to trust all direct peers only when network access to liwan is restricted.
1819
# trusted_proxies = ["127.0.0.1/8", "::1/128"]
1920

2021
## Hour of day, in the server's local timezone, when visitor grouping salts rotate.

‎src/config.rs‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,17 @@ mod test {
475475
assert_eq!(config.trusted_headers.as_ref(), &[ClientIpHeaderSource::Header("x-client-ip".to_string())]);
476476
}
477477

478+
#[test]
479+
fn test_wildcard_trusted_proxy() {
480+
let env_config = Config::load(None, [("LIWAN_TRUSTED_PROXIES", "*")]).expect("failed to load config");
481+
assert_eq!(env_config.trusted_proxies.as_ref(), &[TrustedProxy::All]);
482+
483+
let (_temp_dir, config_path) = temp_config("wildcard-proxy.config.toml", "trusted_proxies = \"*\"");
484+
let file_config =
485+
Config::load(Some(config_path), Vec::<(String, String)>::new()).expect("failed to load config");
486+
assert_eq!(file_config.trusted_proxies.as_ref(), &[TrustedProxy::All]);
487+
}
488+
478489
#[test]
479490
fn test_header_presets_and_geoip_mappings() {
480491
let (_temp_dir, config_path) = temp_config(

‎src/utils/ip_headers.rs‎

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,22 +134,47 @@ pub fn parse_geoip_headers(headers: &http::HeaderMap, sources: &[GeoIpHeaderSour
134134
values
135135
}
136136

137-
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
138-
#[serde(untagged)]
137+
#[derive(Debug, Clone, PartialEq, Eq)]
139138
pub enum TrustedProxy {
139+
All,
140140
Ip(IpAddr),
141141
Cidr(IpNet),
142142
}
143143

144144
impl TrustedProxy {
145145
pub fn contains(&self, ip: IpAddr) -> bool {
146146
match self {
147+
TrustedProxy::All => true,
147148
TrustedProxy::Ip(proxy_ip) => *proxy_ip == ip,
148149
TrustedProxy::Cidr(net) => net.contains(&ip),
149150
}
150151
}
151152
}
152153

154+
impl Serialize for TrustedProxy {
155+
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
156+
match self {
157+
Self::All => serializer.serialize_str("*"),
158+
Self::Ip(ip) => serializer.collect_str(ip),
159+
Self::Cidr(network) => serializer.collect_str(network),
160+
}
161+
}
162+
}
163+
164+
impl<'de> Deserialize<'de> for TrustedProxy {
165+
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
166+
let value = String::deserialize(deserializer)?;
167+
let value = value.trim();
168+
if value == "*" {
169+
return Ok(Self::All);
170+
}
171+
if let Ok(ip) = value.parse() {
172+
return Ok(Self::Ip(ip));
173+
}
174+
value.parse().map(Self::Cidr).map_err(serde::de::Error::custom)
175+
}
176+
}
177+
153178
pub fn parse_header_ip(headers: &http::HeaderMap, source: &ClientIpHeaderSource) -> Option<IpAddr> {
154179
let header = source.as_header_name();
155180
let value = headers.get(header)?.to_str().ok()?.trim();
@@ -258,6 +283,15 @@ mod tests {
258283
assert!(!should_trust_proxy_headers(Some("10.0.0.2".parse().unwrap()), &[]));
259284
}
260285

286+
#[test]
287+
fn wildcard_trusts_all_proxy_addresses() {
288+
let proxy: TrustedProxy = serde_json::from_str(r#""*""#).unwrap();
289+
290+
assert!(proxy.contains("192.0.2.1".parse().unwrap()));
291+
assert!(proxy.contains("2001:db8::1".parse().unwrap()));
292+
assert_eq!(serde_json::to_string(&proxy).unwrap(), r#""*""#);
293+
}
294+
261295
#[test]
262296
fn client_ip_walks_trusted_forwarded_chain() {
263297
let request = http::Request::builder().header("x-forwarded-for", "203.0.113.10, 10.0.0.1").body(()).unwrap();

‎src/web/mod.rs‎

Lines changed: 77 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,14 @@ pub mod webext;
44

55
use std::net::{SocketAddr, ToSocketAddrs};
66
use std::ops::Deref;
7-
use std::sync::Arc;
8-
use std::time::Duration;
7+
use std::sync::{Arc, Mutex};
8+
use std::time::{Duration, Instant};
99

1010
use anyhow::{Context, Result};
11+
use axum::extract::{ConnectInfo, Request, State};
1112
use axum::handler::{Handler, HandlerWithoutStateExt};
13+
use axum::middleware::Next;
14+
use axum::response::Response;
1215
use rust_embed::RustEmbed;
1316

1417
use aide::{axum::ApiRouter, openapi};
@@ -26,6 +29,7 @@ use crate::app::{
2629
Liwan,
2730
models::{Event, EventExit},
2831
};
32+
use crate::utils::ip_headers::should_trust_proxy_headers;
2933
use crate::web::webext::serve;
3034

3135
pub use session::MaybeSessionId;
@@ -45,6 +49,27 @@ pub struct RouterState {
4549
pub events: Sender<Event>,
4650
pub exits: Sender<EventExit>,
4751
pub report_permits: Arc<Semaphore>,
52+
untrusted_proxy_warning: Arc<UntrustedProxyWarning>,
53+
}
54+
55+
const PROXY_WARNING_INTERVAL: Duration = Duration::from_secs(60 * 60);
56+
57+
#[derive(Default)]
58+
struct UntrustedProxyWarning {
59+
last: Mutex<Option<Instant>>,
60+
}
61+
62+
impl UntrustedProxyWarning {
63+
fn should_warn(&self, now: Instant) -> bool {
64+
let Ok(mut last) = self.last.lock() else {
65+
return false;
66+
};
67+
if last.is_some_and(|last| now.duration_since(last) < PROXY_WARNING_INTERVAL) {
68+
return false;
69+
}
70+
*last = Some(now);
71+
true
72+
}
4873
}
4974

5075
/// Event ingestion queues used by the web server.
@@ -90,6 +115,32 @@ impl Deref for RouterState {
90115
}
91116
}
92117

118+
async fn warn_untrusted_proxy_headers(State(state): State<RouterState>, request: Request, next: Next) -> Response {
119+
let peer_ip = request.extensions().get::<ConnectInfo<SocketAddr>>().map(|ConnectInfo(addr)| addr.ip());
120+
let is_private = peer_ip.is_some_and(|ip| {
121+
ip.is_loopback()
122+
|| match ip {
123+
std::net::IpAddr::V4(ip) => ip.is_private() || ip.is_link_local(),
124+
std::net::IpAddr::V6(ip) => ip.is_unique_local() || ip.is_unicast_link_local(),
125+
}
126+
});
127+
let has_client_ip_header =
128+
state.config.trusted_headers.iter().any(|source| request.headers().contains_key(source.as_header_name()));
129+
130+
if is_private
131+
&& has_client_ip_header
132+
&& !should_trust_proxy_headers(peer_ip, &state.config.trusted_proxies)
133+
&& state.untrusted_proxy_warning.should_warn(Instant::now())
134+
{
135+
tracing::warn!(
136+
peer_ip = %peer_ip.expect("private peer IP is present"),
137+
"Ignoring client IP headers from an untrusted private proxy. Add its address or network to `trusted_proxies` or `LIWAN_TRUSTED_PROXIES` if it should be trusted"
138+
);
139+
}
140+
141+
next.run(request).await
142+
}
143+
93144
pub fn router(app: Arc<Liwan>, queues: EventQueues) -> Result<(axum::Router<()>, openapi::OpenApi)> {
94145
aide::generate::in_context(|ctx| {
95146
ctx.schema = ctx.schema.settings().clone().with_transform(WrapSchemaRefs).into_generator();
@@ -143,6 +194,13 @@ pub fn router(app: Arc<Liwan>, queues: EventQueues) -> Result<(axum::Router<()>,
143194
.merge(routes::external_auth::router(&app.config))
144195
.merge(routes::dashboard::router());
145196

197+
let state = RouterState {
198+
app: app.clone(),
199+
events: queues.events,
200+
exits: queues.exits,
201+
report_permits: Arc::new(Semaphore::new(app.config.limits.report_max_concurrency)),
202+
untrusted_proxy_warning: Arc::new(UntrustedProxyWarning::default()),
203+
};
146204
let router = ApiRouter::new()
147205
.nest("/api", routes::event::router(&app.config).layer(event_cors))
148206
.nest("/api/dashboard", dashboard)
@@ -152,12 +210,8 @@ pub fn router(app: Arc<Liwan>, queues: EventQueues) -> Result<(axum::Router<()>,
152210
.layer(CompressionLayer::new())
153211
.layer(set_headers)
154212
.layer(TraceLayer::new_for_http())
155-
.with_state(RouterState {
156-
app: app.clone(),
157-
events: queues.events,
158-
exits: queues.exits,
159-
report_permits: Arc::new(Semaphore::new(app.config.limits.report_max_concurrency)),
160-
})
213+
.layer(axum::middleware::from_fn_with_state(state.clone(), warn_untrusted_proxy_headers))
214+
.with_state(state)
161215
.finish_api(&mut api);
162216

163217
Ok((router, api))
@@ -212,3 +266,18 @@ pub async fn start_webserver(app: Arc<Liwan>, queues: EventQueues) -> Result<()>
212266
let service = router.0.into_make_service_with_connect_info::<SocketAddr>();
213267
axum::serve(listener, service).await.context("server exited unexpectedly")
214268
}
269+
270+
#[cfg(test)]
271+
mod tests {
272+
use super::*;
273+
274+
#[test]
275+
fn untrusted_proxy_warning_is_rate_limited() {
276+
let warning = UntrustedProxyWarning::default();
277+
let now = Instant::now();
278+
279+
assert!(warning.should_warn(now));
280+
assert!(!warning.should_warn(now + PROXY_WARNING_INTERVAL - Duration::from_secs(1)));
281+
assert!(warning.should_warn(now + PROXY_WARNING_INTERVAL));
282+
}
283+
}

0 commit comments

Comments
 (0)