From 5c85ea3db53beece11dbc855d0f76a880d1e268a Mon Sep 17 00:00:00 2001 From: Arjen <4867268+arjentz@users.noreply.github.com> Date: Tue, 11 Apr 2023 23:43:12 +0200 Subject: [PATCH] Support configurable 'X-Forwarded-Proto'. Co-authored-by: Sergio Benitez --- core/http/src/header/mod.rs | 2 + core/http/src/header/proxy_proto.rs | 51 ++++++++ core/lib/src/config/config.rs | 39 ++++-- .../config/{ip_header.rs => http_header.rs} | 0 core/lib/src/config/mod.rs | 2 +- core/lib/src/cookies.rs | 107 ++++++++-------- core/lib/src/local/asynchronous/response.rs | 5 +- core/lib/src/local/client.rs | 3 +- core/lib/src/request/from_request.rs | 20 ++- core/lib/src/request/request.rs | 110 +++++++++++++--- core/lib/tests/config-proxy-proto-header.rs | 118 ++++++++++++++++++ site/guide/9-configuration.md | 60 ++++++--- 12 files changed, 421 insertions(+), 96 deletions(-) create mode 100644 core/http/src/header/proxy_proto.rs rename core/lib/src/config/{ip_header.rs => http_header.rs} (100%) create mode 100644 core/lib/tests/config-proxy-proto-header.rs diff --git a/core/http/src/header/mod.rs b/core/http/src/header/mod.rs index b91521ff..653b7863 100644 --- a/core/http/src/header/mod.rs +++ b/core/http/src/header/mod.rs @@ -4,10 +4,12 @@ mod media_type; mod content_type; mod accept; mod header; +mod proxy_proto; pub use self::content_type::ContentType; pub use self::accept::{Accept, QMediaType}; pub use self::media_type::MediaType; pub use self::header::{Header, HeaderMap}; +pub use self::proxy_proto::ProxyProto; pub(crate) use self::media_type::Source; diff --git a/core/http/src/header/proxy_proto.rs b/core/http/src/header/proxy_proto.rs new file mode 100644 index 00000000..6c475263 --- /dev/null +++ b/core/http/src/header/proxy_proto.rs @@ -0,0 +1,51 @@ +use std::fmt; + +use uncased::{UncasedStr, AsUncased}; + +/// A protocol used to identify a specific protocol forwarded by an HTTP proxy. +/// Value are case-insensitive. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ProxyProto<'a> { + /// `http` value, Hypertext Transfer Protocol. + Http, + /// `https` value, Hypertext Transfer Protocol Secure. + Https, + /// Any protocol name other than `http` or `https`. + Unknown(&'a UncasedStr), +} + +impl ProxyProto<'_> { + /// Returns `true` if `self` is `ProxyProto::Https` and `false` otherwise. + /// + /// # Example + /// + /// ```rust + /// use rocket::http::ProxyProto; + /// + /// assert!(ProxyProto::Https.is_https()); + /// assert!(!ProxyProto::Http.is_https()); + /// ``` + pub fn is_https(&self) -> bool { + self == &ProxyProto::Https + } +} + +impl<'a> From<&'a str> for ProxyProto<'a> { + fn from(value: &'a str) -> ProxyProto<'a> { + match value.as_uncased() { + v if v == "http" => ProxyProto::Http, + v if v == "https" => ProxyProto::Https, + v => ProxyProto::Unknown(v) + } + } +} + +impl fmt::Display for ProxyProto<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match *self { + ProxyProto::Http => "http", + ProxyProto::Https => "https", + ProxyProto::Unknown(s) => s.as_str(), + }) + } +} diff --git a/core/lib/src/config/config.rs b/core/lib/src/config/config.rs index 6f69b85d..aaa62c41 100644 --- a/core/lib/src/config/config.rs +++ b/core/lib/src/config/config.rs @@ -85,13 +85,28 @@ pub struct Config { /// 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. + /// To disable using any header for this purpose, set this value to `false` + /// or `None`. 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")] + #[serde(deserialize_with = "crate::config::http_header::deserialize")] pub ip_header: Option>, + /// The name of a header, whose value is typically set by an intermediary + /// server or proxy, which contains the protocol (HTTP or HTTPS) used by the + /// connecting client. This should probably be [`X-Forwarded-Proto`], as + /// that is the de facto standard. Used by [`Request::forwarded_proto()`] + /// to determine the forwarded protocol and [`Request::forwarded_secure()`] + /// to determine whether a request is handled in a secure context. + /// + /// [`X-Forwarded-Proto`]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Proto + /// + /// To disable using any header for this purpose, set this value to `false` + /// or `None`. Deserialization semantics are identical to those of [`ip_header`]. + /// + /// **(default: `None`)** + #[serde(deserialize_with = "crate::config::http_header::deserialize")] + pub proxy_proto_header: Option>, /// Streaming read size limits. **(default: [`Limits::default()`])** pub limits: Limits, /// Directory to store temporary files in. **(default: @@ -189,6 +204,7 @@ impl Config { max_blocking: 512, ident: Ident::default(), ip_header: Some(Uncased::from_borrowed("X-Real-IP")), + proxy_proto_header: None, limits: Limits::default(), temp_dir: std::env::temp_dir().into(), keep_alive: 5, @@ -409,6 +425,11 @@ impl Config { None => launch_meta_!("IP header: {}", "disabled".paint(VAL)) } + match self.proxy_proto_header.as_ref() { + Some(name) => launch_meta_!("Proxy-Proto header: {}", name.paint(VAL)), + None => launch_meta_!("Proxy-Proto header: {}", "disabled".paint(VAL)) + } + launch_meta_!("limits: {}", (&self.limits).paint(VAL)); launch_meta_!("temp dir: {}", self.temp_dir.relative().display().paint(VAL)); launch_meta_!("http/2: {}", (cfg!(feature = "http2").paint(VAL))); @@ -513,6 +534,9 @@ impl Config { /// The stringy parameter name for setting/extracting [`Config::ip_header`]. pub const IP_HEADER: &'static str = "ip_header"; + /// The stringy parameter name for setting/extracting [`Config::proxy_proto_header`]. + pub const PROXY_PROTO_HEADER: &'static str = "proxy_proto_header"; + /// The stringy parameter name for setting/extracting [`Config::limits`]. pub const LIMITS: &'static str = "limits"; @@ -536,10 +560,9 @@ impl Config { /// An array of all of the stringy parameter names. pub const PARAMETERS: &'static [&'static str] = &[ - Self::ADDRESS, Self::PORT, Self::WORKERS, Self::MAX_BLOCKING, - Self::KEEP_ALIVE, Self::IDENT, Self::IP_HEADER, Self::LIMITS, Self::TLS, - Self::SECRET_KEY, Self::TEMP_DIR, Self::LOG_LEVEL, Self::SHUTDOWN, - Self::CLI_COLORS, + Self::ADDRESS, Self::PORT, Self::WORKERS, Self::MAX_BLOCKING, Self::KEEP_ALIVE, + Self::IDENT, Self::IP_HEADER, Self::PROXY_PROTO_HEADER, Self::LIMITS, Self::TLS, + Self::SECRET_KEY, Self::TEMP_DIR, Self::LOG_LEVEL, Self::SHUTDOWN, Self::CLI_COLORS, ]; } diff --git a/core/lib/src/config/ip_header.rs b/core/lib/src/config/http_header.rs similarity index 100% rename from core/lib/src/config/ip_header.rs rename to core/lib/src/config/http_header.rs diff --git a/core/lib/src/config/mod.rs b/core/lib/src/config/mod.rs index 07286fc1..2bf83cd2 100644 --- a/core/lib/src/config/mod.rs +++ b/core/lib/src/config/mod.rs @@ -114,8 +114,8 @@ mod ident; mod config; mod shutdown; -mod ip_header; mod cli_colors; +mod http_header; #[cfg(feature = "tls")] mod tls; diff --git a/core/lib/src/cookies.rs b/core/lib/src/cookies.rs index 368172cd..926241fd 100644 --- a/core/lib/src/cookies.rs +++ b/core/lib/src/cookies.rs @@ -2,8 +2,8 @@ use std::fmt; use parking_lot::Mutex; -use crate::Config; use crate::http::private::cookie; +use crate::{Rocket, Orbit}; #[doc(inline)] pub use self::cookie::{Cookie, SameSite, Iter}; @@ -154,17 +154,14 @@ pub use self::cookie::{Cookie, SameSite, Iter}; pub struct CookieJar<'a> { jar: cookie::CookieJar, ops: Mutex>, - config: &'a Config, + pub(crate) state: CookieState<'a>, } -impl<'a> Clone for CookieJar<'a> { - fn clone(&self) -> Self { - CookieJar { - jar: self.jar.clone(), - ops: Mutex::new(self.ops.lock().clone()), - config: self.config, - } - } +#[derive(Copy, Clone)] +pub(crate) struct CookieState<'a> { + pub secure: bool, + #[cfg_attr(not(feature = "secrets"), allow(unused))] + pub config: &'a crate::Config, } #[derive(Clone)] @@ -173,22 +170,17 @@ enum Op { Remove(Cookie<'static>, bool), } -impl Op { - fn cookie(&self) -> &Cookie<'static> { - match self { - Op::Add(c, _) | Op::Remove(c, _) => c - } - } -} - impl<'a> CookieJar<'a> { - #[inline(always)] - pub(crate) fn new(config: &'a Config) -> Self { - CookieJar::from(cookie::CookieJar::new(), config) - } - - pub(crate) fn from(jar: cookie::CookieJar, config: &'a Config) -> Self { - CookieJar { jar, config, ops: Mutex::new(Vec::new()) } + pub(crate) fn new(base: Option, rocket: &'a Rocket) -> Self { + CookieJar { + jar: base.unwrap_or_default(), + ops: Mutex::new(Vec::new()), + state: CookieState { + // This is updated dynamically when headers are received. + secure: rocket.config().tls_enabled(), + config: rocket.config(), + } + } } /// Returns a reference to the _original_ `Cookie` inside this container @@ -236,7 +228,7 @@ impl<'a> CookieJar<'a> { #[cfg(feature = "secrets")] #[cfg_attr(nightly, doc(cfg(feature = "secrets")))] pub fn get_private(&self, name: &str) -> Option> { - self.jar.private(&self.config.secret_key.key).get(name) + self.jar.private(&self.state.config.secret_key.key).get(name) } /// Returns a reference to the _original or pending_ `Cookie` inside this @@ -287,7 +279,10 @@ impl<'a> CookieJar<'a> { /// * `path`: `"/"` /// * `SameSite`: `Strict` /// - /// Furthermore, if TLS is enabled, the `Secure` cookie flag is set. + /// Furthermore, if TLS is enabled or handled by a proxy (as determined by + /// [`Request::context_is_likely_secure()`]), the `Secure` cookie flag is set. + /// These defaults ensure maximum usability and security. For additional + /// security, you may wish to set the `secure` flag explicitly. /// /// # Example /// @@ -309,7 +304,7 @@ impl<'a> CookieJar<'a> { /// ``` pub fn add>>(&self, cookie: C) { let mut cookie = cookie.into(); - Self::set_defaults(self.config, &mut cookie); + self.set_defaults(&mut cookie); self.ops.lock().push(Op::Add(cookie, false)); } @@ -327,9 +322,10 @@ impl<'a> CookieJar<'a> { /// * `HttpOnly`: `true` /// * `Expires`: 1 week from now /// - /// Furthermore, if TLS is enabled, the `Secure` cookie flag is set. These - /// defaults ensure maximum usability and security. For additional security, - /// you may wish to set the `secure` flag. + /// Furthermore, if TLS is enabled or handled by a proxy (as determined by + /// [`Request::context_is_likely_secure()`]), the `Secure` cookie flag is set. + /// These defaults ensure maximum usability and security. For additional + /// security, you may wish to set the `secure` flag explicitly. /// /// # Example /// @@ -346,7 +342,7 @@ impl<'a> CookieJar<'a> { #[cfg_attr(nightly, doc(cfg(feature = "secrets")))] pub fn add_private>>(&self, cookie: C) { let mut cookie = cookie.into(); - Self::set_private_defaults(self.config, &mut cookie); + self.set_private_defaults(&mut cookie); self.ops.lock().push(Op::Add(cookie, true)); } @@ -476,7 +472,7 @@ impl<'a> CookieJar<'a> { Op::Add(c, false) => jar.add(c), #[cfg(feature = "secrets")] Op::Add(c, true) => { - jar.private_mut(&self.config.secret_key.key).add(c); + jar.private_mut(&self.state.config.secret_key.key).add(c); } Op::Remove(mut c, _) => { if self.jar.get(c.name()).is_some() { @@ -505,7 +501,7 @@ impl<'a> CookieJar<'a> { #[cfg_attr(nightly, doc(cfg(feature = "secrets")))] #[inline(always)] pub(crate) fn add_original_private(&mut self, cookie: Cookie<'static>) { - self.jar.private_mut(&self.config.secret_key.key).add_original(cookie); + self.jar.private_mut(&self.state.config.secret_key.key).add_original(cookie); } /// For each property mentioned below, this method checks if there is a @@ -515,8 +511,9 @@ impl<'a> CookieJar<'a> { /// * `path`: `"/"` /// * `SameSite`: `Strict` /// - /// Furthermore, if TLS is enabled, the `Secure` cookie flag is set. - fn set_defaults(config: &Config, cookie: &mut Cookie<'static>) { + /// Furthermore, if TLS is enabled or handled by a proxy (as determined by + /// [`Request::context_is_likely_secure()`]), the `Secure` cookie flag is set. + fn set_defaults(&self, cookie: &mut Cookie<'static>) { if cookie.path().is_none() { cookie.set_path("/"); } @@ -525,7 +522,7 @@ impl<'a> CookieJar<'a> { cookie.set_same_site(SameSite::Strict); } - if cookie.secure().is_none() && config.tls_enabled() { + if cookie.secure().is_none() && self.state.secure { cookie.set_secure(true); } } @@ -554,17 +551,12 @@ impl<'a> CookieJar<'a> { /// * `HttpOnly`: `true` /// * `Expires`: 1 week from now /// - /// Furthermore, if TLS is enabled, the `Secure` cookie flag is set. + /// Furthermore, if TLS is enabled or handled by a proxy (as determined by + /// [`Request::context_is_likely_secure()`]), the `Secure` cookie flag is set. #[cfg(feature = "secrets")] #[cfg_attr(nightly, doc(cfg(feature = "secrets")))] - fn set_private_defaults(config: &Config, cookie: &mut Cookie<'static>) { - if cookie.path().is_none() { - cookie.set_path("/"); - } - - if cookie.same_site().is_none() { - cookie.set_same_site(SameSite::Strict); - } + fn set_private_defaults(&self, cookie: &mut Cookie<'static>) { + self.set_defaults(cookie); if cookie.http_only().is_none() { cookie.set_http_only(true); @@ -573,10 +565,6 @@ impl<'a> CookieJar<'a> { if cookie.expires().is_none() { cookie.set_expires(time::OffsetDateTime::now_utc() + time::Duration::weeks(1)); } - - if cookie.secure().is_none() && config.tls_enabled() { - cookie.set_secure(true); - } } } @@ -593,5 +581,22 @@ impl fmt::Debug for CookieJar<'_> { .field("pending", &pending) .finish() } - +} + +impl<'a> Clone for CookieJar<'a> { + fn clone(&self) -> Self { + CookieJar { + jar: self.jar.clone(), + ops: Mutex::new(self.ops.lock().clone()), + state: self.state, + } + } +} + +impl Op { + fn cookie(&self) -> &Cookie<'static> { + match self { + Op::Add(c, _) | Op::Remove(c, _) => c + } + } } diff --git a/core/lib/src/local/asynchronous/response.rs b/core/lib/src/local/asynchronous/response.rs index cabbdccc..e0fbd836 100644 --- a/core/lib/src/local/asynchronous/response.rs +++ b/core/lib/src/local/asynchronous/response.rs @@ -87,8 +87,11 @@ impl<'c> LocalResponse<'c> { let request: &'c Request<'c> = unsafe { &*(&*boxed_req as *const _) }; async move { + // NOTE: The new `secure` cookie jar state will not reflect the last + // known value in `request.cookies()`. This is okay as new cookies + // should never be added to the resulting jar. let response: Response<'c> = f(request).await; - let mut cookies = CookieJar::new(request.rocket().config()); + let mut cookies = CookieJar::new(None, request.rocket()); for cookie in response.cookies() { cookies.add_original(cookie.into_owned()); } diff --git a/core/lib/src/local/client.rs b/core/lib/src/local/client.rs index 48001f05..f2b3b922 100644 --- a/core/lib/src/local/client.rs +++ b/core/lib/src/local/client.rs @@ -181,9 +181,8 @@ macro_rules! pub_client_impl { /// ``` #[inline(always)] pub fn cookies(&self) -> crate::http::CookieJar<'_> { - let config = &self.rocket().config(); let jar = self._with_raw_cookies(|jar| jar.clone()); - crate::http::CookieJar::from(jar, config) + crate::http::CookieJar::new(Some(jar), self.rocket()) } req_method!($import, "GET", get, Method::Get); diff --git a/core/lib/src/request/from_request.rs b/core/lib/src/request/from_request.rs index 0d1879cc..e183d4e4 100644 --- a/core/lib/src/request/from_request.rs +++ b/core/lib/src/request/from_request.rs @@ -6,7 +6,7 @@ use crate::{Request, Route}; use crate::outcome::{self, Outcome::*}; use crate::http::uri::{Host, Origin}; -use crate::http::{Status, ContentType, Accept, Method, CookieJar}; +use crate::http::{Status, ContentType, Accept, Method, ProxyProto, CookieJar}; /// Type alias for the `Outcome` of a `FromRequest` conversion. pub type Outcome = outcome::Outcome; @@ -160,6 +160,12 @@ pub type Outcome = outcome::Outcome; /// via [`Request::client_ip()`]. If the client's IP address is not known, /// the request is forwarded with a 500 Internal Server Error status. /// +/// * **ProxyProto** +/// +/// Extracts the protocol of the incoming request as a [`ProxyProto`] via +/// [`Request::proxy_proto()`] (HTTP or HTTPS). If value of the header is +/// not known, the request is forwarded with a 404 Not Found status. +/// /// * **SocketAddr** /// /// Extracts the remote address of the incoming request as a [`SocketAddr`] @@ -470,6 +476,18 @@ impl<'r> FromRequest<'r> for IpAddr { } } +#[crate::async_trait] +impl<'r> FromRequest<'r> for ProxyProto<'r> { + type Error = std::convert::Infallible; + + async fn from_request(request: &'r Request<'_>) -> Outcome { + match request.proxy_proto() { + Some(proto) => Success(proto), + None => Forward(Status::InternalServerError), + } + } +} + #[crate::async_trait] impl<'r> FromRequest<'r> for SocketAddr { type Error = Infallible; diff --git a/core/lib/src/request/request.rs b/core/lib/src/request/request.rs index 7f7e50e7..44630b26 100644 --- a/core/lib/src/request/request.rs +++ b/core/lib/src/request/request.rs @@ -13,9 +13,8 @@ use crate::request::{FromParam, FromSegments, FromRequest, Outcome}; use crate::form::{self, ValueField, FromForm}; use crate::data::Limits; -use crate::http::{hyper, Method, Header, HeaderMap}; +use crate::http::{hyper, Method, Header, HeaderMap, ProxyProto}; use crate::http::{ContentType, Accept, MediaType, CookieJar, Cookie}; -use crate::http::uncased::UncasedStr; use crate::http::private::Certificates; use crate::http::uri::{fmt::Path, Origin, Segments, Host, Authority}; @@ -97,7 +96,7 @@ impl<'r> Request<'r> { state: RequestState { rocket, route: Atomic::new(None), - cookies: CookieJar::new(rocket.config()), + cookies: CookieJar::new(None, rocket), accept: InitCell::new(), content_type: InitCell::new(), cache: Arc::new(::new()), @@ -386,6 +385,85 @@ impl<'r> Request<'r> { }) } + /// Returns the [`ProxyProto`] associated with the current request. + /// + /// The value is determined by inspecting the header named + /// [`proxy_proto_header`](crate::Config::proxy_proto_header), if + /// configured. If parameter isn't configured or the request doesn't contain + /// a header named as indicated, this method returns `None`. + /// + /// # Example + /// + /// ```rust + /// use rocket::http::{Header, ProxyProto}; + /// + /// # let c = rocket::local::blocking::Client::debug_with(vec![]).unwrap(); + /// # let req = c.get("/"); + /// // By default, no `proxy_proto_header` is configured. + /// let req = req.header(Header::new("x-forwarded-proto", "https")); + /// assert_eq!(req.proxy_proto(), None); + /// + /// // We can configure one by setting the `proxy_proto_header` parameter. + /// // Here we set it to `x-forwarded-proto`, considered de-facto standard. + /// # let figment = rocket::figment::Figment::from(rocket::Config::debug_default()); + /// let figment = figment.merge(("proxy_proto_header", "x-forwarded-proto")); + /// # let c = rocket::local::blocking::Client::debug(rocket::custom(figment)).unwrap(); + /// # let req = c.get("/"); + /// let req = req.header(Header::new("x-forwarded-proto", "https")); + /// assert_eq!(req.proxy_proto(), Some(ProxyProto::Https)); + /// + /// # let req = c.get("/"); + /// let req = req.header(Header::new("x-forwarded-proto", "http")); + /// assert_eq!(req.proxy_proto(), Some(ProxyProto::Http)); + /// + /// # let req = c.get("/"); + /// let req = req.header(Header::new("x-forwarded-proto", "xproto")); + /// assert_eq!(req.proxy_proto(), Some(ProxyProto::Unknown("xproto".into()))); + /// ``` + pub fn proxy_proto(&self) -> Option> { + self.rocket() + .config + .proxy_proto_header + .as_ref() + .and_then(|header| self.headers().get_one(header.as_str())) + .map(ProxyProto::from) + } + + /// Returns whether we are *likely* in a secure context. + /// + /// A request is in a "secure context" if it was initially sent over a + /// secure (TLS, via HTTPS) connection. If TLS is configured and enabled, + /// then the request is guaranteed to be in a secure context. Otherwise, if + /// [`Request::proxy_proto()`] evaluates to `Https`, then we are _likely_ to + /// be in a secure context. We say _likely_ because it is entirely possible + /// for the header to indicate that the connection is being proxied via + /// HTTPS while reality differs. As such, this value should not be trusted + /// when security is a concern. + /// + /// # Example + /// + /// ```rust + /// use rocket::http::{Header, ProxyProto}; + /// + /// # let client = rocket::local::blocking::Client::debug_with(vec![]).unwrap(); + /// # let req = client.get("/"); + /// // If TLS and proxy_proto are disabled, we are not in a secure context. + /// assert_eq!(req.context_is_likely_secure(), false); + /// + /// // Configuring proxy_proto and receiving a header value of `https` is + /// // interpreted as likely being in a secure context. + /// // Here we set it to `x-forwarded-proto`, considered de-facto standard. + /// # let figment = rocket::figment::Figment::from(rocket::Config::debug_default()); + /// let figment = figment.merge(("proxy_proto_header", "x-forwarded-proto")); + /// # let c = rocket::local::blocking::Client::debug(rocket::custom(figment)).unwrap(); + /// # let req = c.get("/"); + /// let req = req.header(Header::new("x-forwarded-proto", "https")); + /// assert_eq!(req.context_is_likely_secure(), true); + /// ``` + pub fn context_is_likely_secure(&self) -> bool { + self.cookies().state.secure + } + /// Attempts to return the client's IP address by first inspecting the /// [`ip_header`](crate::Config::ip_header) and then using the remote /// connection's IP address. Note that the built-in `IpAddr` request guard @@ -497,7 +575,7 @@ impl<'r> Request<'r> { #[inline] pub fn add_header<'h: 'r, H: Into>>(&mut self, header: H) { let header = header.into(); - self.bust_header_cache(header.name(), false); + self.bust_header_cache(&header, false); self.headers.add(header); } @@ -526,7 +604,7 @@ impl<'r> Request<'r> { #[inline] pub fn replace_header<'h: 'r, H: Into>>(&mut self, header: H) { let header = header.into(); - self.bust_header_cache(header.name(), true); + self.bust_header_cache(&header, true); self.headers.replace(header); } @@ -547,9 +625,9 @@ impl<'r> Request<'r> { /// ``` #[inline] pub fn content_type(&self) -> Option<&ContentType> { - self.state.content_type.get_or_init(|| { - self.headers().get_one("Content-Type").and_then(|v| v.parse().ok()) - }).as_ref() + self.state.content_type + .get_or_init(|| self.headers().get_one("Content-Type").and_then(|v| v.parse().ok())) + .as_ref() } /// Returns the Accept header of `self`. If the header is not present, @@ -567,9 +645,9 @@ impl<'r> Request<'r> { /// ``` #[inline] pub fn accept(&self) -> Option<&Accept> { - self.state.accept.get_or_init(|| { - self.headers().get_one("Accept").and_then(|v| v.parse().ok()) - }).as_ref() + self.state.accept + .get_or_init(|| self.headers().get_one("Accept").and_then(|v| v.parse().ok())) + .as_ref() } /// Returns the media type "format" of the request. @@ -925,15 +1003,19 @@ impl<'r> Request<'r> { #[doc(hidden)] impl<'r> Request<'r> { /// Resets the cached value (if any) for the header with name `name`. - fn bust_header_cache(&mut self, name: &UncasedStr, replace: bool) { - if name == "Content-Type" { + fn bust_header_cache(&mut self, header: &Header<'_>, replace: bool) { + if header.name() == "Content-Type" { if self.content_type().is_none() || replace { self.state.content_type = InitCell::new(); } - } else if name == "Accept" { + } else if header.name() == "Accept" { if self.accept().is_none() || replace { self.state.accept = InitCell::new(); } + } else if Some(header.name()) == self.rocket().config.proxy_proto_header.as_deref() { + if !self.cookies().state.secure || replace { + self.cookies_mut().state.secure |= ProxyProto::from(header.value()).is_https(); + } } } diff --git a/core/lib/tests/config-proxy-proto-header.rs b/core/lib/tests/config-proxy-proto-header.rs new file mode 100644 index 00000000..48dc5af3 --- /dev/null +++ b/core/lib/tests/config-proxy-proto-header.rs @@ -0,0 +1,118 @@ +use rocket::http::ProxyProto; + +#[macro_use] +extern crate rocket; + +#[get("/")] +fn inspect_proto(proto: Option) -> String { + proto + .map(|proto| match proto { + ProxyProto::Http => "http".to_owned(), + ProxyProto::Https => "https".to_owned(), + ProxyProto::Unknown(s) => s.to_string(), + }) + .unwrap_or("".to_owned()) +} + +mod tests { + use rocket::{Rocket, Build, Route}; + use rocket::http::Header; + use rocket::local::blocking::Client; + use rocket::figment::Figment; + + fn routes() -> Vec { + routes![super::inspect_proto] + } + + fn rocket_with_proto_header(header: Option<&'static str>) -> Rocket { + let mut config = rocket::Config::debug_default(); + config.proxy_proto_header = header.map(|h| h.into()); + rocket::custom(config).mount("/", routes()) + } + + #[test] + fn check_proxy_proto_header_works() { + let rocket = rocket_with_proto_header(Some("X-Url-Scheme")); + let client = Client::debug(rocket).unwrap(); + let response = client.get("/") + .header(Header::new("X-Forwarded-Proto", "https")) + .header(Header::new("X-Url-Scheme", "http")) + .dispatch(); + + assert_eq!(response.into_string().unwrap(), "http"); + + let response = client.get("/") + .header(Header::new("X-Url-Scheme", "https")) + .dispatch(); + + assert_eq!(response.into_string().unwrap(), "https"); + + let response = client.get("/").dispatch(); + assert_eq!(response.into_string().unwrap(), ""); + } + + #[test] + fn check_proxy_proto_header_works_again() { + let client = Client::debug(rocket_with_proto_header(Some("x-url-scheme"))).unwrap(); + let response = client + .get("/") + .header(Header::new("X-Url-Scheme", "https")) + .dispatch(); + + assert_eq!(response.into_string().unwrap(), "https"); + + let config = Figment::from(rocket::Config::debug_default()) + .merge(("proxy_proto_header", "x-url-scheme")); + + let client = Client::debug(rocket::custom(config).mount("/", routes())).unwrap(); + let response = client + .get("/") + .header(Header::new("X-url-Scheme", "https")) + .dispatch(); + + assert_eq!(response.into_string().unwrap(), "https"); + } + + #[test] + fn check_default_proxy_proto_header_works() { + let client = Client::debug_with(routes()).unwrap(); + let response = client + .get("/") + .header(Header::new("X-Forwarded-Proto", "https")) + .dispatch(); + + assert_eq!(response.into_string(), Some("".into())); + } + + #[test] + fn check_no_proxy_proto_header_works() { + let client = Client::debug(rocket_with_proto_header(None)).unwrap(); + let response = client.get("/") + .header(Header::new("X-Forwarded-Proto", "https")) + .dispatch(); + + assert_eq!(response.into_string(), Some("".into())); + + let config = + Figment::from(rocket::Config::debug_default()).merge(("proxy_proto_header", false)); + + let client = Client::debug(rocket::custom(config).mount("/", routes())).unwrap(); + let response = client + .get("/") + .header(Header::new("X-Forwarded-Proto", "https")) + .dispatch(); + + assert_eq!(response.into_string(), Some("".into())); + + let config = Figment::from(rocket::Config::debug_default()) + .merge(("proxy_proto_header", "x-forwarded-proto")); + + let client = Client::debug(rocket::custom(config).mount("/", routes())).unwrap(); + let response = client + .get("/") + .header(Header::new("x-Forwarded-Proto", "https")) + .dispatch(); + + assert_eq!(response.into_string(), Some("https".into())); + } +} diff --git a/site/guide/9-configuration.md b/site/guide/9-configuration.md index 76b14a9a..5cd2c51e 100644 --- a/site/guide/9-configuration.md +++ b/site/guide/9-configuration.md @@ -17,28 +17,31 @@ is configured with. This means that no matter which configuration provider Rocket is asked to use, it must be able to read the following configuration values: -| key | kind | description | debug/release default | -|-----------------|-------------------|-------------------------------------------------|-------------------------| -| `address` | `IpAddr` | IP address to serve on | `127.0.0.1` | -| `port` | `u16` | Port to serve on. | `8000` | -| `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` | -| `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` | -| `log_level` | [`LogLevel`] | Max level to log. (off/normal/debug/critical) | `normal`/`critical` | -| `cli_colors` | [`CliColors`] | Whether to use colors and emoji when logging. | `"auto"` | -| `secret_key` | [`SecretKey`] | Secret key for signing and encrypting values. | `None` | -| `tls` | [`TlsConfig`] | TLS configuration, if any. | `None` | -| `limits` | [`Limits`] | Streaming read size limits. | [`Limits::default()`] | -| `limits.$name` | `&str`/`uint` | Read limit for `$name`. | form = "32KiB" | -| `ctrlc` | `bool` | Whether `ctrl-c` initiates a server shutdown. | `true` | -| `shutdown`* | [`Shutdown`] | Graceful shutdown configuration. | [`Shutdown::default()`] | +| key | kind | description | debug/release default | +|----------------------|-------------------|-------------------------------------------------|-------------------------| +| `address` | `IpAddr` | IP address to serve on. | `127.0.0.1` | +| `port` | `u16` | Port to serve on. | `8000` | +| `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` | +| `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"` | +| `proxy_proto_header` | `string`, `false` | Header identifying [client to proxy protocol]. | `None` | +| `keep_alive` | `u32` | Keep-alive timeout seconds; disabled when `0`. | `5` | +| `log_level` | [`LogLevel`] | Max level to log. (off/normal/debug/critical) | `normal`/`critical` | +| `cli_colors` | [`CliColors`] | Whether to use colors and emoji when logging. | `"auto"` | +| `secret_key` | [`SecretKey`] | Secret key for signing and encrypting values. | `None` | +| `tls` | [`TlsConfig`] | TLS configuration, if any. | `None` | +| `limits` | [`Limits`] | Streaming read size limits. | [`Limits::default()`] | +| `limits.$name` | `&str`/`uint` | Read limit for `$name`. | form = "32KiB" | +| `ctrlc` | `bool` | Whether `ctrl-c` initiates a server shutdown. | `true` | +| `shutdown`* | [`Shutdown`] | Graceful shutdown configuration. | [`Shutdown::default()`] | + * Note: the `workers`, `max_blocking`, and `shutdown.force` configuration parameters are only read from the [default provider](#default-provider). [client's real IP]: @api/rocket/request/struct.Request.html#method.real_ip +[client to proxy protocol]: @api/rocket/request/struct.Request.html#method.proxy_proto ### Profiles @@ -130,6 +133,7 @@ port = 9001 [release] port = 9999 ip_header = false +proxy_proto_header = "X-Forwarded-Proto" # NOTE: Don't (!) use this key! Generate your own and keep it private! # e.g. via `head -c64 /dev/urandom | base64` secret_key = "hPrYyЭRiMyµ5sBB1π+CMæ1køFsåqKvBiQJxBVHQk=" @@ -148,7 +152,8 @@ workers = 16 max_blocking = 512 keep_alive = 5 ident = "Rocket" -ip_header = "X-Real-IP" # set to `false` to disable +ip_header = "X-Real-IP" # set to `false` or `None` to disable +proxy_proto_header = `false` # set to `false` or `None` to disable log_level = "normal" temp_dir = "/tmp" cli_colors = true @@ -358,6 +363,25 @@ mutual TLS. [`mtls::Certificate`]: @api/rocket/mtls/struct.Certificate.html +### Proxied TLS + +If Rocket is running behind a reverse proxy that terminates TLS, it is useful to +know whether the original connection was made securely. Therefore, Rocket offers +the option to configure a `proxy_proto_header` that is used to determine if the +request is handled in a secure context. The outcome is available via +[`Request::context_is_likely_secure()`] and used to set cookies' secure flag by +default. To enable this behaviour, configure the header as set by your reverse +proxy. For example: + +```toml,ignore +proxy_proto_header = 'X-Forwarded-Proto' +``` + +Note that this only sets the cookies' secure flag when not configured +explicitly. This setting also provides the [request guard `ProxyProto`]. + +[`ProxyProto`]: @api/rocket/request/trait.FromRequest.html#impl-FromRequest%3C'r%3E-for-%26ProxyProto + ### Workers The `workers` parameter sets the number of threads used for parallel task