From 5283dae19623e9148a53cfd45435c229c15a7851 Mon Sep 17 00:00:00 2001 From: Jacob Rothstein Date: Tue, 14 Jul 2020 16:13:44 -0700 Subject: [PATCH] Tide sessions --- Cargo.toml | 5 +- examples/sessions.rs | 39 +++++ src/lib.rs | 3 + src/request.rs | 32 ++++ src/sessions/middleware.rs | 290 +++++++++++++++++++++++++++++++++++++ src/sessions/mod.rs | 76 ++++++++++ tests/sessions.rs | 217 +++++++++++++++++++++++++++ tests/test_utils.rs | 2 +- 8 files changed, 661 insertions(+), 3 deletions(-) create mode 100644 examples/sessions.rs create mode 100644 src/sessions/middleware.rs create mode 100644 src/sessions/mod.rs create mode 100644 tests/sessions.rs diff --git a/Cargo.toml b/Cargo.toml index 3019ba618..40f21f912 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,16 +24,18 @@ features = ["docs"] rustdoc-args = ["--cfg", "feature=\"docs\""] [features] -default = ["h1-server", "logger"] +default = ["h1-server", "logger", "sessions"] h1-server = ["async-h1"] logger = [] docs = ["unstable"] +sessions = ["async-session"] unstable = [] # DO NOT USE. Only exists to expose internals so they can be benchmarked. __internal__bench = [] [dependencies] async-h1 = { version = "2.0.1", optional = true } +async-session = { version = "2.0.0", optional = true } async-sse = "4.0.0" async-std = { version = "1.6.0", features = ["unstable"] } async-trait = "0.1.36" @@ -65,4 +67,3 @@ required-features = ["unstable"] [[bench]] name = "router" harness = false - diff --git a/examples/sessions.rs b/examples/sessions.rs new file mode 100644 index 000000000..9cb21b959 --- /dev/null +++ b/examples/sessions.rs @@ -0,0 +1,39 @@ +#[async_std::main] +async fn main() -> Result<(), std::io::Error> { + tide::log::start(); + let mut app = tide::new(); + + app.middleware(tide::sessions::SessionMiddleware::new( + tide::sessions::MemoryStore::new(), + std::env::var("TIDE_SECRET") + .expect( + "Please provide a TIDE_SECRET value of at \ + least 32 bytes in order to run this example", + ) + .as_bytes(), + )); + + app.middleware(tide::utils::Before( + |mut request: tide::Request<()>| async move { + let session = request.session_mut(); + let visits: usize = session.get("visits").unwrap_or_default(); + session.insert("visits", visits + 1).unwrap(); + request + }, + )); + + app.at("/").get(|req: tide::Request<()>| async move { + let visits: usize = req.session().get("visits").unwrap(); + Ok(format!("you have visited this website {} times", visits)) + }); + + app.at("/reset") + .get(|mut req: tide::Request<()>| async move { + req.session_mut().destroy(); + Ok(tide::Redirect::new("/")) + }); + + app.listen("127.0.0.1:8080").await?; + + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index 07cd85aab..4312ac771 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -215,6 +215,9 @@ pub mod security; pub mod sse; pub mod utils; +#[cfg(feature = "sessions")] +pub mod sessions; + pub use endpoint::Endpoint; pub use middleware::{Middleware, Next}; pub use redirect::Redirect; diff --git a/src/request.rs b/src/request.rs index bb2cd83b2..77a404c15 100644 --- a/src/request.rs +++ b/src/request.rs @@ -260,6 +260,12 @@ impl Request { self.req.ext().get() } + /// Get a mutable reference to value stored in request extensions. + #[must_use] + pub fn ext_mut(&mut self) -> Option<&mut T> { + self.req.ext_mut().get_mut() + } + /// Set a request extension value. pub fn set_ext(&mut self, val: T) -> Option { self.req.ext_mut().insert(val) @@ -506,6 +512,32 @@ impl Request { .and_then(|cookie_data| cookie_data.content.read().unwrap().get(name).cloned()) } + /// Retrieves a reference to the current session. + /// + /// # Panics + /// + /// This method will panic if a tide::sessions:SessionMiddleware has not + /// been run. + #[cfg(feature = "sessions")] + pub fn session(&self) -> &crate::sessions::Session { + self.ext::().expect( + "request session not initialized, did you enable tide::sessions::SessionMiddleware?", + ) + } + + /// Retrieves a mutable reference to the current session. + /// + /// # Panics + /// + /// This method will panic if a tide::sessions:SessionMiddleware has not + /// been run. + #[cfg(feature = "sessions")] + pub fn session_mut(&mut self) -> &mut crate::sessions::Session { + self.ext_mut().expect( + "request session not initialized, did you enable tide::sessions::SessionMiddleware?", + ) + } + /// Get the length of the body stream, if it has been set. /// /// This value is set when passing a fixed-size object into as the body. E.g. a string, or a diff --git a/src/sessions/middleware.rs b/src/sessions/middleware.rs new file mode 100644 index 000000000..2c6eebe52 --- /dev/null +++ b/src/sessions/middleware.rs @@ -0,0 +1,290 @@ +use super::{Session, SessionStore}; +use crate::http::{ + cookies::{Cookie, Key, SameSite}, + format_err, +}; +use crate::{utils::async_trait, Middleware, Next, Request}; +use std::time::Duration; + +use async_session::{ + base64, + hmac::{Hmac, Mac, NewMac}, + sha2::Sha256, +}; + +const BASE64_DIGEST_LEN: usize = 44; + +/// # Middleware to enable sessions. +/// See [sessions](crate::sessions) for an overview of tide's approach to sessions. +/// +/// ## Example +/// ```rust +/// # async_std::task::block_on(async { +/// let mut app = tide::new(); +/// +/// app.middleware(tide::sessions::SessionMiddleware::new( +/// tide::sessions::MemoryStore::new(), +/// b"we recommend you use std::env::var(\"TIDE_SECRET\").unwrap().as_bytes() instead of a fixed value" +/// )); +/// +/// app.middleware(tide::utils::Before(|mut request: tide::Request<()>| async move { +/// let session = request.session_mut(); +/// let visits: usize = session.get("visits").unwrap_or_default(); +/// session.insert("visits", visits + 1).unwrap(); +/// request +/// })); +/// +/// app.at("/").get(|req: tide::Request<()>| async move { +/// let visits: usize = req.session().get("visits").unwrap(); +/// Ok(format!("you have visited this website {} times", visits)) +/// }); +/// +/// app.at("/reset") +/// .get(|mut req: tide::Request<()>| async move { +/// req.session_mut().destroy(); +/// Ok(tide::Redirect::new("/")) +/// }); +/// # }) +/// ``` + +pub struct SessionMiddleware { + store: Store, + cookie_path: String, + cookie_name: String, + session_ttl: Option, + save_unchanged: bool, + same_site_policy: SameSite, + key: Key, +} + +impl std::fmt::Debug for SessionMiddleware { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SessionMiddleware") + .field("store", &self.store) + .field("cookie_path", &self.cookie_path) + .field("cookie_name", &self.cookie_name) + .field("session_ttl", &self.session_ttl) + .field("same_site_policy", &self.same_site_policy) + .field("key", &"..") + .field("save_unchanged", &self.save_unchanged) + .finish() + } +} + +#[async_trait] +impl Middleware for SessionMiddleware +where + Store: SessionStore, + State: Clone + Send + Sync + 'static, +{ + async fn handle(&self, mut request: Request, next: Next<'_, State>) -> crate::Result { + let cookie = request.cookie(&self.cookie_name); + let cookie_value = cookie + .clone() + .and_then(|cookie| self.verify_signature(cookie.value()).ok()); + + let mut session = self.load_or_create(cookie_value).await; + + if let Some(ttl) = self.session_ttl { + session.expire_in(ttl); + } + + let secure_cookie = request.url().scheme() == "https"; + request.set_ext(session.clone()); + + let mut response = next.run(request).await; + + if session.is_destroyed() { + if let Err(e) = self.store.destroy_session(session).await { + crate::log::error!("unable to destroy session", { error: e.to_string() }); + } + + if let Some(mut cookie) = cookie { + cookie.set_path("/"); + response.remove_cookie(cookie); + } + } else if self.save_unchanged || session.data_changed() { + if let Some(cookie_value) = self + .store + .store_session(session) + .await + .map_err(|e| format_err!("{}", e.to_string()))? + { + let cookie = self.build_cookie(secure_cookie, cookie_value); + response.insert_cookie(cookie); + } + } + + Ok(response) + } +} + +impl SessionMiddleware { + /// Creates a new SessionMiddleware with a mandatory cookie + /// signing secret. The `secret` MUST be at least 32 bytes long, + /// and MUST be cryptographically random to be secure. It is + /// recommended to retrieve this at runtime from the environment + /// instead of compiling it into your + /// application. + /// + /// # Panics + /// + /// SessionMiddleware::new will panic if the secret is fewer than + /// 32 bytes. + /// + /// # Defaults + /// + /// The defaults for SessionMiddleware are: + /// * cookie path: "/" + /// * cookie name: "tide.sid" + /// * session ttl: one day + /// * same site: strict + /// * save unchanged: enabled + /// + /// # Customization + /// + /// Although the above defaults are appropriate for most + /// applications, they can be overridden. Please be careful + /// changing these settings, as they can weaken your application's + /// security: + /// + /// ```rust + /// # use tide::http::cookies::SameSite; + /// # use std::time::Duration; + /// # use tide::sessions::{SessionMiddleware, MemoryStore}; + /// let mut app = tide::new(); + /// app.middleware( + /// SessionMiddleware::new(MemoryStore::new(), b"please do not hardcode your secret") + /// .with_cookie_name("custom.cookie.name") + /// .with_cookie_path("/some/path") + /// .with_same_site_policy(SameSite::Lax) + /// .with_session_ttl(Some(Duration::from_secs(1))) + /// .without_save_unchanged(), + /// ); + /// ``` + pub fn new(store: Store, secret: &[u8]) -> Self { + Self { + store, + save_unchanged: true, + cookie_path: "/".into(), + cookie_name: "tide.sid".into(), + same_site_policy: SameSite::Strict, + session_ttl: Some(Duration::from_secs(24 * 60 * 60)), + key: Key::derive_from(secret), + } + } + + /// Sets a cookie path for this session middleware. + /// The default for this value is "/" + pub fn with_cookie_path(mut self, cookie_path: impl AsRef) -> Self { + self.cookie_path = cookie_path.as_ref().to_owned(); + self + } + + /// Sets a session ttl. This will be used both for the cookie + /// expiry and also for the session-internal expiry. + /// + /// The default for this value is one day. Set this to None to not + /// set a cookie or session expiry. This is not recommended. + pub fn with_session_ttl(mut self, session_ttl: Option) -> Self { + self.session_ttl = session_ttl; + self + } + + /// Sets the name of the cookie that the session is stored with or in. + /// + /// If you are running multiple tide applications on the same + /// domain, you will need different values for each + /// application. The default value is "tide.sid" + pub fn with_cookie_name(mut self, cookie_name: impl AsRef) -> Self { + self.cookie_name = cookie_name.as_ref().to_owned(); + self + } + + /// Disables the `save_unchanged` setting. When `save_unchanged` + /// is enabled, a session will cookie will always be set. With + /// `save_unchanged` disabled, the session data must be modified + /// from the `Default` value in order for it to save. If a session + /// already exists and its data unmodified in the course of a + /// request, the session will only be persisted if + /// `save_unchanged` is enabled. + pub fn without_save_unchanged(mut self) -> Self { + self.save_unchanged = false; + self + } + + /// Sets the same site policy for the session cookie. Defaults to + /// SameSite::Strict. See [incrementally better + /// cookies](https://tools.ietf.org/html/draft-west-cookie-incrementalism-01) + /// for more information about this setting + pub fn with_same_site_policy(mut self, policy: SameSite) -> Self { + self.same_site_policy = policy; + self + } + + //--- methods below here are private --- + + async fn load_or_create(&self, cookie_value: Option) -> Session { + let session = match cookie_value { + Some(cookie_value) => self.store.load_session(cookie_value).await.ok().flatten(), + None => None, + }; + + session + .and_then(|session| session.validate()) + .unwrap_or_default() + } + + fn build_cookie(&self, secure: bool, cookie_value: String) -> Cookie<'static> { + let mut cookie = Cookie::build(self.cookie_name.clone(), cookie_value) + .http_only(true) + .same_site(self.same_site_policy) + .secure(secure) + .path(self.cookie_path.clone()) + .finish(); + + if let Some(ttl) = self.session_ttl { + cookie.set_expires(Some((std::time::SystemTime::now() + ttl).into())); + } + + self.sign_cookie(&mut cookie); + + cookie + } + + // the following is reused verbatim from + // https://github.com/SergioBenitez/cookie-rs/blob/master/src/secure/signed.rs#L33-L43 + /// Signs the cookie's value providing integrity and authenticity. + fn sign_cookie(&self, cookie: &mut Cookie<'_>) { + // Compute HMAC-SHA256 of the cookie's value. + let mut mac = Hmac::::new_varkey(&self.key.signing()).expect("good key"); + mac.update(cookie.value().as_bytes()); + + // Cookie's new value is [MAC | original-value]. + let mut new_value = base64::encode(&mac.finalize().into_bytes()); + new_value.push_str(cookie.value()); + cookie.set_value(new_value); + } + + // the following is reused verbatim from + // https://github.com/SergioBenitez/cookie-rs/blob/master/src/secure/signed.rs#L45-L63 + /// Given a signed value `str` where the signature is prepended to `value`, + /// verifies the signed value and returns it. If there's a problem, returns + /// an `Err` with a string describing the issue. + fn verify_signature(&self, cookie_value: &str) -> Result { + if cookie_value.len() < BASE64_DIGEST_LEN { + return Err("length of value is <= BASE64_DIGEST_LEN"); + } + + // Split [MAC | original-value] into its two parts. + let (digest_str, value) = cookie_value.split_at(BASE64_DIGEST_LEN); + let digest = base64::decode(digest_str).map_err(|_| "bad base64 digest")?; + + // Perform the verification. + let mut mac = Hmac::::new_varkey(&self.key.signing()).expect("good key"); + mac.update(value.as_bytes()); + mac.verify(&digest) + .map(|_| value.to_string()) + .map_err(|_| "value did not verify") + } +} diff --git a/src/sessions/mod.rs b/src/sessions/mod.rs new file mode 100644 index 000000000..55784ee5a --- /dev/null +++ b/src/sessions/mod.rs @@ -0,0 +1,76 @@ +//! # Tide session support +//! +//! This document provides a high-level overview of tide's approach to +//! sessions. For implementation and examples, please refer to +//! [SessionMiddleware](crate::sessions::SessionMiddleware) +//! +//! Sessions allows tide to securely attach data to a browser session +//! allowing for retrieval and modification of this data within tide +//! on subsequent visits. Session data is generally only retained for +//! the duration of a browser session. +//! +//! Tide's session implementation provides guest sessions by default, +//! meaning that all web requests to a session-enabled tide host will +//! have a cookie attached, whether or not there is anything stored in +//! that client's session yet. +//! +//! ## Stores +//! +//! Although tide provides two bundled session stores, it is highly +//! recommended that tide applications use an +//! external-datastore-backed session storage. For a list of currently +//! available session stores, see [the documentation for +//! async-session](https://github.com/http-rs/async-session). +//! +//! ## Security +//! +//! Although each session store may have different security +//! implications, the general approach of tide's session system is as +//! follows: On each request, tide checks the cookie configurable as +//! `cookie_name` on the middleware. +//! +//! ### If no cookie is found: +//! +//! A cryptographically random cookie value is generated. A cookie is +//! set on the outbound response and signed with an HKDF key derived +//! from the `secret` provided on creation of the SessionMiddleware. +//! The configurable session store uses a SHA256 digest of the cookie +//! value and stores the session along with a potential expiry. +//! +//! ### If a cookie is found: +//! +//! The hkdf derived signing key is used to verify the cookie value's +//! signature. If it verifies, it is then passed to the session store +//! to retrieve a Session. For most session stores, this will involve +//! taking a SHA256 digest of the cookie value and retrieving a +//! serialized Session from an external datastore based on that +//! digest. +//! +//! ### Expiry +//! +//! In addition to setting an expiry on the session cookie, tide +//! sessions include the same expiry in their serialization format. If +//! an adversary were able to tamper with the expiry of a cookie, tide +//! sessions would still check the expiry on the contained session +//! before using it +//! +//! ### If anything goes wrong with the above process +//! +//! If there are any failures in the above session retrieval process, +//! a new empty session is generated for the request, which proceeds +//! through the application as normal. +//! +//! ## Stale/expired session cleanup +//! +//! Any session store other than the cookie store will accumulate +//! stale sessions. Although the tide session middleware ensures that +//! they will not be used as valid sessions, For most session stores, +//! it is the tide application's responsibility to call cleanup on the +//! session store if it requires it +//! + +pub use middleware::SessionMiddleware; + +mod middleware; + +pub use async_session::{CookieStore, MemoryStore, Session, SessionStore}; diff --git a/tests/sessions.rs b/tests/sessions.rs new file mode 100644 index 000000000..0db32b0b5 --- /dev/null +++ b/tests/sessions.rs @@ -0,0 +1,217 @@ +mod test_utils; +use test_utils::ServerTestingExt; + +use cookie::SameSite; +use std::time::Duration; +use tide::{ + http::{ + cookies as cookie, + headers::HeaderValue, + Method::{Get, Post}, + Request, Response, Url, + }, + sessions::{MemoryStore, SessionMiddleware}, + utils::Before, +}; +#[derive(Clone, Debug, Default, PartialEq)] +struct SessionData { + visits: usize, +} + +#[async_std::test] +async fn test_basic_sessions() { + let mut app = tide::new(); + app.middleware(SessionMiddleware::new( + MemoryStore::new(), + b"12345678901234567890123456789012345", + )); + + app.middleware(Before(|mut request: tide::Request<()>| async move { + let visits: usize = request.session().get("visits").unwrap_or_default(); + request.session_mut().insert("visits", visits + 1).unwrap(); + request + })); + + app.at("/").get(|req: tide::Request<()>| async move { + let visits: usize = req.session().get("visits").unwrap(); + Ok(format!("you have visited this website {} times", visits)) + }); + + let response = app.get("/").await; + let cookies = Cookies::from_response(&response); + let cookie = &cookies["tide.sid"]; + assert_eq!(cookie.name(), "tide.sid"); + assert_eq!(cookie.http_only(), Some(true)); + assert_eq!(cookie.same_site(), Some(SameSite::Strict)); + assert_eq!(cookie.secure(), None); // this request was http:// + assert_eq!(cookie.path(), Some("/")); + + let mut second_request = Request::new(Get, Url::parse("https://whatever/").unwrap()); + second_request.insert_header("Cookie", &cookies); + let mut second_response: Response = app.respond(second_request).await.unwrap(); + let body = second_response.body_string().await.unwrap(); + assert_eq!("you have visited this website 2 times", body); + assert!(second_response.header("Set-Cookie").is_none()); + + let response = app.get("https://secure/").await; + let cookies = Cookies::from_response(&response); + let cookie = &cookies["tide.sid"]; + assert_eq!(cookie.secure(), Some(true)); +} + +#[async_std::test] +async fn test_customized_sessions() { + let mut app = tide::new(); + app.middleware( + SessionMiddleware::new(MemoryStore::new(), b"12345678901234567890123456789012345") + .with_cookie_name("custom.cookie.name") + .with_cookie_path("/nested") + .with_same_site_policy(SameSite::Lax) + .with_session_ttl(Some(Duration::from_secs(1))) + .without_save_unchanged(), + ); + + app.at("/").get(|_| async { Ok("/") }); + app.at("/nested").get(|req: tide::Request<()>| async move { + Ok(format!( + "/nested {}", + req.session().get::("visits").unwrap_or_default() + )) + }); + app.at("/nested/incr") + .get(|mut req: tide::Request<()>| async move { + let mut visits: usize = req.session().get("visits").unwrap_or_default(); + visits += 1; + req.session_mut().insert("visits", visits).unwrap(); + Ok(format!("/nested/incr {}", visits)) + }); + + let response = app.get("/").await; + assert_eq!(Cookies::from_response(&response).len(), 0); + + let mut response = app.get("/nested").await; + assert_eq!(Cookies::from_response(&response).len(), 0); + assert_eq!(response.body_string().await.unwrap(), "/nested 0"); + + let mut response = app.get("/nested/incr").await; + let cookies = Cookies::from_response(&response); + assert_eq!(response.body_string().await.unwrap(), "/nested/incr 1"); + + assert_eq!(cookies.len(), 1); + assert!(cookies.get("tide.sid").is_none()); + let cookie = &cookies["custom.cookie.name"]; + assert_eq!(cookie.http_only(), Some(true)); + assert_eq!(cookie.same_site(), Some(SameSite::Lax)); + assert_eq!(cookie.path(), Some("/nested")); + let cookie_value = cookie.value().to_string(); + + let mut second_request = Request::new(Get, Url::parse("https://whatever/nested/incr").unwrap()); + second_request.insert_header("Cookie", &cookies); + let mut second_response: Response = app.respond(second_request).await.unwrap(); + let body = second_response.body_string().await.unwrap(); + assert_eq!("/nested/incr 2", body); + assert!(second_response.header("Set-Cookie").is_none()); + + async_std::task::sleep(Duration::from_secs(5)).await; // wait for expiration + + let mut expired_request = + Request::new(Get, Url::parse("https://whatever/nested/incr").unwrap()); + expired_request.insert_header("Cookie", &cookies); + let mut expired_response: Response = app.respond(expired_request).await.unwrap(); + let cookies = Cookies::from_response(&expired_response); + assert_eq!(cookies.len(), 1); + assert!(cookies["custom.cookie.name"].value() != cookie_value); + + let body = expired_response.body_string().await.unwrap(); + assert_eq!("/nested/incr 1", body); +} + +#[async_std::test] +async fn test_session_destruction() { + let mut app = tide::new(); + app.middleware(SessionMiddleware::new( + MemoryStore::new(), + b"12345678901234567890123456789012345", + )); + + app.middleware(Before(|mut request: tide::Request<()>| async move { + let visits: usize = request.session().get("visits").unwrap_or_default(); + request.session_mut().insert("visits", visits + 1).unwrap(); + request + })); + + app.at("/").get(|req: tide::Request<()>| async move { + let visits: usize = req.session().get("visits").unwrap(); + Ok(format!("you have visited this website {} times", visits)) + }); + + app.at("/logout") + .post(|mut req: tide::Request<()>| async move { + req.session_mut().destroy(); + Ok(Response::new(200)) + }); + + let response = app.get("/").await; + let cookies = Cookies::from_response(&response); + + let mut second_request = Request::new(Post, Url::parse("https://whatever/logout").unwrap()); + second_request.insert_header("Cookie", &cookies); + let second_response: Response = app.respond(second_request).await.unwrap(); + let cookies = Cookies::from_response(&second_response); + assert_eq!(cookies["tide.sid"].value(), ""); + assert_eq!(cookies.len(), 1); +} + +#[derive(Debug, Clone)] +struct Cookies(Vec>); +impl Cookies { + fn len(&self) -> usize { + self.0.len() + } + + fn from_response(response: &http_types::Response) -> Self { + response + .header("Set-Cookie") + .map(|hv| hv.to_string()) + .unwrap_or_else(|| "[]".into()) + .parse() + .unwrap() + } + + fn get<'a>(&'a self, name: &str) -> Option<&'a tide::http::Cookie<'static>> { + self.0.iter().find(|cookie| cookie.name() == name) + } +} +impl tide::http::headers::ToHeaderValues for &Cookies { + type Iter = std::iter::Once; + fn to_header_values(&self) -> http_types::Result { + let value = self + .0 + .iter() + .map(|cookie| format!("{}={}", cookie.name(), cookie.value())) + .collect::>() + .join("; "); + Ok(std::iter::once(HeaderValue::from_bytes(value.into())?)) + } +} + +impl std::ops::Index<&str> for Cookies { + type Output = cookie::Cookie<'static>; + fn index(&self, index: &str) -> &Self::Output { + self.get(index).unwrap() + } +} + +impl std::str::FromStr for Cookies { + type Err = std::convert::Infallible; + fn from_str(s: &str) -> Result { + let strings: Vec = serde_json::from_str(s).unwrap_or_default(); + + Ok(Self( + strings + .iter() + .filter_map(|cookie| cookie::Cookie::parse(cookie.to_owned()).ok()) + .collect(), + )) + } +} diff --git a/tests/test_utils.rs b/tests/test_utils.rs index cf2961e3a..42152d14f 100644 --- a/tests/test_utils.rs +++ b/tests/test_utils.rs @@ -24,7 +24,7 @@ where State: Clone + Send + Sync + 'static, { async fn request(&self, method: Method, path: &str) -> http::Response { - let url = if path.starts_with("http:") { + let url = if path.starts_with("http:") || path.starts_with("https:") { Url::parse(path).unwrap() } else { Url::parse("http://example.com/")