diff --git a/config.example.toml b/config.example.toml index a1faefa8..bf3b07ba 100644 --- a/config.example.toml +++ b/config.example.toml @@ -188,10 +188,16 @@ jwt_auth_fail_timeout_seconds = 300 # HTTP header to use to determine the real client IP, if the Signer is behind a proxy (e.g. nginx) # OPTIONAL. If missing, the client IP will be taken directly from the TCP connection. # [signer.reverse_proxy] -# Unique: HTTP header name to use to determine the real client IP. Expected to appear only once in the request. Requests with multiple values of this header will be rejected. -# unique = "X-Real-IP" -# Rightmost: HTTP header name to use to determine the real client IP from a comma-separated list of IPs. Rightmost IP is the client IP. If the header appears multiple times, the last value will be used. -# rightmost = "X-Forwarded-For" +# Type of reverse proxy configuration. Supported values: +# - unique: use a single HTTP header value as the client IP. +# - rightmost: use the rightmost IP from a comma-separated list of IPs in the HTTP header. +# type = "unique" +# Unique: HTTP header name to use to determine the real client IP. If the header appears multiple times, the request will be rejected. +# header = "X-Real-IP" +# Rightmost: HTTP header name to use to determine the real client IP from a comma-separated list of IPs. If the header appears multiple times, the last value will be used. +# header = "X-Forwarded-For" +# Rightmost: number of trusted proxies in front of the Signer, whose IPs will be skipped when extracting the client IP from the rightmost side of the list. Must be greater than 0. +# trusted_count = 1 # [signer.tls_mode] # How to use TLS for the Signer's HTTP server; two modes are supported: diff --git a/crates/common/src/config/signer.rs b/crates/common/src/config/signer.rs index 054ec717..cfd95be4 100644 --- a/crates/common/src/config/signer.rs +++ b/crates/common/src/config/signer.rs @@ -1,6 +1,8 @@ use std::{ collections::HashMap, + fmt::Display, net::{Ipv4Addr, SocketAddr}, + num::NonZeroUsize, path::PathBuf, }; @@ -71,12 +73,37 @@ pub enum TlsMode { /// Reverse proxy setup, used to extract real client's IP #[derive(Debug, Serialize, Deserialize, Clone, Default)] -#[serde(rename_all = "snake_case")] +#[serde(rename_all = "snake_case", tag = "type")] pub enum ReverseProxyHeaderSetup { #[default] None, - Unique(String), - Rightmost(String), + Unique { + header: String, + }, + Rightmost { + header: String, + trusted_count: NonZeroUsize, + }, +} + +impl Display for ReverseProxyHeaderSetup { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ReverseProxyHeaderSetup::None => write!(f, "None"), + ReverseProxyHeaderSetup::Unique { header } => { + write!(f, "\"{header} (unique)\"") + } + ReverseProxyHeaderSetup::Rightmost { header, trusted_count } => { + let suffix = match trusted_count.get() % 10 { + 1 => "st", + 2 => "nd", + 3 => "rd", + _ => "th", + }; + write!(f, "\"{header} ({trusted_count}{suffix} from the right)\"") + } + } + } } #[derive(Debug, Serialize, Deserialize, Clone)] diff --git a/crates/signer/src/service.rs b/crates/signer/src/service.rs index 219a4ae5..134da25a 100644 --- a/crates/signer/src/service.rs +++ b/crates/signer/src/service.rs @@ -126,7 +126,7 @@ impl SigningService { loaded_proxies, jwt_auth_fail_limit =? state.jwt_auth_fail_limit, jwt_auth_fail_timeout =? state.jwt_auth_fail_timeout, - reverse_proxy =? state.reverse_proxy, + reverse_proxy =% state.reverse_proxy, "Starting signing service" ); diff --git a/crates/signer/src/utils.rs b/crates/signer/src/utils.rs index a6d313ef..5ee808e8 100644 --- a/crates/signer/src/utils.rs +++ b/crates/signer/src/utils.rs @@ -13,6 +13,8 @@ pub enum IpError { InvalidValue, #[error("header `{0}` appears multiple times but expected to be unique")] NotUnique(String), + #[error("header does not contain enough values: found {found}, required {required}")] + NotEnoughValues { found: usize, required: usize }, } /// Get the true client IP from the request headers or fallback to the socket @@ -24,8 +26,10 @@ pub fn get_true_ip( ) -> Result { match reverse_proxy { ReverseProxyHeaderSetup::None => Ok(addr.ip()), - ReverseProxyHeaderSetup::Unique(header) => get_ip_from_unique_header(headers, header), - ReverseProxyHeaderSetup::Rightmost(header) => get_ip_from_rightmost_value(headers, header), + ReverseProxyHeaderSetup::Unique { header } => get_ip_from_unique_header(headers, header), + ReverseProxyHeaderSetup::Rightmost { header, trusted_count } => { + get_ip_from_rightmost_value(headers, header, trusted_count.get()) + } } } @@ -46,7 +50,11 @@ fn get_ip_from_unique_header(headers: &HeaderMap, header_name: &str) -> Result Result { +fn get_ip_from_rightmost_value( + headers: &HeaderMap, + header_name: &str, + trusted_count: usize, +) -> Result { let last_value = headers .get_all(header_name) .iter() @@ -55,10 +63,15 @@ fn get_ip_from_rightmost_value(headers: &HeaderMap, header_name: &str) -> Result .to_str() .map_err(|_| IpError::HasInvalidCharacters)?; + // Selecting the first untrusted IP from the right according to: + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-Forwarded-For#selecting_an_ip_address last_value - .rsplit_once(",") - .map(|(_, rightmost)| rightmost) - .unwrap_or(last_value) + .rsplit(",") + .nth(trusted_count - 1) + .ok_or(IpError::NotEnoughValues { + found: last_value.split(",").count(), + required: trusted_count, + })? .parse::() .map_err(|_| IpError::InvalidValue) } diff --git a/docs/docs/get_started/configuration.md b/docs/docs/get_started/configuration.md index abdb9e20..c50fcf0c 100644 --- a/docs/docs/get_started/configuration.md +++ b/docs/docs/get_started/configuration.md @@ -401,21 +401,26 @@ jwt_auth_fail_timeout_seconds = 300 # The time window in seconds The rate limit is applied to the IP address of the client making the request. By default, the IP is extracted directly from the TCP connection. If you're running the Signer service behind a reverse proxy (e.g. Nginx), you can configure it to extract the IP from a custom HTTP header instead. There're two options: -- `unique`: The name of the HTTP header that contains the IP. This header is expected to appear only once in the request. This is common when using `X-Real-IP`, `True-Client-IP`, etc. If a request is received that has multiple values for this header, it will be considered invalid and rejected. -- `rightmost`: The name of the HTTP header that contains a comma-separated list of IPs. The rightmost IP in the list is used. If the header appears multiple times, the last occurrence is used. This is common when using `X-Forwarded-For`. +- unique: Provides an HTTP header that contains the IP. This header is expected to appear only once in the request. This is common when using `X-Real-IP`, `True-Client-IP`, etc. If a request has multiple values for this header, it will be considered invalid and rejected. +- `rightmost`: Provides an HTTP header that contains a comma-separated list of IPs. The nth rightmost IP in the list is used. If the header appears multiple times, the last occurrence is used. This is common when using `X-Forwarded-For`. Examples: ```toml [signer.reverse_proxy] -unique = "X-Real-IP" +type = "unique" +header = "X-Real-IP" ``` ```toml [signer.reverse_proxy] -rightmost = "X-Forwarded-For" +type = "rightmost" +header = "X-Forwarded-For" +trusted_count = 1 ``` +Note: `trusted_count` is the number of trusted proxies in front of the Signer service, but the last proxy won't add its address, so the number of skipped IPs is `trusted_count - 1`. See [MDN docs](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-Forwarded-For#trusted_proxy_count) for more info. + ## Custom module We currently provide a test module that needs to be built locally. To build the module run: