#![allow(clippy::redundant_pub_crate)]
use crate::error::{Error, Result};
use crate::shared::oauth_validation::same_origin;
use std::time::Duration;
use url::Url;
pub(crate) const DEFAULT_AUTH_RESPONSE_BYTES: usize = 1_048_576;
pub(crate) const MAX_DISCOVERY_REDIRECTS: usize = 5;
pub(crate) const REDIRECT_REFUSAL_MARKER: &str = "discovery redirect refused";
pub(crate) async fn collect_reqwest_body_within_cap(
mut response: reqwest::Response,
max_bytes: usize,
) -> Result<Vec<u8>> {
if let Some(declared) = response.content_length() {
if declared > max_bytes as u64 {
return Err(auth_body_over_cap(max_bytes, Some(declared)));
}
}
let mut accumulated: Vec<u8> = Vec::new();
loop {
let next = response.chunk().await;
let Some(chunk) = next.map_err(|e| {
Error::internal(format!(
"authorization-server response body read failed: {e}"
))
})?
else {
break;
};
if chunk.len() > max_bytes - accumulated.len() {
return Err(auth_body_over_cap(max_bytes, None));
}
accumulated.extend_from_slice(&chunk);
}
Ok(accumulated)
}
pub(crate) fn is_body_over_cap(error: &Error) -> bool {
matches!(error, Error::Validation(_))
}
fn auth_body_over_cap(max_bytes: usize, declared: Option<u64>) -> Error {
let observed = match declared {
Some(bytes) => format!("declares Content-Length {bytes}"),
None => "delivered more than the cap (Content-Length absent or understated)".to_string(),
};
Error::validation(format!(
"authorization-server response body {observed}, over the {max_bytes}-byte cap \
(DEFAULT_AUTH_RESPONSE_BYTES); refusing to read it. No byte of the refused body is \
reproduced here"
))
}
pub(crate) fn hardened_discovery_client(timeout: Duration) -> Result<reqwest::Client> {
let policy = reqwest::redirect::Policy::custom(|attempt| {
let decision = {
let previous = attempt.previous();
if previous.len() > MAX_DISCOVERY_REDIRECTS {
Err(redirect_limit_refusal())
} else {
match previous.last() {
Some(from) if discovery_redirect_permitted(from, attempt.url()) => Ok(()),
Some(from) => Err(cross_origin_redirect_refusal(from, attempt.url())),
None => Err(unjudgeable_redirect_refusal()),
}
}
};
match decision {
Ok(()) => attempt.follow(),
Err(message) => attempt.error(message),
}
});
reqwest::Client::builder()
.timeout(timeout)
.redirect(policy)
.build()
.map_err(|e| {
Error::internal(format!(
"failed to build the hardened discovery HTTP client, so discovery cannot run \
without dropping its redirect policy: {e}"
))
})
}
fn discovery_redirect_permitted(previous: &Url, next: &Url) -> bool {
same_origin(previous, next)
}
pub(crate) fn is_redirect_refusal(error: &reqwest::Error) -> bool {
error.is_redirect()
}
fn cross_origin_redirect_refusal(previous: &Url, next: &Url) -> String {
format!(
"{REDIRECT_REFUSAL_MARKER}: {} redirected to {}, a DIFFERENT origin. A discovery redirect \
that leaves the issuer's origin hands document authorship to another host and defeats \
every issuer check upstream of it. Only the origins are named here; the paths are not \
reproduced",
origin_of(previous),
origin_of(next),
)
}
fn redirect_limit_refusal() -> String {
format!(
"{REDIRECT_REFUSAL_MARKER}: more than {MAX_DISCOVERY_REDIRECTS} redirects were offered. \
Every hop of a server redirecting to itself satisfies the same-origin rule, so the count \
is bounded separately rather than followed forever"
)
}
fn unjudgeable_redirect_refusal() -> String {
format!(
"{REDIRECT_REFUSAL_MARKER}: the redirect chain carried no previous URL, so the target's \
origin cannot be compared against anything and the redirect fails closed"
)
}
fn origin_of(url: &Url) -> String {
let host = url.host_str().unwrap_or("<no host>");
match url.port_or_known_default() {
Some(port) => format!("{}://{host}:{port}", url.scheme()),
None => format!("{}://{host}", url.scheme()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use mockito::Server;
use std::error::Error as StdError;
use std::time::Duration;
use url::Url;
const TEST_CAP: usize = 32;
const CANARY: &str = "MARKER-DO-NOT-ECHO-b3f1";
fn test_timeout() -> Duration {
Duration::from_secs(10)
}
fn rendered_chain(error: &dyn StdError) -> String {
let mut rendered = error.to_string();
let mut current = error.source();
while let Some(cause) = current {
rendered.push_str(" <- ");
rendered.push_str(&cause.to_string());
current = cause.source();
}
rendered
}
#[tokio::test]
async fn within_cap_refuses_a_declared_content_length_over_the_cap() {
let mut server = Server::new_async().await;
let body = format!("{}{}", CANARY, "x".repeat(200));
let _m = server
.mock("GET", "/big")
.with_status(200)
.with_body(&body)
.create_async()
.await;
let response = reqwest::Client::new()
.get(format!("{}/big", server.url()))
.send()
.await
.unwrap();
assert_eq!(
response.content_length(),
Some(body.len() as u64),
"the fixture must DECLARE a Content-Length or this row exercises refusal 2, not refusal 1"
);
let error = collect_reqwest_body_within_cap(response, TEST_CAP)
.await
.unwrap_err();
let message = error.to_string();
assert!(
is_body_over_cap(&error),
"a cap refusal must be distinguishable from a mid-read failure: {message}"
);
assert!(
message.contains(&TEST_CAP.to_string()),
"the refusal must name the LIMIT: {message}"
);
assert!(
message.contains(&body.len().to_string()),
"the Content-Length variant must name the DECLARED size: {message}"
);
assert!(
!message.contains(CANARY),
"a refusal must never echo a byte of the body it refused: {message}"
);
}
#[tokio::test]
async fn within_cap_refuses_a_chunked_body_that_exceeds_the_cap_mid_flight() {
let mut server = Server::new_async().await;
let _m = server
.mock("GET", "/stream")
.with_status(200)
.with_chunked_body(|writer| {
writer.write_all(CANARY.as_bytes())?;
writer.write_all(&[b'x'; 500])
})
.create_async()
.await;
let response = reqwest::Client::new()
.get(format!("{}/stream", server.url()))
.send()
.await
.unwrap();
assert_eq!(
response.content_length(),
None,
"the fixture must OMIT Content-Length or refusal 1 short-circuits this row"
);
let error = collect_reqwest_body_within_cap(response, TEST_CAP)
.await
.unwrap_err();
let message = error.to_string();
assert!(
is_body_over_cap(&error),
"a mid-flight cap refusal is still a cap refusal: {message}"
);
assert!(
message.contains(&TEST_CAP.to_string()),
"the refusal must name the LIMIT: {message}"
);
assert!(
message.contains("Content-Length absent or understated"),
"the mid-flight variant must state that no total is knowable rather than invent one: \
{message}"
);
assert!(
!message.contains(CANARY),
"a refusal must never echo a byte of the body it refused: {message}"
);
}
#[tokio::test]
async fn within_cap_admits_a_body_exactly_at_the_cap() {
let mut server = Server::new_async().await;
let body = "y".repeat(TEST_CAP);
let _m = server
.mock("GET", "/exact")
.with_status(200)
.with_body(&body)
.create_async()
.await;
let response = reqwest::Client::new()
.get(format!("{}/exact", server.url()))
.send()
.await
.unwrap();
let bytes = collect_reqwest_body_within_cap(response, TEST_CAP)
.await
.unwrap();
assert_eq!(bytes.len(), TEST_CAP);
assert_eq!(bytes, body.as_bytes());
}
#[tokio::test]
async fn within_cap_returns_byte_identical_content_under_the_cap() {
let mut server = Server::new_async().await;
let body = r#"{"issuer":"https://as.example","note":"üπ"}"#;
let _m = server
.mock("GET", "/small")
.with_status(200)
.with_body(body)
.create_async()
.await;
let response = reqwest::Client::new()
.get(format!("{}/small", server.url()))
.send()
.await
.unwrap();
let bytes = collect_reqwest_body_within_cap(response, DEFAULT_AUTH_RESPONSE_BYTES)
.await
.unwrap();
assert_eq!(bytes, body.as_bytes());
}
#[tokio::test]
async fn within_cap_returns_an_empty_vec_for_an_empty_body() {
let mut server = Server::new_async().await;
let _m = server
.mock("GET", "/empty")
.with_status(204)
.create_async()
.await;
let response = reqwest::Client::new()
.get(format!("{}/empty", server.url()))
.send()
.await
.unwrap();
let bytes = collect_reqwest_body_within_cap(response, TEST_CAP)
.await
.unwrap();
assert!(bytes.is_empty(), "an empty body is not an error");
}
#[test]
fn hardened_discovery_client_permits_only_same_origin_redirect_targets() {
let from = Url::parse("https://as.example/.well-known/openid-configuration").unwrap();
assert!(discovery_redirect_permitted(
&from,
&Url::parse("https://as.example/elsewhere").unwrap()
));
assert!(
discovery_redirect_permitted(
&from,
&Url::parse("https://as.example:443/elsewhere").unwrap()
),
"the EFFECTIVE port is what makes the explicit 443 the same origin"
);
assert!(
!discovery_redirect_permitted(&from, &Url::parse("https://cdn.example/x").unwrap()),
"a different host is a different origin"
);
assert!(
!discovery_redirect_permitted(&from, &Url::parse("http://as.example/x").unwrap()),
"an https -> http downgrade on the SAME host is still a different origin"
);
let explicit = Url::parse("https://as.example:8443/.well-known/openid-configuration")
.expect("fixture URL parses");
assert!(
!discovery_redirect_permitted(
&explicit,
&Url::parse("http://as.example:8443/x").unwrap()
),
"an https -> http downgrade on the same host AND the same port is a different origin"
);
assert!(
!discovery_redirect_permitted(&from, &Url::parse("https://as.example:8443/x").unwrap()),
"a different port is a different origin"
);
}
#[tokio::test]
async fn hardened_discovery_client_follows_a_same_origin_redirect() {
let mut server = Server::new_async().await;
let _target = server
.mock("GET", "/target")
.with_status(200)
.with_body("arrived")
.create_async()
.await;
let _redirect = server
.mock("GET", "/redirect")
.with_status(302)
.with_header("location", "/target")
.create_async()
.await;
let client = hardened_discovery_client(test_timeout()).unwrap();
let response = client
.get(format!("{}/redirect", server.url()))
.send()
.await
.unwrap();
assert_eq!(response.status().as_u16(), 200);
let bytes = collect_reqwest_body_within_cap(response, TEST_CAP)
.await
.unwrap();
assert_eq!(bytes, b"arrived");
}
#[tokio::test]
async fn hardened_discovery_client_refuses_a_cross_origin_redirect() {
let mut origin = Server::new_async().await;
let mut elsewhere = Server::new_async().await;
let never = elsewhere
.mock("GET", "/target")
.with_status(200)
.with_body("SHOULD NOT BE FETCHED")
.expect(0)
.create_async()
.await;
let _redirect = origin
.mock("GET", "/offsite")
.with_status(302)
.with_header("location", &format!("{}/target", elsewhere.url()))
.create_async()
.await;
let client = hardened_discovery_client(test_timeout()).unwrap();
let error = client
.get(format!("{}/offsite", origin.url()))
.send()
.await
.unwrap_err();
assert!(
is_redirect_refusal(&error),
"a refused redirect must be distinguishable from a transport failure: {error}"
);
assert!(
!error.is_connect(),
"the refusal must happen BEFORE any connection to the other origin: {error}"
);
let chain = rendered_chain(&error);
assert!(
chain.contains(REDIRECT_REFUSAL_MARKER),
"the refusal must name the rule it enforced: {chain}"
);
never.assert_async().await;
}
#[tokio::test]
async fn hardened_discovery_client_refuses_a_scheme_change_on_the_same_host() {
let mut server = Server::new_async().await;
let never = server
.mock("GET", "/target")
.with_status(200)
.expect(0)
.create_async()
.await;
let _redirect = server
.mock("GET", "/downgrade")
.with_status(302)
.with_header(
"location",
&format!("https://{}/target", server.host_with_port()),
)
.create_async()
.await;
let client = hardened_discovery_client(test_timeout()).unwrap();
let error = client
.get(format!("{}/downgrade", server.url()))
.send()
.await
.unwrap_err();
assert!(
is_redirect_refusal(&error),
"a scheme change is an origin change: {error}"
);
assert!(
!error.is_connect(),
"the refusal must happen BEFORE any TLS attempt against a plaintext port: {error}"
);
never.assert_async().await;
}
#[tokio::test]
async fn hardened_discovery_client_bounds_a_redirect_loop_within_one_origin() {
let mut server = Server::new_async().await;
let _loop_mock = server
.mock("GET", "/loop")
.with_status(302)
.with_header("location", "/loop")
.expect_at_least(1)
.create_async()
.await;
let client = hardened_discovery_client(test_timeout()).unwrap();
let error = client
.get(format!("{}/loop", server.url()))
.send()
.await
.unwrap_err();
assert!(
is_redirect_refusal(&error),
"a same-origin redirect loop must TERMINATE rather than hang: {error}"
);
let chain = rendered_chain(&error);
assert!(
chain.contains(REDIRECT_REFUSAL_MARKER),
"the refusal must name the rule it enforced: {chain}"
);
assert!(
chain.contains(&MAX_DISCOVERY_REDIRECTS.to_string()),
"the refusal must name the redirect LIMIT: {chain}"
);
}
}