+89
-11
@@ -7,7 +7,7 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::atomic::{AtomicU8, AtomicU64, Ordering};
|
||||
use std::time::{Duration, SystemTime};
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
@@ -15,12 +15,13 @@ use serde_json::Value;
|
||||
use ureq::Agent;
|
||||
use ureq::config::{Config, RedirectAuthHeaders};
|
||||
use ureq::tls::{RootCerts, TlsConfig};
|
||||
use ureq::{ResponseExt, http::Uri};
|
||||
|
||||
use crate::jmap::error::JmapError;
|
||||
use crate::jmap::inflight::{Permit, Semaphore};
|
||||
use crate::jmap::retry::{self, Disposition, RateLimitState};
|
||||
use crate::jmap::session::Limits;
|
||||
use crate::logging::{LEVEL_BODIES, LEVEL_DEFAULT, LEVEL_PROGRESS, Logger};
|
||||
use crate::logging::{HttpCall, LEVEL_BODIES, LEVEL_DEFAULT, LEVEL_PROGRESS, Logger};
|
||||
|
||||
const MAX_BODY: u64 = 512 * 1024 * 1024;
|
||||
|
||||
@@ -253,7 +254,7 @@ impl HttpClient {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.one_attempt(url, body, content_type)
|
||||
self.one_attempt(method, url, body, content_type)
|
||||
};
|
||||
match attempt_outcome {
|
||||
Attempt::Ok { status, body, .. } if (200..300).contains(&status) => {
|
||||
@@ -366,8 +367,16 @@ impl HttpClient {
|
||||
}
|
||||
}
|
||||
|
||||
fn one_attempt(&self, url: &str, body: Option<&[u8]>, content_type: Option<&str>) -> Attempt {
|
||||
fn one_attempt(
|
||||
&self,
|
||||
method: &str,
|
||||
url: &str,
|
||||
body: Option<&[u8]>,
|
||||
content_type: Option<&str>,
|
||||
) -> Attempt {
|
||||
let auth = self.inner.auth.header_value();
|
||||
let logger = self.logger();
|
||||
let started = Instant::now();
|
||||
let result = if let Some(payload) = body {
|
||||
let mut req = self
|
||||
.inner
|
||||
@@ -390,6 +399,12 @@ impl HttpClient {
|
||||
match result {
|
||||
Ok(mut resp) => {
|
||||
let status = resp.status().as_u16();
|
||||
let final_uri = resp.get_uri().clone();
|
||||
let response_type = resp
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::to_owned);
|
||||
let retry_after = resp
|
||||
.headers()
|
||||
.get("retry-after")
|
||||
@@ -411,18 +426,37 @@ impl HttpClient {
|
||||
})
|
||||
.collect();
|
||||
match resp.body_mut().with_config().limit(MAX_BODY).read_to_vec() {
|
||||
Ok(bytes) => Attempt::Ok {
|
||||
status,
|
||||
body: bytes,
|
||||
retry_after,
|
||||
rate_limit_headers,
|
||||
},
|
||||
Ok(bytes) => {
|
||||
warn_on_redirect(&logger, method, url, &final_uri);
|
||||
logger.trace_http(&HttpCall {
|
||||
proto: "JMAP",
|
||||
method,
|
||||
url,
|
||||
status,
|
||||
elapsed: started.elapsed(),
|
||||
note: None,
|
||||
request: body,
|
||||
request_type: content_type,
|
||||
response: &bytes,
|
||||
response_type: response_type.as_deref(),
|
||||
});
|
||||
Attempt::Ok {
|
||||
status,
|
||||
body: bytes,
|
||||
retry_after,
|
||||
rate_limit_headers,
|
||||
}
|
||||
}
|
||||
Err(e) => Attempt::Transport(JmapError::Transport(format!(
|
||||
"reading response body: {e}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
Err(e) => Attempt::Transport(map_ureq_error(e)),
|
||||
Err(e) => {
|
||||
let err = map_ureq_error(e);
|
||||
logger.trace_http_error("JMAP", method, url, &err.to_string(), started.elapsed());
|
||||
Attempt::Transport(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -498,6 +532,26 @@ struct RetryLog<'a> {
|
||||
body: &'a [u8],
|
||||
}
|
||||
|
||||
fn warn_on_redirect(logger: &Logger, method: &str, requested: &str, final_uri: &Uri) {
|
||||
let landed = final_uri.to_string();
|
||||
if cross_host(requested, &landed) {
|
||||
logger.warn(&format!(
|
||||
"{method} {requested} was redirected across hosts to {landed}; a \
|
||||
JMAP endpoint must not redirect: verify the server's advertised base \
|
||||
URL and that the request host matches its configured hostname"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cross_host(requested: &str, landed: &str) -> bool {
|
||||
match (url::Url::parse(requested), url::Url::parse(landed)) {
|
||||
(Ok(a), Ok(b)) => {
|
||||
a.host_str() != b.host_str() || a.port_or_known_default() != b.port_or_known_default()
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn transport_disposition(err: &JmapError) -> Disposition {
|
||||
match err {
|
||||
JmapError::Connect(_) => Disposition::Fatal,
|
||||
@@ -600,6 +654,30 @@ mod tests {
|
||||
assert_eq!(auth.header_value(), "Bearer abc.def");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_host_detects_hostname_change() {
|
||||
assert!(cross_host(
|
||||
"https://mail.example.com/jmap",
|
||||
"https://login.example.net/sso"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_host_ignores_same_host_path_change() {
|
||||
assert!(!cross_host(
|
||||
"https://mail.example.com/jmap",
|
||||
"https://mail.example.com/jmap/api"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_host_flags_port_change() {
|
||||
assert!(cross_host(
|
||||
"https://mail.example.com/jmap",
|
||||
"https://mail.example.com:8443/jmap"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_does_not_panic_on_multibyte_boundary() {
|
||||
let mut body = vec![b'a'; 510];
|
||||
|
||||
Reference in New Issue
Block a user