Make real IP header configurable via 'ip_header'.

Adds an `ip_header` configuration parameter that allows modifying the
header Rocket attempts to use to retrieve the "real IP" address of the
client via `Request` methods like `client_ip()`. Additionally allows
disabling the use of any such header.
This commit is contained in:
Sergio Benitez 2023-03-20 12:57:21 -07:00
parent 0c84af2ea7
commit 9377af5978
8 changed files with 269 additions and 12 deletions

View File

@ -54,6 +54,62 @@ impl<'h> Header<'h> {
} }
} }
/// Returns `true` if `name` is a valid header name.
///
/// This implements a simple (i.e, correct but not particularly performant)
/// header "field-name" checker as defined in RFC 7230.
///
/// ```text
/// header-field = field-name ":" OWS field-value OWS
/// field-name = token
/// token = 1*tchar
/// tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*"
/// / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~"
/// / DIGIT / ALPHA
/// ; any VCHAR, except delimiters
/// ```
///
/// # Example
///
/// ```rust
/// # extern crate rocket;
/// use rocket::http::Header;
///
/// assert!(!Header::is_valid_name(""));
/// assert!(!Header::is_valid_name("some header"));
/// assert!(!Header::is_valid_name("some()"));
/// assert!(!Header::is_valid_name("[SomeHeader]"));
/// assert!(!Header::is_valid_name("<"));
/// assert!(!Header::is_valid_name(""));
/// assert!(!Header::is_valid_name("header,here"));
///
/// assert!(Header::is_valid_name("Some#Header"));
/// assert!(Header::is_valid_name("Some-Header"));
/// assert!(Header::is_valid_name("This-Is_A~Header"));
/// ```
#[doc(hidden)]
pub const fn is_valid_name(name: &str) -> bool {
const fn is_tchar(b: &u8) -> bool {
b.is_ascii_alphanumeric() || match *b {
b'!' | b'#' | b'$' | b'%' | b'&' | b'\'' | b'*' | b'+' | b'-' |
b'.' | b'^' | b'_' | b'`' | b'|' | b'~' => true,
_ => false
}
}
let mut i = 0;
let bytes = name.as_bytes();
while i < bytes.len() {
if !is_tchar(&bytes[i]) {
return false
}
i += 1;
}
i > 0
}
/// Returns `true` if `val` is a valid header value. /// Returns `true` if `val` is a valid header value.
/// ///
/// If `allow_empty` is `true`, this function returns `true` for empty /// If `allow_empty` is `true`, this function returns `true` for empty

View File

@ -8,6 +8,7 @@ use yansi::Paint;
use crate::config::{LogLevel, Shutdown, Ident}; use crate::config::{LogLevel, Shutdown, Ident};
use crate::request::{self, Request, FromRequest}; use crate::request::{self, Request, FromRequest};
use crate::http::uncased::Uncased;
use crate::data::Limits; use crate::data::Limits;
#[cfg(feature = "tls")] #[cfg(feature = "tls")]
@ -78,6 +79,18 @@ pub struct Config {
/// How, if at all, to identify the server via the `Server` header. /// How, if at all, to identify the server via the `Server` header.
/// **(default: `"Rocket"`)** /// **(default: `"Rocket"`)**
pub ident: Ident, pub ident: Ident,
/// The name of a header, whose value is typically set by an intermediary
/// server or proxy, which contains the real IP address of the connecting
/// client. Used internally and by [`Request::client_ip()`] and
/// [`Request::real_ip()`].
///
/// To disable using any header for this purpose, set this value to `false`.
/// Deserialization semantics are identical to those of [`Ident`] except
/// that the value must syntactically be a valid HTTP header name.
///
/// **(default: `"X-Real-IP"`)**
#[serde(deserialize_with = "crate::config::ip_header::deserialize")]
pub ip_header: Option<Uncased<'static>>,
/// Streaming read size limits. **(default: [`Limits::default()`])** /// Streaming read size limits. **(default: [`Limits::default()`])**
pub limits: Limits, pub limits: Limits,
/// Directory to store temporary files in. **(default: /// Directory to store temporary files in. **(default:
@ -174,6 +187,7 @@ impl Config {
workers: num_cpus::get(), workers: num_cpus::get(),
max_blocking: 512, max_blocking: 512,
ident: Ident::default(), ident: Ident::default(),
ip_header: Some(Uncased::from_borrowed("X-Real-IP")),
limits: Limits::default(), limits: Limits::default(),
temp_dir: std::env::temp_dir().into(), temp_dir: std::env::temp_dir().into(),
keep_alive: 5, keep_alive: 5,
@ -363,6 +377,12 @@ impl Config {
launch_meta_!("workers: {}", bold(self.workers)); launch_meta_!("workers: {}", bold(self.workers));
launch_meta_!("max blocking threads: {}", bold(self.max_blocking)); launch_meta_!("max blocking threads: {}", bold(self.max_blocking));
launch_meta_!("ident: {}", bold(&self.ident)); launch_meta_!("ident: {}", bold(&self.ident));
match self.ip_header {
Some(ref name) => launch_meta_!("IP header: {}", bold(name)),
None => launch_meta_!("IP header: {}", bold("disabled"))
}
launch_meta_!("limits: {}", bold(&self.limits)); launch_meta_!("limits: {}", bold(&self.limits));
launch_meta_!("temp dir: {}", bold(&self.temp_dir.relative().display())); launch_meta_!("temp dir: {}", bold(&self.temp_dir.relative().display()));
launch_meta_!("http/2: {}", bold(cfg!(feature = "http2"))); launch_meta_!("http/2: {}", bold(cfg!(feature = "http2")));

View File

@ -0,0 +1,56 @@
use std::fmt;
use serde::de;
use crate::http::Header;
use crate::http::uncased::Uncased;
pub(crate) fn deserialize<'de, D>(de: D) -> Result<Option<Uncased<'static>>, D::Error>
where D: de::Deserializer<'de>
{
struct Visitor;
impl<'de> de::Visitor<'de> for Visitor {
type Value = Option<Uncased<'static>>;
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("a valid header name or `false`")
}
fn visit_bool<E: de::Error>(self, v: bool) -> Result<Self::Value, E> {
if !v {
return Ok(None);
}
Err(E::invalid_value(de::Unexpected::Bool(v), &self))
}
fn visit_some<D>(self, de: D) -> Result<Self::Value, D::Error>
where D: de::Deserializer<'de>
{
de.deserialize_string(self)
}
fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
Ok(None)
}
fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
Ok(None)
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
self.visit_string(v.into())
}
fn visit_string<E: de::Error>(self, v: String) -> Result<Self::Value, E> {
if Header::is_valid_name(&v) {
Ok(Some(Uncased::from_owned(v)))
} else {
Err(E::invalid_value(de::Unexpected::Str(&v), &self))
}
}
}
de.deserialize_string(Visitor)
}

View File

@ -114,6 +114,7 @@
mod ident; mod ident;
mod config; mod config;
mod shutdown; mod shutdown;
mod ip_header;
#[cfg(feature = "tls")] #[cfg(feature = "tls")]
mod tls; mod tls;

View File

@ -307,9 +307,10 @@ impl<'r> Request<'r> {
/// ///
/// Because it is common for proxies to forward connections for clients, the /// Because it is common for proxies to forward connections for clients, the
/// remote address may contain information about the proxy instead of the /// remote address may contain information about the proxy instead of the
/// client. For this reason, proxies typically set the "X-Real-IP" header /// client. For this reason, proxies typically set a "X-Real-IP" header
/// with the client's true IP. To extract this IP from the request, use the /// [`ip_header`](rocket::Config::ip_header) with the client's true IP. To
/// [`real_ip()`] or [`client_ip()`] methods. /// extract this IP from the request, use the [`real_ip()`] or
/// [`client_ip()`] methods.
/// ///
/// [`real_ip()`]: #method.real_ip /// [`real_ip()`]: #method.real_ip
/// [`client_ip()`]: #method.client_ip /// [`client_ip()`]: #method.client_ip
@ -356,8 +357,9 @@ impl<'r> Request<'r> {
self.connection.remote = Some(address); self.connection.remote = Some(address);
} }
/// Returns the IP address in the "X-Real-IP" header of the request if such /// Returns the IP address of the configured
/// a header exists and contains a valid IP address. /// [`ip_header`](rocket::Config::ip_header) of the request if such a header
/// is configured, exists and contains a valid IP address.
/// ///
/// # Example /// # Example
/// ///
@ -369,25 +371,40 @@ impl<'r> Request<'r> {
/// # let req = c.get("/"); /// # let req = c.get("/");
/// assert_eq!(req.real_ip(), None); /// assert_eq!(req.real_ip(), None);
/// ///
/// // `ip_header` defaults to `X-Real-IP`.
/// let req = req.header(Header::new("X-Real-IP", "127.0.0.1")); /// let req = req.header(Header::new("X-Real-IP", "127.0.0.1"));
/// assert_eq!(req.real_ip(), Some(Ipv4Addr::LOCALHOST.into())); /// assert_eq!(req.real_ip(), Some(Ipv4Addr::LOCALHOST.into()));
/// ``` /// ```
pub fn real_ip(&self) -> Option<IpAddr> { pub fn real_ip(&self) -> Option<IpAddr> {
let ip_header = self.rocket().config.ip_header.as_ref()?.as_str();
self.headers() self.headers()
.get_one("X-Real-IP") .get_one(ip_header)
.and_then(|ip| { .and_then(|ip| {
ip.parse() ip.parse()
.map_err(|_| warn_!("'X-Real-IP' header is malformed: {}", ip)) .map_err(|_| warn_!("'{}' header is malformed: {}", ip_header, ip))
.ok() .ok()
}) })
} }
/// Attempts to return the client's IP address by first inspecting the /// Attempts to return the client's IP address by first inspecting the
/// "X-Real-IP" header and then using the remote connection's IP address. /// [`ip_header`](rocket::Config::ip_header) and then using the remote
/// connection's IP address. Note that the built-in `IpAddr` request guard
/// can be used to retrieve the same information in a handler:
/// ///
/// If the "X-Real-IP" header exists and contains a valid IP address, that /// ```rust
/// address is returned. Otherwise, if the address of the remote connection /// # use rocket::get;
/// is known, that address is returned. Otherwise, `None` is returned. /// use std::net::IpAddr;
///
/// #[get("/")]
/// fn get_ip(client_ip: IpAddr) { /* ... */ }
///
/// #[get("/")]
/// fn try_get_ip(client_ip: Option<IpAddr>) { /* ... */ }
/// ````
///
/// If the `ip_header` exists and contains a valid IP address, that address
/// is returned. Otherwise, if the address of the remote connection is
/// known, that address is returned. Otherwise, `None` is returned.
/// ///
/// # Example /// # Example
/// ///
@ -405,7 +422,7 @@ impl<'r> Request<'r> {
/// request.set_remote("127.0.0.1:8000".parse().unwrap()); /// request.set_remote("127.0.0.1:8000".parse().unwrap());
/// assert_eq!(request.client_ip(), Some("127.0.0.1".parse().unwrap())); /// assert_eq!(request.client_ip(), Some("127.0.0.1".parse().unwrap()));
/// ///
/// // now with an X-Real-IP header /// // now with an X-Real-IP header, the default value for `ip_header`.
/// request.add_header(Header::new("X-Real-IP", "8.8.8.8")); /// request.add_header(Header::new("X-Real-IP", "8.8.8.8"));
/// assert_eq!(request.client_ip(), Some("8.8.8.8".parse().unwrap())); /// assert_eq!(request.client_ip(), Some("8.8.8.8".parse().unwrap()));
/// ``` /// ```

View File

@ -0,0 +1,101 @@
#[macro_use] extern crate rocket;
#[get("/")]
fn inspect_ip(ip: Option<std::net::IpAddr>) -> String {
ip.map(|ip| ip.to_string()).unwrap_or("<none>".into())
}
mod tests {
use rocket::{Rocket, Build, Route};
use rocket::local::blocking::Client;
use rocket::figment::Figment;
use rocket::http::Header;
fn routes() -> Vec<Route> {
routes![super::inspect_ip]
}
fn rocket_with_custom_ip_header(header: Option<&'static str>) -> Rocket<Build> {
let mut config = rocket::Config::debug_default();
config.ip_header = header.map(|h| h.into());
rocket::custom(config).mount("/", routes())
}
#[test]
fn check_real_ip_header_works() {
let client = Client::debug(rocket_with_custom_ip_header(Some("IP"))).unwrap();
let response = client.get("/")
.header(Header::new("X-Real-IP", "1.2.3.4"))
.header(Header::new("IP", "8.8.8.8"))
.dispatch();
assert_eq!(response.into_string(), Some("8.8.8.8".into()));
let response = client.get("/")
.header(Header::new("IP", "1.1.1.1"))
.dispatch();
assert_eq!(response.into_string(), Some("1.1.1.1".into()));
let response = client.get("/").dispatch();
assert_eq!(response.into_string(), Some("<none>".into()));
}
#[test]
fn check_real_ip_header_works_again() {
let client = Client::debug(rocket_with_custom_ip_header(Some("x-forward-ip"))).unwrap();
let response = client.get("/")
.header(Header::new("X-Forward-IP", "1.2.3.4"))
.dispatch();
assert_eq!(response.into_string(), Some("1.2.3.4".into()));
let config = Figment::from(rocket::Config::debug_default())
.merge(("ip_header", "x-forward-ip"));
let client = Client::debug(rocket::custom(config).mount("/", routes())).unwrap();
let response = client.get("/")
.header(Header::new("X-Forward-IP", "1.2.3.4"))
.dispatch();
assert_eq!(response.into_string(), Some("1.2.3.4".into()));
}
#[test]
fn check_default_real_ip_header_works() {
let client = Client::debug_with(routes()).unwrap();
let response = client.get("/")
.header(Header::new("X-Real-IP", "1.2.3.4"))
.dispatch();
assert_eq!(response.into_string(), Some("1.2.3.4".into()));
}
#[test]
fn check_no_ip_header_works() {
let client = Client::debug(rocket_with_custom_ip_header(None)).unwrap();
let response = client.get("/")
.header(Header::new("X-Real-IP", "1.2.3.4"))
.dispatch();
assert_eq!(response.into_string(), Some("<none>".into()));
let config = Figment::from(rocket::Config::debug_default())
.merge(("ip_header", false));
let client = Client::debug(rocket::custom(config).mount("/", routes())).unwrap();
let response = client.get("/")
.header(Header::new("X-Real-IP", "1.2.3.4"))
.dispatch();
assert_eq!(response.into_string(), Some("<none>".into()));
let config = Figment::from(rocket::Config::debug_default());
let client = Client::debug(rocket::custom(config).mount("/", routes())).unwrap();
let response = client.get("/")
.header(Header::new("X-Real-IP", "1.2.3.4"))
.dispatch();
assert_eq!(response.into_string(), Some("1.2.3.4".into()));
}
}

View File

@ -11,6 +11,7 @@ msgpack = "2 MiB"
key = "a default app-key" key = "a default app-key"
extra = false extra = false
ident = "Rocket" ident = "Rocket"
ip_header = "CF-Connecting-IP"
[debug] [debug]
address = "127.0.0.1" address = "127.0.0.1"

View File

@ -24,6 +24,7 @@ values:
| `workers`* | `usize` | Number of threads to use for executing futures. | cpu core count | | `workers`* | `usize` | Number of threads to use for executing futures. | cpu core count |
| `max_blocking`* | `usize` | Limit on threads to start for blocking tasks. | `512` | | `max_blocking`* | `usize` | Limit on threads to start for blocking tasks. | `512` |
| `ident` | `string`, `false` | If and how to identify via the `Server` header. | `"Rocket"` | | `ident` | `string`, `false` | If and how to identify via the `Server` header. | `"Rocket"` |
| `ip_header` | `string`, `false` | IP header to inspect to get [client's real IP]. | `"X-Real-IP"` |
| `keep_alive` | `u32` | Keep-alive timeout seconds; disabled when `0`. | `5` | | `keep_alive` | `u32` | Keep-alive timeout seconds; disabled when `0`. | `5` |
| `log_level` | [`LogLevel`] | Max level to log. (off/normal/debug/critical) | `normal`/`critical` | | `log_level` | [`LogLevel`] | Max level to log. (off/normal/debug/critical) | `normal`/`critical` |
| `cli_colors` | `bool` | Whether to use colors and emoji when logging. | `true` | | `cli_colors` | `bool` | Whether to use colors and emoji when logging. | `true` |
@ -37,6 +38,8 @@ values:
<small>* Note: the `workers`, `max_blocking`, and `shutdown.force` configuration <small>* Note: the `workers`, `max_blocking`, and `shutdown.force` configuration
parameters are only read from the [default provider](#default-provider).</small> parameters are only read from the [default provider](#default-provider).</small>
[client's real IP]: @api/rocket/request/struct.Request.html#method.real_ip
### Profiles ### Profiles
Configurations can be arbitrarily namespaced by [`Profile`]s. Rocket's Configurations can be arbitrarily namespaced by [`Profile`]s. Rocket's
@ -127,6 +130,7 @@ port = 9001
[release] [release]
port = 9999 port = 9999
secret_key = "hPRYyVRiMyxpw5sBB1XeCMN1kFsDCqKvBi2QJxBVHQk=" secret_key = "hPRYyVRiMyxpw5sBB1XeCMN1kFsDCqKvBi2QJxBVHQk="
ip_header = false
``` ```
The following is a `Rocket.toml` file with all configuration options set for The following is a `Rocket.toml` file with all configuration options set for
@ -142,6 +146,7 @@ workers = 16
max_blocking = 512 max_blocking = 512
keep_alive = 5 keep_alive = 5
ident = "Rocket" ident = "Rocket"
ip_header = "X-Real-IP" # set to `false` to disable
log_level = "normal" log_level = "normal"
temp_dir = "/tmp" temp_dir = "/tmp"
cli_colors = true cli_colors = true