@@ -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+
32903348const 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 }
37193777fn default_media_storage_providers ( ) -> BTreeSet < String > { [ "media" . to_owned ( ) ] . into ( ) }
37203778
37213779fn 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