Skip to content

Commit e7a50bd

Browse files
committed
feat: add configurable ip_source for spoofing-resistant client IP
Introduces ip_source, an optional typed enum mapping to SecureClientIpSource. When set, the router installs the matching ConfiguredIpSource extension so the ClientIp extractor resolves the client address from the configured source using rightmost-value semantics. When unset, the extension is not installed and ClientIp falls back to InsecureClientIp, preserving today's behavior. Emits a startup warning for header-based values and rejects hot-reload changes because the router layer stack is built once.
1 parent 3b8df39 commit e7a50bd

11 files changed

Lines changed: 468 additions & 15 deletions

File tree

docs/deploying/generic.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,26 @@ Regardless of which reverse proxy you choose, you will need to:
166166
- Port 443 (HTTPS) for client-server API
167167
- Port 8448 for federation (if federating with other homeservers)
168168

169+
### Client IP source
170+
171+
Set `ip_source` when you want Tuwunel to use a spoofing-resistant client IP
172+
source for rate limiting, logging, and security tooling. Leave it unset to keep
173+
the legacy fallback behavior.
174+
175+
Use `ip_source = "connect_info"` only when Tuwunel accepts direct TCP
176+
connections and should use the TCP peer address. Do not use `connect_info` for
177+
Unix-socket deployments; leave `ip_source` unset there.
178+
179+
If Tuwunel is behind a trusted reverse proxy, set `ip_source` to match the
180+
header that proxy controls. Caddy, Nginx, and Traefik usually use
181+
`ip_source = "rightmost_x_forwarded_for"`. Cloudflare and cloudflared
182+
deployments can use `ip_source = "cf_connecting_ip"` when Cloudflare supplies
183+
that header.
184+
185+
Only use header-based values when clients cannot connect to Tuwunel directly.
186+
If clients can reach Tuwunel without going through the trusted proxy, they can
187+
send forged forwarding headers and choose the IP address Tuwunel sees.
188+
169189
See the following spec pages for more details on well-known files:
170190
- [`/.well-known/matrix/server`](https://spec.matrix.org/latest/client-server-api/#getwell-knownmatrixserver)
171191
- [`/.well-known/matrix/client`](https://spec.matrix.org/latest/client-server-api/#getwell-knownmatrixclient)

docs/deploying/reverse-proxy-caddy.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ your.server.name, your.server.name:8448 {
2828
- Sets all necessary reverse proxy headers correctly
2929
- Routes all traffic to Tuwunel listening on `localhost:8008`
3030

31+
### Client IP source
32+
33+
If Caddy is the only way clients can reach Tuwunel, set
34+
`ip_source = "rightmost_x_forwarded_for"` in `tuwunel.toml`. If you use the
35+
Unix-socket `reverse_proxy` target, leave `ip_source` unset instead.
36+
3137
That's it! Just start and enable the service and you're set.
3238

3339
```bash

docs/deploying/reverse-proxy-nginx.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,9 @@ server {
8484

8585
- **Replace `matrix.example.com`** with your actual server name
8686
- **`client_max_body_size`**: Must match or exceed `max_request_size` in your `tuwunel.toml`
87+
- **`ip_source`**: If Nginx is the only way clients can reach Tuwunel, set
88+
`ip_source = "rightmost_x_forwarded_for"` so Tuwunel uses the trusted
89+
`X-Forwarded-For` value
8790
- **Do NOT use `$request_uri`** in `proxy_pass` - while some guides suggest this, it's not necessary for Tuwunel and can cause issues
8891
- **IPv6**: The `listen [::]:443` and `listen [::]:8448` lines enable IPv6 support. Remove them if you don't need IPv6
8992

docs/deploying/reverse-proxy-traefik.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,15 @@ http:
7676
- url: "http://tuwunel:6167"
7777
passHostHeader: true
7878
```
79+
80+
### Client IP source
81+
82+
If Traefik is the only way clients can reach Tuwunel, set
83+
`ip_source = "rightmost_x_forwarded_for"` in `tuwunel.toml` so Tuwunel uses the
84+
trusted `X-Forwarded-For` value.
85+
7986
### Federation
87+
8088
If you will use a .well-known file you can use traefik to redirect .well-known/matrix to tuwunel built-in .well-known file.
8189

8290
replace the rule in either of the methods from

src/core/config/check.rs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use std::env::consts::OS;
33
use either::Either;
44
use itertools::Itertools;
55

6-
use super::{DEPRECATED_KEYS, IdentityProvider};
6+
use super::{DEPRECATED_KEYS, IdentityProvider, IpSource};
77
use crate::{Config, Err, Result, debug, debug_info, error, warn};
88

99
/// Performs check() with additional checks specific to reloading old config
@@ -19,6 +19,13 @@ pub fn reload(old: &Config, new: &Config) -> Result {
1919
));
2020
}
2121

22+
if new.ip_source != old.ip_source {
23+
return Err!(Config(
24+
"ip_source",
25+
"ip_source cannot be changed at runtime; restart the server to apply this change."
26+
));
27+
}
28+
2229
Ok(())
2330
}
2431

@@ -61,6 +68,16 @@ pub fn check(config: &Config) -> Result {
6168
return Err!(Config("tls", "tls.certs and tls.key must either both be set or unset"));
6269
}
6370

71+
if let Some(source) = config.ip_source
72+
&& !matches!(source, IpSource::ConnectInfo)
73+
{
74+
warn!(
75+
"ip_source is set to {source:?}, a header-based source. Ensure a trusted reverse \
76+
proxy populates this header for every request; otherwise clients can spoof their \
77+
IP address."
78+
);
79+
}
80+
6481
if !config.listening {
6582
warn!("Configuration item `listening` is set to `false`. Cannot hear anyone.");
6683
}

src/core/config/mod.rs

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -576,6 +576,40 @@ pub struct Config {
576576
#[serde(default = "default_client_shutdown_timeout")]
577577
pub client_shutdown_timeout: u64,
578578

579+
/// Source of the client IP address for rate limiting, logging, and
580+
/// security tooling.
581+
///
582+
/// When unset (the default), the `ClientIp` extractor falls back to
583+
/// `axum-client-ip`'s `InsecureClientIp` for backward compatibility;
584+
/// clients can spoof their address via request headers in that mode.
585+
///
586+
/// When set, tuwunel installs `SecureClientIpSource` with the selected
587+
/// variant and `ClientIp` resolves exclusively from that source. The
588+
/// rightmost value is used for multi-valued headers; only the proxy can
589+
/// append to the right, so this is resistant to client spoofing.
590+
///
591+
/// Supported values:
592+
/// - "connect_info" - TCP peer address only (direct connections)
593+
/// - "rightmost_x_forwarded_for" - nginx, Caddy
594+
/// - "rightmost_forwarded" - RFC 7239 proxies
595+
/// - "x_real_ip" - nginx `X-Real-IP`
596+
/// - "cf_connecting_ip" - Cloudflare / cloudflared
597+
/// - "true_client_ip" - Akamai, Cloudflare Enterprise
598+
/// - "fly_client_ip" - Fly.io
599+
/// - "cloudfront_viewer_address" - AWS CloudFront
600+
///
601+
/// On Unix-socket deployments, leave this unset rather than setting
602+
/// "connect_info"; that source requires a TCP peer address.
603+
///
604+
/// WARNING: a header-based value without a trusted reverse proxy in
605+
/// front of tuwunel allows clients to forge their IP. Changing this
606+
/// value requires a server restart.
607+
///
608+
/// default: unset
609+
/// config-example: "connect_info"
610+
#[serde(default)]
611+
pub ip_source: Option<IpSource>,
612+
579613
/// Grace period for clean shutdown of federation requests (seconds).
580614
///
581615
/// default: 5
@@ -3287,6 +3321,30 @@ impl From<AppServiceNamespace> for ruma::api::appservice::Namespace {
32873321
}
32883322
}
32893323

3324+
/// Selects the source used to determine the connecting client's IP
3325+
/// address. Variants correspond 1:1 to `axum_client_ip::SecureClientIpSource`.
3326+
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
3327+
#[serde(rename_all = "snake_case")]
3328+
pub enum IpSource {
3329+
/// TCP peer address. Safe default; no proxy required.
3330+
#[default]
3331+
ConnectInfo,
3332+
/// Rightmost value of `X-Forwarded-For`.
3333+
RightmostXForwardedFor,
3334+
/// Rightmost value of RFC 7239 `Forwarded`.
3335+
RightmostForwarded,
3336+
/// `X-Real-IP` header (nginx).
3337+
XRealIp,
3338+
/// `CF-Connecting-IP` (Cloudflare / cloudflared).
3339+
CfConnectingIp,
3340+
/// `True-Client-IP` (Akamai, Cloudflare Enterprise).
3341+
TrueClientIp,
3342+
/// `Fly-Client-IP` (Fly.io).
3343+
FlyClientIp,
3344+
/// `CloudFront-Viewer-Address` (AWS CloudFront).
3345+
CloudFrontViewerAddress,
3346+
}
3347+
32903348
const DEPRECATED_KEYS: &[&str; 9] = &[
32913349
"cache_capacity",
32923350
"conduit_cache_capacity_modifier",
@@ -3719,3 +3777,211 @@ fn default_redaction_retention_seconds() -> u64 { 5_184_000 }
37193777
fn default_media_storage_providers() -> BTreeSet<String> { ["media".to_owned()].into() }
37203778

37213779
fn default_multipart_threshold() -> ByteSize { ByteSize::mib(100) }
3780+
3781+
#[cfg(test)]
3782+
mod tests {
3783+
use std::{
3784+
io::{Result as IoResult, Write},
3785+
sync::{Arc, Mutex},
3786+
};
3787+
3788+
use tracing_subscriber::fmt::MakeWriter;
3789+
3790+
use super::*;
3791+
3792+
#[derive(Clone)]
3793+
struct SharedBufferWriter(Arc<Mutex<Vec<u8>>>);
3794+
3795+
impl Write for SharedBufferWriter {
3796+
fn write(&mut self, buf: &[u8]) -> IoResult<usize> {
3797+
self.0
3798+
.lock()
3799+
.expect("buffer lock poisoned")
3800+
.write(buf)
3801+
}
3802+
3803+
fn flush(&mut self) -> IoResult<()> { Ok(()) }
3804+
}
3805+
3806+
impl<'a> MakeWriter<'a> for SharedBufferWriter {
3807+
type Writer = Self;
3808+
3809+
fn make_writer(&'a self) -> Self::Writer { self.clone() }
3810+
}
3811+
3812+
fn config_from_toml(toml: &str) -> Result<Config> {
3813+
Config::new(&Figment::new().merge(Data::nested(Toml::string(toml))))
3814+
}
3815+
3816+
fn check_with_captured_logs(config: &Config) -> (Result, String) {
3817+
let captured = Arc::new(Mutex::new(Vec::new()));
3818+
let subscriber = tracing_subscriber::fmt()
3819+
.with_ansi(false)
3820+
.with_writer(SharedBufferWriter(Arc::clone(&captured)))
3821+
.finish();
3822+
3823+
let result = {
3824+
let _guard = tracing::subscriber::set_default(subscriber);
3825+
check(config)
3826+
};
3827+
3828+
let logs = String::from_utf8(
3829+
captured
3830+
.lock()
3831+
.expect("buffer lock poisoned")
3832+
.clone(),
3833+
)
3834+
.expect("captured tracing output should be valid UTF-8");
3835+
3836+
(result, logs)
3837+
}
3838+
3839+
#[test]
3840+
fn ip_source_absent_parses_as_none() {
3841+
let config = config_from_toml("[global]\n").unwrap();
3842+
3843+
assert_eq!(config.ip_source, None);
3844+
}
3845+
3846+
#[test]
3847+
fn ip_source_connect_info_parses() {
3848+
let config = config_from_toml(
3849+
r#"[global]
3850+
ip_source = "connect_info"
3851+
"#,
3852+
)
3853+
.unwrap();
3854+
3855+
assert_eq!(config.ip_source, Some(IpSource::ConnectInfo));
3856+
}
3857+
3858+
#[test]
3859+
fn ip_source_rightmost_x_forwarded_for_parses() {
3860+
let config = config_from_toml(
3861+
r#"[global]
3862+
ip_source = "rightmost_x_forwarded_for"
3863+
"#,
3864+
)
3865+
.unwrap();
3866+
3867+
assert_eq!(config.ip_source, Some(IpSource::RightmostXForwardedFor));
3868+
}
3869+
3870+
#[test]
3871+
fn ip_source_cf_connecting_ip_parses() {
3872+
let config = config_from_toml(
3873+
r#"[global]
3874+
ip_source = "cf_connecting_ip"
3875+
"#,
3876+
)
3877+
.unwrap();
3878+
3879+
assert_eq!(config.ip_source, Some(IpSource::CfConnectingIp));
3880+
}
3881+
3882+
#[test]
3883+
fn ip_source_camel_case_and_bogus_fail_to_parse() {
3884+
for value in ["CamelCase", "bogus"] {
3885+
let result = config_from_toml(&format!(
3886+
r#"[global]
3887+
ip_source = "{value}"
3888+
"#,
3889+
));
3890+
3891+
let Err(err) = result else {
3892+
panic!("ip_source value {value:?} should fail to parse");
3893+
};
3894+
3895+
let err = err.to_string();
3896+
assert!(err.contains("ip_source"), "{err}");
3897+
assert!(err.contains(value), "{err}");
3898+
}
3899+
}
3900+
3901+
#[test]
3902+
fn check_accepts_absent_connect_info_and_cf_connecting_ip() {
3903+
let absent = config_from_toml("[global]\n").unwrap();
3904+
let connect_info = config_from_toml(
3905+
r#"[global]
3906+
ip_source = "connect_info"
3907+
"#,
3908+
)
3909+
.unwrap();
3910+
let cf_connecting_ip = config_from_toml(
3911+
r#"[global]
3912+
ip_source = "cf_connecting_ip"
3913+
"#,
3914+
)
3915+
.unwrap();
3916+
3917+
let (result, logs) = check_with_captured_logs(&absent);
3918+
result.expect("absent ip_source should pass config check");
3919+
assert!(!logs.contains("ip_source is set to"));
3920+
3921+
let (result, logs) = check_with_captured_logs(&connect_info);
3922+
result.expect("connect_info should pass config check");
3923+
assert!(!logs.contains("ip_source is set to"));
3924+
3925+
let (result, logs) = check_with_captured_logs(&cf_connecting_ip);
3926+
result.expect("cf_connecting_ip should pass config check");
3927+
assert!(logs.contains("ip_source is set to CfConnectingIp"));
3928+
}
3929+
3930+
#[test]
3931+
fn reload_rejects_none_to_some_and_some_to_none() {
3932+
let none = config_from_toml("[global]\n").unwrap();
3933+
let some = config_from_toml(
3934+
r#"[global]
3935+
ip_source = "connect_info"
3936+
"#,
3937+
)
3938+
.unwrap();
3939+
let other_some = config_from_toml(
3940+
r#"[global]
3941+
ip_source = "rightmost_x_forwarded_for"
3942+
"#,
3943+
)
3944+
.unwrap();
3945+
3946+
let err = check::reload(&none, &some).unwrap_err();
3947+
assert!(
3948+
err.to_string().contains("'ip_source'")
3949+
&& err
3950+
.to_string()
3951+
.contains("cannot be changed at runtime"),
3952+
"{err}"
3953+
);
3954+
3955+
let err = check::reload(&some, &none).unwrap_err();
3956+
assert!(
3957+
err.to_string().contains("'ip_source'")
3958+
&& err
3959+
.to_string()
3960+
.contains("cannot be changed at runtime"),
3961+
"{err}"
3962+
);
3963+
3964+
let err = check::reload(&some, &other_some).unwrap_err();
3965+
assert!(
3966+
err.to_string().contains("'ip_source'")
3967+
&& err
3968+
.to_string()
3969+
.contains("cannot be changed at runtime"),
3970+
"{err}"
3971+
);
3972+
}
3973+
3974+
#[test]
3975+
fn reload_accepts_unchanged_none_and_unchanged_some() {
3976+
let none = config_from_toml("[global]\n").unwrap();
3977+
let some = config_from_toml(
3978+
r#"[global]
3979+
ip_source = "rightmost_x_forwarded_for"
3980+
"#,
3981+
)
3982+
.unwrap();
3983+
3984+
check::reload(&none, &none).expect("unchanged none config should reload");
3985+
check::reload(&some, &some).expect("unchanged some config should reload");
3986+
}
3987+
}

0 commit comments

Comments
 (0)