mirror of
				https://github.com/dani-garcia/vaultwarden.git
				synced 2025-10-28 00:40:01 +02:00 
			
		
		
		
	Improved HTTP client (#4740)
* Improved HTTP client * Change config compat to use auto, rename blacklist * Fix wrong doc references
This commit is contained in:
		| @@ -320,15 +320,15 @@ | |||||||
| ## The default is 10 seconds, but this could be to low on slower network connections | ## The default is 10 seconds, but this could be to low on slower network connections | ||||||
| # ICON_DOWNLOAD_TIMEOUT=10 | # ICON_DOWNLOAD_TIMEOUT=10 | ||||||
|  |  | ||||||
| ## Icon blacklist Regex | ## Block HTTP domains/IPs by Regex | ||||||
| ## Any domains or IPs that match this regex won't be fetched by the icon service. | ## Any domains or IPs that match this regex won't be fetched by the internal HTTP client. | ||||||
| ## Useful to hide other servers in the local network. Check the WIKI for more details | ## Useful to hide other servers in the local network. Check the WIKI for more details | ||||||
| ## NOTE: Always enclose this regex withing single quotes! | ## NOTE: Always enclose this regex withing single quotes! | ||||||
| # ICON_BLACKLIST_REGEX='^(192\.168\.0\.[0-9]+|192\.168\.1\.[0-9]+)$' | # HTTP_REQUEST_BLOCK_REGEX='^(192\.168\.0\.[0-9]+|192\.168\.1\.[0-9]+)$' | ||||||
|  |  | ||||||
| ## Any IP which is not defined as a global IP will be blacklisted. | ## Enabling this will cause the internal HTTP client to refuse to connect to any non global IP address. | ||||||
| ## Useful to secure your internal environment: See https://en.wikipedia.org/wiki/Reserved_IP_addresses for a list of IPs which it will block | ## Useful to secure your internal environment: See https://en.wikipedia.org/wiki/Reserved_IP_addresses for a list of IPs which it will block | ||||||
| # ICON_BLACKLIST_NON_GLOBAL_IPS=true | # HTTP_REQUEST_BLOCK_NON_GLOBAL_IPS=true | ||||||
|  |  | ||||||
| ## Client Settings | ## Client Settings | ||||||
| ## Enable experimental feature flags for clients. | ## Enable experimental feature flags for clients. | ||||||
|   | |||||||
| @@ -1,4 +1,5 @@ | |||||||
| use once_cell::sync::Lazy; | use once_cell::sync::Lazy; | ||||||
|  | use reqwest::Method; | ||||||
| use serde::de::DeserializeOwned; | use serde::de::DeserializeOwned; | ||||||
| use serde_json::Value; | use serde_json::Value; | ||||||
| use std::env; | use std::env; | ||||||
| @@ -21,10 +22,10 @@ use crate::{ | |||||||
|     config::ConfigBuilder, |     config::ConfigBuilder, | ||||||
|     db::{backup_database, get_sql_server_version, models::*, DbConn, DbConnType}, |     db::{backup_database, get_sql_server_version, models::*, DbConn, DbConnType}, | ||||||
|     error::{Error, MapResult}, |     error::{Error, MapResult}, | ||||||
|  |     http_client::make_http_request, | ||||||
|     mail, |     mail, | ||||||
|     util::{ |     util::{ | ||||||
|         container_base_image, format_naive_datetime_local, get_display_size, get_reqwest_client, |         container_base_image, format_naive_datetime_local, get_display_size, is_running_in_container, NumberOrString, | ||||||
|         is_running_in_container, NumberOrString, |  | ||||||
|     }, |     }, | ||||||
|     CONFIG, VERSION, |     CONFIG, VERSION, | ||||||
| }; | }; | ||||||
| @@ -594,15 +595,15 @@ struct TimeApi { | |||||||
| } | } | ||||||
|  |  | ||||||
| async fn get_json_api<T: DeserializeOwned>(url: &str) -> Result<T, Error> { | async fn get_json_api<T: DeserializeOwned>(url: &str) -> Result<T, Error> { | ||||||
|     let json_api = get_reqwest_client(); |     Ok(make_http_request(Method::GET, url)?.send().await?.error_for_status()?.json::<T>().await?) | ||||||
|  |  | ||||||
|     Ok(json_api.get(url).send().await?.error_for_status()?.json::<T>().await?) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| async fn has_http_access() -> bool { | async fn has_http_access() -> bool { | ||||||
|     let http_access = get_reqwest_client(); |     let req = match make_http_request(Method::HEAD, "https://github.com/dani-garcia/vaultwarden") { | ||||||
|  |         Ok(r) => r, | ||||||
|     match http_access.head("https://github.com/dani-garcia/vaultwarden").send().await { |         Err(_) => return false, | ||||||
|  |     }; | ||||||
|  |     match req.send().await { | ||||||
|         Ok(r) => r.status().is_success(), |         Ok(r) => r.status().is_success(), | ||||||
|         _ => false, |         _ => false, | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -12,6 +12,7 @@ pub use accounts::purge_auth_requests; | |||||||
| pub use ciphers::{purge_trashed_ciphers, CipherData, CipherSyncData, CipherSyncType}; | pub use ciphers::{purge_trashed_ciphers, CipherData, CipherSyncData, CipherSyncType}; | ||||||
| pub use emergency_access::{emergency_notification_reminder_job, emergency_request_timeout_job}; | pub use emergency_access::{emergency_notification_reminder_job, emergency_request_timeout_job}; | ||||||
| pub use events::{event_cleanup_job, log_event, log_user_event}; | pub use events::{event_cleanup_job, log_event, log_user_event}; | ||||||
|  | use reqwest::Method; | ||||||
| pub use sends::purge_sends; | pub use sends::purge_sends; | ||||||
|  |  | ||||||
| pub fn routes() -> Vec<Route> { | pub fn routes() -> Vec<Route> { | ||||||
| @@ -53,7 +54,8 @@ use crate::{ | |||||||
|     auth::Headers, |     auth::Headers, | ||||||
|     db::DbConn, |     db::DbConn, | ||||||
|     error::Error, |     error::Error, | ||||||
|     util::{get_reqwest_client, parse_experimental_client_feature_flags}, |     http_client::make_http_request, | ||||||
|  |     util::parse_experimental_client_feature_flags, | ||||||
| }; | }; | ||||||
|  |  | ||||||
| #[derive(Debug, Serialize, Deserialize)] | #[derive(Debug, Serialize, Deserialize)] | ||||||
| @@ -139,9 +141,7 @@ async fn hibp_breach(username: &str) -> JsonResult { | |||||||
|     ); |     ); | ||||||
|  |  | ||||||
|     if let Some(api_key) = crate::CONFIG.hibp_api_key() { |     if let Some(api_key) = crate::CONFIG.hibp_api_key() { | ||||||
|         let hibp_client = get_reqwest_client(); |         let res = make_http_request(Method::GET, &url)?.header("hibp-api-key", api_key).send().await?; | ||||||
|  |  | ||||||
|         let res = hibp_client.get(&url).header("hibp-api-key", api_key).send().await?; |  | ||||||
|  |  | ||||||
|         // If we get a 404, return a 404, it means no breached accounts |         // If we get a 404, return a 404, it means no breached accounts | ||||||
|         if res.status() == 404 { |         if res.status() == 404 { | ||||||
|   | |||||||
| @@ -15,7 +15,7 @@ use crate::{ | |||||||
|         DbConn, |         DbConn, | ||||||
|     }, |     }, | ||||||
|     error::MapResult, |     error::MapResult, | ||||||
|     util::get_reqwest_client, |     http_client::make_http_request, | ||||||
|     CONFIG, |     CONFIG, | ||||||
| }; | }; | ||||||
|  |  | ||||||
| @@ -210,10 +210,7 @@ async fn duo_api_request(method: &str, path: &str, params: &str, data: &DuoData) | |||||||
|  |  | ||||||
|     let m = Method::from_str(method).unwrap_or_default(); |     let m = Method::from_str(method).unwrap_or_default(); | ||||||
|  |  | ||||||
|     let client = get_reqwest_client(); |     make_http_request(m, &url)? | ||||||
|  |  | ||||||
|     client |  | ||||||
|         .request(m, &url) |  | ||||||
|         .basic_auth(username, Some(password)) |         .basic_auth(username, Some(password)) | ||||||
|         .header(header::USER_AGENT, "vaultwarden:Duo/1.0 (Rust)") |         .header(header::USER_AGENT, "vaultwarden:Duo/1.0 (Rust)") | ||||||
|         .header(header::DATE, date) |         .header(header::DATE, date) | ||||||
|   | |||||||
| @@ -1,6 +1,6 @@ | |||||||
| use std::{ | use std::{ | ||||||
|     net::IpAddr, |     net::IpAddr, | ||||||
|     sync::{Arc, Mutex}, |     sync::Arc, | ||||||
|     time::{Duration, SystemTime}, |     time::{Duration, SystemTime}, | ||||||
| }; | }; | ||||||
|  |  | ||||||
| @@ -22,7 +22,8 @@ use html5gum::{Emitter, HtmlString, InfallibleTokenizer, Readable, StringReader, | |||||||
|  |  | ||||||
| use crate::{ | use crate::{ | ||||||
|     error::Error, |     error::Error, | ||||||
|     util::{get_reqwest_client_builder, Cached, CustomDnsResolver, CustomResolverError}, |     http_client::{get_reqwest_client_builder, should_block_address, CustomHttpClientError}, | ||||||
|  |     util::Cached, | ||||||
|     CONFIG, |     CONFIG, | ||||||
| }; | }; | ||||||
|  |  | ||||||
| @@ -53,7 +54,6 @@ static CLIENT: Lazy<Client> = Lazy::new(|| { | |||||||
|         .timeout(icon_download_timeout) |         .timeout(icon_download_timeout) | ||||||
|         .pool_max_idle_per_host(5) // Configure the Hyper Pool to only have max 5 idle connections |         .pool_max_idle_per_host(5) // Configure the Hyper Pool to only have max 5 idle connections | ||||||
|         .pool_idle_timeout(pool_idle_timeout) // Configure the Hyper Pool to timeout after 10 seconds |         .pool_idle_timeout(pool_idle_timeout) // Configure the Hyper Pool to timeout after 10 seconds | ||||||
|         .dns_resolver(CustomDnsResolver::instance()) |  | ||||||
|         .default_headers(default_headers.clone()) |         .default_headers(default_headers.clone()) | ||||||
|         .build() |         .build() | ||||||
|         .expect("Failed to build client") |         .expect("Failed to build client") | ||||||
| @@ -69,7 +69,8 @@ fn icon_external(domain: &str) -> Option<Redirect> { | |||||||
|         return None; |         return None; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     if is_domain_blacklisted(domain) { |     if should_block_address(domain) { | ||||||
|  |         warn!("Blocked address: {}", domain); | ||||||
|         return None; |         return None; | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -99,6 +100,15 @@ async fn icon_internal(domain: &str) -> Cached<(ContentType, Vec<u8>)> { | |||||||
|         ); |         ); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     if should_block_address(domain) { | ||||||
|  |         warn!("Blocked address: {}", domain); | ||||||
|  |         return Cached::ttl( | ||||||
|  |             (ContentType::new("image", "png"), FALLBACK_ICON.to_vec()), | ||||||
|  |             CONFIG.icon_cache_negttl(), | ||||||
|  |             true, | ||||||
|  |         ); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     match get_icon(domain).await { |     match get_icon(domain).await { | ||||||
|         Some((icon, icon_type)) => { |         Some((icon, icon_type)) => { | ||||||
|             Cached::ttl((ContentType::new("image", icon_type), icon), CONFIG.icon_cache_ttl(), true) |             Cached::ttl((ContentType::new("image", icon_type), icon), CONFIG.icon_cache_ttl(), true) | ||||||
| @@ -144,30 +154,6 @@ fn is_valid_domain(domain: &str) -> bool { | |||||||
|     true |     true | ||||||
| } | } | ||||||
|  |  | ||||||
| pub fn is_domain_blacklisted(domain: &str) -> bool { |  | ||||||
|     let Some(config_blacklist) = CONFIG.icon_blacklist_regex() else { |  | ||||||
|         return false; |  | ||||||
|     }; |  | ||||||
|  |  | ||||||
|     // Compiled domain blacklist |  | ||||||
|     static COMPILED_BLACKLIST: Mutex<Option<(String, Regex)>> = Mutex::new(None); |  | ||||||
|     let mut guard = COMPILED_BLACKLIST.lock().unwrap(); |  | ||||||
|  |  | ||||||
|     // If the stored regex is up to date, use it |  | ||||||
|     if let Some((value, regex)) = &*guard { |  | ||||||
|         if value == &config_blacklist { |  | ||||||
|             return regex.is_match(domain); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     // If we don't have a regex stored, or it's not up to date, recreate it |  | ||||||
|     let regex = Regex::new(&config_blacklist).unwrap(); |  | ||||||
|     let is_match = regex.is_match(domain); |  | ||||||
|     *guard = Some((config_blacklist, regex)); |  | ||||||
|  |  | ||||||
|     is_match |  | ||||||
| } |  | ||||||
|  |  | ||||||
| async fn get_icon(domain: &str) -> Option<(Vec<u8>, String)> { | async fn get_icon(domain: &str) -> Option<(Vec<u8>, String)> { | ||||||
|     let path = format!("{}/{}.png", CONFIG.icon_cache_folder(), domain); |     let path = format!("{}/{}.png", CONFIG.icon_cache_folder(), domain); | ||||||
|  |  | ||||||
| @@ -195,9 +181,9 @@ async fn get_icon(domain: &str) -> Option<(Vec<u8>, String)> { | |||||||
|             Some((icon.to_vec(), icon_type.unwrap_or("x-icon").to_string())) |             Some((icon.to_vec(), icon_type.unwrap_or("x-icon").to_string())) | ||||||
|         } |         } | ||||||
|         Err(e) => { |         Err(e) => { | ||||||
|             // If this error comes from the custom resolver, this means this is a blacklisted domain |             // If this error comes from the custom resolver, this means this is a blocked domain | ||||||
|             // or non global IP, don't save the miss file in this case to avoid leaking it |             // or non global IP, don't save the miss file in this case to avoid leaking it | ||||||
|             if let Some(error) = CustomResolverError::downcast_ref(&e) { |             if let Some(error) = CustomHttpClientError::downcast_ref(&e) { | ||||||
|                 warn!("{error}"); |                 warn!("{error}"); | ||||||
|                 return None; |                 return None; | ||||||
|             } |             } | ||||||
| @@ -353,7 +339,7 @@ async fn get_icon_url(domain: &str) -> Result<IconUrlResult, Error> { | |||||||
|  |  | ||||||
|     // First check the domain as given during the request for HTTPS. |     // First check the domain as given during the request for HTTPS. | ||||||
|     let resp = match get_page(&ssldomain).await { |     let resp = match get_page(&ssldomain).await { | ||||||
|         Err(e) if CustomResolverError::downcast_ref(&e).is_none() => { |         Err(e) if CustomHttpClientError::downcast_ref(&e).is_none() => { | ||||||
|             // If we get an error that is not caused by the blacklist, we retry with HTTP |             // If we get an error that is not caused by the blacklist, we retry with HTTP | ||||||
|             match get_page(&httpdomain).await { |             match get_page(&httpdomain).await { | ||||||
|                 mut sub_resp @ Err(_) => { |                 mut sub_resp @ Err(_) => { | ||||||
|   | |||||||
| @@ -20,7 +20,7 @@ pub use crate::api::{ | |||||||
|     core::two_factor::send_incomplete_2fa_notifications, |     core::two_factor::send_incomplete_2fa_notifications, | ||||||
|     core::{emergency_notification_reminder_job, emergency_request_timeout_job}, |     core::{emergency_notification_reminder_job, emergency_request_timeout_job}, | ||||||
|     core::{event_cleanup_job, events_routes as core_events_routes}, |     core::{event_cleanup_job, events_routes as core_events_routes}, | ||||||
|     icons::{is_domain_blacklisted, routes as icons_routes}, |     icons::routes as icons_routes, | ||||||
|     identity::routes as identity_routes, |     identity::routes as identity_routes, | ||||||
|     notifications::routes as notifications_routes, |     notifications::routes as notifications_routes, | ||||||
|     notifications::{AnonymousNotify, Notify, UpdateType, WS_ANONYMOUS_SUBSCRIPTIONS, WS_USERS}, |     notifications::{AnonymousNotify, Notify, UpdateType, WS_ANONYMOUS_SUBSCRIPTIONS, WS_USERS}, | ||||||
|   | |||||||
| @@ -1,11 +1,14 @@ | |||||||
| use reqwest::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE}; | use reqwest::{ | ||||||
|  |     header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE}, | ||||||
|  |     Method, | ||||||
|  | }; | ||||||
| use serde_json::Value; | use serde_json::Value; | ||||||
| use tokio::sync::RwLock; | use tokio::sync::RwLock; | ||||||
|  |  | ||||||
| use crate::{ | use crate::{ | ||||||
|     api::{ApiResult, EmptyResult, UpdateType}, |     api::{ApiResult, EmptyResult, UpdateType}, | ||||||
|     db::models::{Cipher, Device, Folder, Send, User}, |     db::models::{Cipher, Device, Folder, Send, User}, | ||||||
|     util::get_reqwest_client, |     http_client::make_http_request, | ||||||
|     CONFIG, |     CONFIG, | ||||||
| }; | }; | ||||||
|  |  | ||||||
| @@ -50,8 +53,7 @@ async fn get_auth_push_token() -> ApiResult<String> { | |||||||
|         ("client_secret", &client_secret), |         ("client_secret", &client_secret), | ||||||
|     ]; |     ]; | ||||||
|  |  | ||||||
|     let res = match get_reqwest_client() |     let res = match make_http_request(Method::POST, &format!("{}/connect/token", CONFIG.push_identity_uri()))? | ||||||
|         .post(&format!("{}/connect/token", CONFIG.push_identity_uri())) |  | ||||||
|         .form(¶ms) |         .form(¶ms) | ||||||
|         .send() |         .send() | ||||||
|         .await |         .await | ||||||
| @@ -104,8 +106,7 @@ pub async fn register_push_device(device: &mut Device, conn: &mut crate::db::DbC | |||||||
|     let auth_push_token = get_auth_push_token().await?; |     let auth_push_token = get_auth_push_token().await?; | ||||||
|     let auth_header = format!("Bearer {}", &auth_push_token); |     let auth_header = format!("Bearer {}", &auth_push_token); | ||||||
|  |  | ||||||
|     if let Err(e) = get_reqwest_client() |     if let Err(e) = make_http_request(Method::POST, &(CONFIG.push_relay_uri() + "/push/register"))? | ||||||
|         .post(CONFIG.push_relay_uri() + "/push/register") |  | ||||||
|         .header(CONTENT_TYPE, "application/json") |         .header(CONTENT_TYPE, "application/json") | ||||||
|         .header(ACCEPT, "application/json") |         .header(ACCEPT, "application/json") | ||||||
|         .header(AUTHORIZATION, auth_header) |         .header(AUTHORIZATION, auth_header) | ||||||
| @@ -132,8 +133,7 @@ pub async fn unregister_push_device(push_uuid: Option<String>) -> EmptyResult { | |||||||
|  |  | ||||||
|     let auth_header = format!("Bearer {}", &auth_push_token); |     let auth_header = format!("Bearer {}", &auth_push_token); | ||||||
|  |  | ||||||
|     match get_reqwest_client() |     match make_http_request(Method::DELETE, &(CONFIG.push_relay_uri() + "/push/" + &push_uuid.unwrap()))? | ||||||
|         .delete(CONFIG.push_relay_uri() + "/push/" + &push_uuid.unwrap()) |  | ||||||
|         .header(AUTHORIZATION, auth_header) |         .header(AUTHORIZATION, auth_header) | ||||||
|         .send() |         .send() | ||||||
|         .await |         .await | ||||||
| @@ -266,8 +266,15 @@ async fn send_to_push_relay(notification_data: Value) { | |||||||
|  |  | ||||||
|     let auth_header = format!("Bearer {}", &auth_push_token); |     let auth_header = format!("Bearer {}", &auth_push_token); | ||||||
|  |  | ||||||
|     if let Err(e) = get_reqwest_client() |     let req = match make_http_request(Method::POST, &(CONFIG.push_relay_uri() + "/push/send")) { | ||||||
|         .post(CONFIG.push_relay_uri() + "/push/send") |         Ok(r) => r, | ||||||
|  |         Err(e) => { | ||||||
|  |             error!("An error occurred while sending a send update to the push relay: {}", e); | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     if let Err(e) = req | ||||||
|         .header(ACCEPT, "application/json") |         .header(ACCEPT, "application/json") | ||||||
|         .header(CONTENT_TYPE, "application/json") |         .header(CONTENT_TYPE, "application/json") | ||||||
|         .header(AUTHORIZATION, &auth_header) |         .header(AUTHORIZATION, &auth_header) | ||||||
|   | |||||||
| @@ -146,6 +146,12 @@ macro_rules! make_config { | |||||||
|                 config.signups_domains_whitelist = config.signups_domains_whitelist.trim().to_lowercase(); |                 config.signups_domains_whitelist = config.signups_domains_whitelist.trim().to_lowercase(); | ||||||
|                 config.org_creation_users = config.org_creation_users.trim().to_lowercase(); |                 config.org_creation_users = config.org_creation_users.trim().to_lowercase(); | ||||||
|  |  | ||||||
|  |  | ||||||
|  |                 // Copy the values from the deprecated flags to the new ones | ||||||
|  |                 if config.http_request_block_regex.is_none() { | ||||||
|  |                     config.http_request_block_regex = config.icon_blacklist_regex.clone(); | ||||||
|  |                 } | ||||||
|  |  | ||||||
|                 config |                 config | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
| @@ -531,12 +537,18 @@ make_config! { | |||||||
|         icon_cache_negttl:      u64,    true,   def,    259_200; |         icon_cache_negttl:      u64,    true,   def,    259_200; | ||||||
|         /// Icon download timeout |> Number of seconds when to stop attempting to download an icon. |         /// Icon download timeout |> Number of seconds when to stop attempting to download an icon. | ||||||
|         icon_download_timeout:  u64,    true,   def,    10; |         icon_download_timeout:  u64,    true,   def,    10; | ||||||
|         /// Icon blacklist Regex |> Any domains or IPs that match this regex won't be fetched by the icon service. |  | ||||||
|  |         /// [Deprecated] Icon blacklist Regex |> Use `http_request_block_regex` instead | ||||||
|  |         icon_blacklist_regex:   String, false,   option; | ||||||
|  |         /// [Deprecated] Icon blacklist non global IPs |> Use `http_request_block_non_global_ips` instead | ||||||
|  |         icon_blacklist_non_global_ips:  bool,   false,   def, true; | ||||||
|  |  | ||||||
|  |         /// Block HTTP domains/IPs by Regex |> Any domains or IPs that match this regex won't be fetched by the internal HTTP client. | ||||||
|         /// Useful to hide other servers in the local network. Check the WIKI for more details |         /// Useful to hide other servers in the local network. Check the WIKI for more details | ||||||
|         icon_blacklist_regex:   String, true,   option; |         http_request_block_regex:   String, true,   option; | ||||||
|         /// Icon blacklist non global IPs |> Any IP which is not defined as a global IP will be blacklisted. |         /// Block non global IPs |> Enabling this will cause the internal HTTP client to refuse to connect to any non global IP address. | ||||||
|         /// Useful to secure your internal environment: See https://en.wikipedia.org/wiki/Reserved_IP_addresses for a list of IPs which it will block |         /// Useful to secure your internal environment: See https://en.wikipedia.org/wiki/Reserved_IP_addresses for a list of IPs which it will block | ||||||
|         icon_blacklist_non_global_ips:  bool,   true,   def,    true; |         http_request_block_non_global_ips:  bool,   true,   auto, |c| c.icon_blacklist_non_global_ips; | ||||||
|  |  | ||||||
|         /// Disable Two-Factor remember |> Enabling this would force the users to use a second factor to login every time. |         /// Disable Two-Factor remember |> Enabling this would force the users to use a second factor to login every time. | ||||||
|         /// Note that the checkbox would still be present, but ignored. |         /// Note that the checkbox would still be present, but ignored. | ||||||
| @@ -899,12 +911,12 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> { | |||||||
|         err!("To use email 2FA as automatic fallback, email 2fa has to be enabled!"); |         err!("To use email 2FA as automatic fallback, email 2fa has to be enabled!"); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     // Check if the icon blacklist regex is valid |     // Check if the HTTP request block regex is valid | ||||||
|     if let Some(ref r) = cfg.icon_blacklist_regex { |     if let Some(ref r) = cfg.http_request_block_regex { | ||||||
|         let validate_regex = regex::Regex::new(r); |         let validate_regex = regex::Regex::new(r); | ||||||
|         match validate_regex { |         match validate_regex { | ||||||
|             Ok(_) => (), |             Ok(_) => (), | ||||||
|             Err(e) => err!(format!("`ICON_BLACKLIST_REGEX` is invalid: {e:#?}")), |             Err(e) => err!(format!("`HTTP_REQUEST_BLOCK_REGEX` is invalid: {e:#?}")), | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -2,6 +2,7 @@ | |||||||
| // Error generator macro | // Error generator macro | ||||||
| // | // | ||||||
| use crate::db::models::EventType; | use crate::db::models::EventType; | ||||||
|  | use crate::http_client::CustomHttpClientError; | ||||||
| use std::error::Error as StdError; | use std::error::Error as StdError; | ||||||
|  |  | ||||||
| macro_rules! make_error { | macro_rules! make_error { | ||||||
| @@ -68,6 +69,10 @@ make_error! { | |||||||
|     Empty(Empty):     _no_source, _serialize, |     Empty(Empty):     _no_source, _serialize, | ||||||
|     // Used to represent err! calls |     // Used to represent err! calls | ||||||
|     Simple(String):  _no_source,  _api_error, |     Simple(String):  _no_source,  _api_error, | ||||||
|  |  | ||||||
|  |     // Used in our custom http client to handle non-global IPs and blocked domains | ||||||
|  |     CustomHttpClient(CustomHttpClientError): _has_source, _api_error, | ||||||
|  |  | ||||||
|     // Used for special return values, like 2FA errors |     // Used for special return values, like 2FA errors | ||||||
|     Json(Value):     _no_source,  _serialize, |     Json(Value):     _no_source,  _serialize, | ||||||
|     Db(DieselErr):   _has_source, _api_error, |     Db(DieselErr):   _has_source, _api_error, | ||||||
|   | |||||||
							
								
								
									
										246
									
								
								src/http_client.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										246
									
								
								src/http_client.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,246 @@ | |||||||
|  | use std::{ | ||||||
|  |     fmt, | ||||||
|  |     net::{IpAddr, SocketAddr}, | ||||||
|  |     str::FromStr, | ||||||
|  |     sync::{Arc, Mutex}, | ||||||
|  |     time::Duration, | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | use hickory_resolver::{system_conf::read_system_conf, TokioAsyncResolver}; | ||||||
|  | use once_cell::sync::Lazy; | ||||||
|  | use regex::Regex; | ||||||
|  | use reqwest::{ | ||||||
|  |     dns::{Name, Resolve, Resolving}, | ||||||
|  |     header, Client, ClientBuilder, | ||||||
|  | }; | ||||||
|  | use url::Host; | ||||||
|  |  | ||||||
|  | use crate::{util::is_global, CONFIG}; | ||||||
|  |  | ||||||
|  | pub fn make_http_request(method: reqwest::Method, url: &str) -> Result<reqwest::RequestBuilder, crate::Error> { | ||||||
|  |     let Ok(url) = url::Url::parse(url) else { | ||||||
|  |         err!("Invalid URL"); | ||||||
|  |     }; | ||||||
|  |     let Some(host) = url.host() else { | ||||||
|  |         err!("Invalid host"); | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     should_block_host(host)?; | ||||||
|  |  | ||||||
|  |     static INSTANCE: Lazy<Client> = Lazy::new(|| get_reqwest_client_builder().build().expect("Failed to build client")); | ||||||
|  |  | ||||||
|  |     Ok(INSTANCE.request(method, url)) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | pub fn get_reqwest_client_builder() -> ClientBuilder { | ||||||
|  |     let mut headers = header::HeaderMap::new(); | ||||||
|  |     headers.insert(header::USER_AGENT, header::HeaderValue::from_static("Vaultwarden")); | ||||||
|  |  | ||||||
|  |     let redirect_policy = reqwest::redirect::Policy::custom(|attempt| { | ||||||
|  |         if attempt.previous().len() >= 5 { | ||||||
|  |             return attempt.error("Too many redirects"); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         let Some(host) = attempt.url().host() else { | ||||||
|  |             return attempt.error("Invalid host"); | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         if let Err(e) = should_block_host(host) { | ||||||
|  |             return attempt.error(e); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         attempt.follow() | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     Client::builder() | ||||||
|  |         .default_headers(headers) | ||||||
|  |         .redirect(redirect_policy) | ||||||
|  |         .dns_resolver(CustomDnsResolver::instance()) | ||||||
|  |         .timeout(Duration::from_secs(10)) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | pub fn should_block_address(domain_or_ip: &str) -> bool { | ||||||
|  |     if let Ok(ip) = IpAddr::from_str(domain_or_ip) { | ||||||
|  |         if should_block_ip(ip) { | ||||||
|  |             return true; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     should_block_address_regex(domain_or_ip) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | fn should_block_ip(ip: IpAddr) -> bool { | ||||||
|  |     if !CONFIG.http_request_block_non_global_ips() { | ||||||
|  |         return false; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     !is_global(ip) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | fn should_block_address_regex(domain_or_ip: &str) -> bool { | ||||||
|  |     let Some(block_regex) = CONFIG.http_request_block_regex() else { | ||||||
|  |         return false; | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     static COMPILED_REGEX: Mutex<Option<(String, Regex)>> = Mutex::new(None); | ||||||
|  |     let mut guard = COMPILED_REGEX.lock().unwrap(); | ||||||
|  |  | ||||||
|  |     // If the stored regex is up to date, use it | ||||||
|  |     if let Some((value, regex)) = &*guard { | ||||||
|  |         if value == &block_regex { | ||||||
|  |             return regex.is_match(domain_or_ip); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // If we don't have a regex stored, or it's not up to date, recreate it | ||||||
|  |     let regex = Regex::new(&block_regex).unwrap(); | ||||||
|  |     let is_match = regex.is_match(domain_or_ip); | ||||||
|  |     *guard = Some((block_regex, regex)); | ||||||
|  |  | ||||||
|  |     is_match | ||||||
|  | } | ||||||
|  |  | ||||||
|  | fn should_block_host(host: Host<&str>) -> Result<(), CustomHttpClientError> { | ||||||
|  |     let (ip, host_str): (Option<IpAddr>, String) = match host { | ||||||
|  |         url::Host::Ipv4(ip) => (Some(ip.into()), ip.to_string()), | ||||||
|  |         url::Host::Ipv6(ip) => (Some(ip.into()), ip.to_string()), | ||||||
|  |         url::Host::Domain(d) => (None, d.to_string()), | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     if let Some(ip) = ip { | ||||||
|  |         if should_block_ip(ip) { | ||||||
|  |             return Err(CustomHttpClientError::NonGlobalIp { | ||||||
|  |                 domain: None, | ||||||
|  |                 ip, | ||||||
|  |             }); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if should_block_address_regex(&host_str) { | ||||||
|  |         return Err(CustomHttpClientError::Blocked { | ||||||
|  |             domain: host_str, | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     Ok(()) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Debug, Clone)] | ||||||
|  | pub enum CustomHttpClientError { | ||||||
|  |     Blocked { | ||||||
|  |         domain: String, | ||||||
|  |     }, | ||||||
|  |     NonGlobalIp { | ||||||
|  |         domain: Option<String>, | ||||||
|  |         ip: IpAddr, | ||||||
|  |     }, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl CustomHttpClientError { | ||||||
|  |     pub fn downcast_ref(e: &dyn std::error::Error) -> Option<&Self> { | ||||||
|  |         let mut source = e.source(); | ||||||
|  |  | ||||||
|  |         while let Some(err) = source { | ||||||
|  |             source = err.source(); | ||||||
|  |             if let Some(err) = err.downcast_ref::<CustomHttpClientError>() { | ||||||
|  |                 return Some(err); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         None | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl fmt::Display for CustomHttpClientError { | ||||||
|  |     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | ||||||
|  |         match self { | ||||||
|  |             Self::Blocked { | ||||||
|  |                 domain, | ||||||
|  |             } => write!(f, "Blocked domain: {domain} matched HTTP_REQUEST_BLOCK_REGEX"), | ||||||
|  |             Self::NonGlobalIp { | ||||||
|  |                 domain: Some(domain), | ||||||
|  |                 ip, | ||||||
|  |             } => write!(f, "IP {ip} for domain '{domain}' is not a global IP!"), | ||||||
|  |             Self::NonGlobalIp { | ||||||
|  |                 domain: None, | ||||||
|  |                 ip, | ||||||
|  |             } => write!(f, "IP {ip} is not a global IP!"), | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl std::error::Error for CustomHttpClientError {} | ||||||
|  |  | ||||||
|  | #[derive(Debug, Clone)] | ||||||
|  | enum CustomDnsResolver { | ||||||
|  |     Default(), | ||||||
|  |     Hickory(Arc<TokioAsyncResolver>), | ||||||
|  | } | ||||||
|  | type BoxError = Box<dyn std::error::Error + Send + Sync>; | ||||||
|  |  | ||||||
|  | impl CustomDnsResolver { | ||||||
|  |     fn instance() -> Arc<Self> { | ||||||
|  |         static INSTANCE: Lazy<Arc<CustomDnsResolver>> = Lazy::new(CustomDnsResolver::new); | ||||||
|  |         Arc::clone(&*INSTANCE) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     fn new() -> Arc<Self> { | ||||||
|  |         match read_system_conf() { | ||||||
|  |             Ok((config, opts)) => { | ||||||
|  |                 let resolver = TokioAsyncResolver::tokio(config.clone(), opts.clone()); | ||||||
|  |                 Arc::new(Self::Hickory(Arc::new(resolver))) | ||||||
|  |             } | ||||||
|  |             Err(e) => { | ||||||
|  |                 warn!("Error creating Hickory resolver, falling back to default: {e:?}"); | ||||||
|  |                 Arc::new(Self::Default()) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Note that we get an iterator of addresses, but we only grab the first one for convenience | ||||||
|  |     async fn resolve_domain(&self, name: &str) -> Result<Option<SocketAddr>, BoxError> { | ||||||
|  |         pre_resolve(name)?; | ||||||
|  |  | ||||||
|  |         let result = match self { | ||||||
|  |             Self::Default() => tokio::net::lookup_host(name).await?.next(), | ||||||
|  |             Self::Hickory(r) => r.lookup_ip(name).await?.iter().next().map(|a| SocketAddr::new(a, 0)), | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         if let Some(addr) = &result { | ||||||
|  |             post_resolve(name, addr.ip())?; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         Ok(result) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | fn pre_resolve(name: &str) -> Result<(), CustomHttpClientError> { | ||||||
|  |     if should_block_address(name) { | ||||||
|  |         return Err(CustomHttpClientError::Blocked { | ||||||
|  |             domain: name.to_string(), | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     Ok(()) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | fn post_resolve(name: &str, ip: IpAddr) -> Result<(), CustomHttpClientError> { | ||||||
|  |     if should_block_ip(ip) { | ||||||
|  |         Err(CustomHttpClientError::NonGlobalIp { | ||||||
|  |             domain: Some(name.to_string()), | ||||||
|  |             ip, | ||||||
|  |         }) | ||||||
|  |     } else { | ||||||
|  |         Ok(()) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl Resolve for CustomDnsResolver { | ||||||
|  |     fn resolve(&self, name: Name) -> Resolving { | ||||||
|  |         let this = self.clone(); | ||||||
|  |         Box::pin(async move { | ||||||
|  |             let name = name.as_str(); | ||||||
|  |             let result = this.resolve_domain(name).await?; | ||||||
|  |             Ok::<reqwest::dns::Addrs, _>(Box::new(result.into_iter())) | ||||||
|  |         }) | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -47,6 +47,7 @@ mod config; | |||||||
| mod crypto; | mod crypto; | ||||||
| #[macro_use] | #[macro_use] | ||||||
| mod db; | mod db; | ||||||
|  | mod http_client; | ||||||
| mod mail; | mod mail; | ||||||
| mod ratelimit; | mod ratelimit; | ||||||
| mod util; | mod util; | ||||||
|   | |||||||
							
								
								
									
										146
									
								
								src/util.rs
									
									
									
									
									
								
							
							
						
						
									
										146
									
								
								src/util.rs
									
									
									
									
									
								
							| @@ -4,7 +4,6 @@ | |||||||
| use std::{collections::HashMap, io::Cursor, ops::Deref, path::Path}; | use std::{collections::HashMap, io::Cursor, ops::Deref, path::Path}; | ||||||
|  |  | ||||||
| use num_traits::ToPrimitive; | use num_traits::ToPrimitive; | ||||||
| use once_cell::sync::Lazy; |  | ||||||
| use rocket::{ | use rocket::{ | ||||||
|     fairing::{Fairing, Info, Kind}, |     fairing::{Fairing, Info, Kind}, | ||||||
|     http::{ContentType, Header, HeaderMap, Method, Status}, |     http::{ContentType, Header, HeaderMap, Method, Status}, | ||||||
| @@ -686,19 +685,6 @@ where | |||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
| use reqwest::{header, Client, ClientBuilder}; |  | ||||||
|  |  | ||||||
| pub fn get_reqwest_client() -> &'static Client { |  | ||||||
|     static INSTANCE: Lazy<Client> = Lazy::new(|| get_reqwest_client_builder().build().expect("Failed to build client")); |  | ||||||
|     &INSTANCE |  | ||||||
| } |  | ||||||
|  |  | ||||||
| pub fn get_reqwest_client_builder() -> ClientBuilder { |  | ||||||
|     let mut headers = header::HeaderMap::new(); |  | ||||||
|     headers.insert(header::USER_AGENT, header::HeaderValue::from_static("Vaultwarden")); |  | ||||||
|     Client::builder().default_headers(headers).timeout(Duration::from_secs(10)) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| pub fn convert_json_key_lcase_first(src_json: Value) -> Value { | pub fn convert_json_key_lcase_first(src_json: Value) -> Value { | ||||||
|     match src_json { |     match src_json { | ||||||
|         Value::Array(elm) => { |         Value::Array(elm) => { | ||||||
| @@ -750,138 +736,6 @@ pub fn parse_experimental_client_feature_flags(experimental_client_feature_flags | |||||||
|     feature_states |     feature_states | ||||||
| } | } | ||||||
|  |  | ||||||
| mod dns_resolver { |  | ||||||
|     use std::{ |  | ||||||
|         fmt, |  | ||||||
|         net::{IpAddr, SocketAddr}, |  | ||||||
|         sync::Arc, |  | ||||||
|     }; |  | ||||||
|  |  | ||||||
|     use hickory_resolver::{system_conf::read_system_conf, TokioAsyncResolver}; |  | ||||||
|     use once_cell::sync::Lazy; |  | ||||||
|     use reqwest::dns::{Name, Resolve, Resolving}; |  | ||||||
|  |  | ||||||
|     use crate::{util::is_global, CONFIG}; |  | ||||||
|  |  | ||||||
|     #[derive(Debug, Clone)] |  | ||||||
|     pub enum CustomResolverError { |  | ||||||
|         Blacklist { |  | ||||||
|             domain: String, |  | ||||||
|         }, |  | ||||||
|         NonGlobalIp { |  | ||||||
|             domain: String, |  | ||||||
|             ip: IpAddr, |  | ||||||
|         }, |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     impl CustomResolverError { |  | ||||||
|         pub fn downcast_ref(e: &dyn std::error::Error) -> Option<&Self> { |  | ||||||
|             let mut source = e.source(); |  | ||||||
|  |  | ||||||
|             while let Some(err) = source { |  | ||||||
|                 source = err.source(); |  | ||||||
|                 if let Some(err) = err.downcast_ref::<CustomResolverError>() { |  | ||||||
|                     return Some(err); |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|             None |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     impl fmt::Display for CustomResolverError { |  | ||||||
|         fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |  | ||||||
|             match self { |  | ||||||
|                 Self::Blacklist { |  | ||||||
|                     domain, |  | ||||||
|                 } => write!(f, "Blacklisted domain: {domain} matched ICON_BLACKLIST_REGEX"), |  | ||||||
|                 Self::NonGlobalIp { |  | ||||||
|                     domain, |  | ||||||
|                     ip, |  | ||||||
|                 } => write!(f, "IP {ip} for domain '{domain}' is not a global IP!"), |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     impl std::error::Error for CustomResolverError {} |  | ||||||
|  |  | ||||||
|     #[derive(Debug, Clone)] |  | ||||||
|     pub enum CustomDnsResolver { |  | ||||||
|         Default(), |  | ||||||
|         Hickory(Arc<TokioAsyncResolver>), |  | ||||||
|     } |  | ||||||
|     type BoxError = Box<dyn std::error::Error + Send + Sync>; |  | ||||||
|  |  | ||||||
|     impl CustomDnsResolver { |  | ||||||
|         pub fn instance() -> Arc<Self> { |  | ||||||
|             static INSTANCE: Lazy<Arc<CustomDnsResolver>> = Lazy::new(CustomDnsResolver::new); |  | ||||||
|             Arc::clone(&*INSTANCE) |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         fn new() -> Arc<Self> { |  | ||||||
|             match read_system_conf() { |  | ||||||
|                 Ok((config, opts)) => { |  | ||||||
|                     let resolver = TokioAsyncResolver::tokio(config.clone(), opts.clone()); |  | ||||||
|                     Arc::new(Self::Hickory(Arc::new(resolver))) |  | ||||||
|                 } |  | ||||||
|                 Err(e) => { |  | ||||||
|                     warn!("Error creating Hickory resolver, falling back to default: {e:?}"); |  | ||||||
|                     Arc::new(Self::Default()) |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         // Note that we get an iterator of addresses, but we only grab the first one for convenience |  | ||||||
|         async fn resolve_domain(&self, name: &str) -> Result<Option<SocketAddr>, BoxError> { |  | ||||||
|             pre_resolve(name)?; |  | ||||||
|  |  | ||||||
|             let result = match self { |  | ||||||
|                 Self::Default() => tokio::net::lookup_host(name).await?.next(), |  | ||||||
|                 Self::Hickory(r) => r.lookup_ip(name).await?.iter().next().map(|a| SocketAddr::new(a, 0)), |  | ||||||
|             }; |  | ||||||
|  |  | ||||||
|             if let Some(addr) = &result { |  | ||||||
|                 post_resolve(name, addr.ip())?; |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             Ok(result) |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     fn pre_resolve(name: &str) -> Result<(), CustomResolverError> { |  | ||||||
|         if crate::api::is_domain_blacklisted(name) { |  | ||||||
|             return Err(CustomResolverError::Blacklist { |  | ||||||
|                 domain: name.to_string(), |  | ||||||
|             }); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         Ok(()) |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     fn post_resolve(name: &str, ip: IpAddr) -> Result<(), CustomResolverError> { |  | ||||||
|         if CONFIG.icon_blacklist_non_global_ips() && !is_global(ip) { |  | ||||||
|             Err(CustomResolverError::NonGlobalIp { |  | ||||||
|                 domain: name.to_string(), |  | ||||||
|                 ip, |  | ||||||
|             }) |  | ||||||
|         } else { |  | ||||||
|             Ok(()) |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     impl Resolve for CustomDnsResolver { |  | ||||||
|         fn resolve(&self, name: Name) -> Resolving { |  | ||||||
|             let this = self.clone(); |  | ||||||
|             Box::pin(async move { |  | ||||||
|                 let name = name.as_str(); |  | ||||||
|                 let result = this.resolve_domain(name).await?; |  | ||||||
|                 Ok::<reqwest::dns::Addrs, _>(Box::new(result.into_iter())) |  | ||||||
|             }) |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| pub use dns_resolver::{CustomDnsResolver, CustomResolverError}; |  | ||||||
|  |  | ||||||
| /// TODO: This is extracted from IpAddr::is_global, which is unstable: | /// TODO: This is extracted from IpAddr::is_global, which is unstable: | ||||||
| /// https://doc.rust-lang.org/nightly/std/net/enum.IpAddr.html#method.is_global | /// https://doc.rust-lang.org/nightly/std/net/enum.IpAddr.html#method.is_global | ||||||
| /// Remove once https://github.com/rust-lang/rust/issues/27709 is merged | /// Remove once https://github.com/rust-lang/rust/issues/27709 is merged | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user