v0.32.2
This commit is contained in:
1 parent
bb025dcf44
commit
b0f211555a
74 files changed
+6089
-556
No files matched your search
@@ -235,7 +235,8 @@ let downstream = if handshake.as_ref().is_some_and(|handshake| handshake.h1()) {
|
||||
None
|
||||
};
|
||||
let h2_downstream = if handshake.as_ref().is_some_and(|handshake| handshake.h2())
|
||||
&& (backend.protocol != crate::config::UpstreamProtocol::Http2
|
||||
&& (!matches!(backend.protocol,
|
||||
crate::config::UpstreamProtocol::Http2 | crate::config::UpstreamProtocol::H2)
|
||||
|| websocket_legacy_support)
|
||||
{
|
||||
Some(std::mem::replace(request.body_mut(), Body::empty()))
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
let websocket_upstream_h2 = !websocket_legacy_support
|
||||
&& handshake.as_ref().is_some_and(|handshake| handshake.h2())
|
||||
&& backend.protocol == crate::config::UpstreamProtocol::Http2;
|
||||
&& matches!(backend.protocol,
|
||||
crate::config::UpstreamProtocol::Http2 | crate::config::UpstreamProtocol::H2);
|
||||
if let Some(handshake) = &handshake {
|
||||
request.headers_mut().remove("content-length");
|
||||
request.headers_mut().remove("transfer-encoding");
|
||||
|
||||
+193
-146
@@ -32,6 +32,27 @@ mod authority_tests {
|
||||
Version::HTTP_2
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn legacy_https_http2_version_is_h1_safe_and_strict_h2_is_required() {
|
||||
let mut config = crate::config::demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
config.vhosts[0].backends[0].url = "https://127.0.0.1:9000".to_owned();
|
||||
config.vhosts[0].backends[0].protocol = crate::config::UpstreamProtocol::Http2;
|
||||
let snapshot = crate::config::compile(config.clone(), 1, &validation_policy()).unwrap();
|
||||
let route = snapshot.select("localhost", "/").unwrap();
|
||||
let origin = route.backend.as_ref().unwrap();
|
||||
assert_eq!(upstream_request_version(origin, false), Version::HTTP_11);
|
||||
// Extended CONNECT still uses H2 only in its existing WebSocket path.
|
||||
assert_eq!(upstream_request_version(origin, true), Version::HTTP_2);
|
||||
|
||||
config.vhosts[0].backends[0].protocol = crate::config::UpstreamProtocol::H2;
|
||||
let snapshot = crate::config::compile(config, 2, &validation_policy()).unwrap();
|
||||
let route = snapshot.select("localhost", "/").unwrap();
|
||||
assert_eq!(
|
||||
upstream_request_version(route.backend.as_ref().unwrap(), false),
|
||||
Version::HTTP_2
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn h3_body_capable_methods_without_length_become_known_empty_for_h1() {
|
||||
use bytes::Bytes;
|
||||
@@ -297,168 +318,194 @@ mod authority_tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upstream_uri_preserves_nested_url_query_without_rejecting_valid_separators() {
|
||||
let config = crate::config::demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
let snapshot = crate::config::compile(config, 1, &validation_policy()).unwrap();
|
||||
let backend = snapshot.select("localhost", "/").unwrap().backend.as_ref().unwrap();
|
||||
let suffix = "/convert?url=https%3A//easylist.to/easylist/easylist.txt&ip=0.0.0.0";
|
||||
let uri = upstream_uri(backend, suffix).unwrap();
|
||||
assert_eq!(uri.path_and_query().unwrap().as_str(), suffix);
|
||||
}
|
||||
#[test]
|
||||
fn upstream_uri_preserves_nested_url_query_without_rejecting_valid_separators() {
|
||||
let config = crate::config::demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
let snapshot = crate::config::compile(config, 1, &validation_policy()).unwrap();
|
||||
let backend = snapshot
|
||||
.select("localhost", "/")
|
||||
.unwrap()
|
||||
.backend
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
let suffix = "/convert?url=https%3A//easylist.to/easylist/easylist.txt&ip=0.0.0.0";
|
||||
let uri = upstream_uri(backend, suffix).unwrap();
|
||||
assert_eq!(uri.path_and_query().unwrap().as_str(), suffix);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn error_fallback_replays_empty_unknown_length_h2_h3_get_and_head() {
|
||||
use bytes::Bytes;
|
||||
#[tokio::test]
|
||||
async fn error_fallback_replays_empty_unknown_length_h2_h3_get_and_head() {
|
||||
use bytes::Bytes;
|
||||
|
||||
for version in [Version::HTTP_2, Version::HTTP_3] {
|
||||
for method in [Method::GET, Method::HEAD] {
|
||||
let stream = futures_util::stream::empty::<Result<Bytes, std::io::Error>>();
|
||||
let mut request = Request::builder()
|
||||
.version(version)
|
||||
.method(method.clone())
|
||||
.uri("https://www.example.test/missing?query=1")
|
||||
.body(Body::from_stream(stream))
|
||||
.unwrap();
|
||||
assert_eq!(request.body().size_hint().exact(), None);
|
||||
let result = match prepare_error_fallback_body(&mut request, true).await {
|
||||
Ok(result) => result,
|
||||
Err(response) => panic!("unexpected status {}", response.status()),
|
||||
};
|
||||
assert!(
|
||||
matches!(result, ErrorFallbackBody::Replayable(ref bytes) if bytes.is_empty()),
|
||||
"an empty {method:?} request over {version:?} should allow fallback"
|
||||
);
|
||||
assert!(request.body().is_end_stream());
|
||||
assert_eq!(request.uri().path_and_query().unwrap().as_str(), "/missing?query=1");
|
||||
for version in [Version::HTTP_2, Version::HTTP_3] {
|
||||
for method in [Method::GET, Method::HEAD] {
|
||||
let stream = futures_util::stream::empty::<Result<Bytes, std::io::Error>>();
|
||||
let mut request = Request::builder()
|
||||
.version(version)
|
||||
.method(method.clone())
|
||||
.uri("https://www.example.test/missing?query=1")
|
||||
.body(Body::from_stream(stream))
|
||||
.unwrap();
|
||||
assert_eq!(request.body().size_hint().exact(), None);
|
||||
let result = match prepare_error_fallback_body(&mut request, true).await {
|
||||
Ok(result) => result,
|
||||
Err(response) => panic!("unexpected status {}", response.status()),
|
||||
};
|
||||
assert!(
|
||||
matches!(result, ErrorFallbackBody::Replayable(ref bytes) if bytes.is_empty()),
|
||||
"an empty {method:?} request over {version:?} should allow fallback"
|
||||
);
|
||||
assert!(request.body().is_end_stream());
|
||||
assert_eq!(
|
||||
request.uri().path_and_query().unwrap().as_str(),
|
||||
"/missing?query=1"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn error_fallback_preserves_unknown_length_get_payload_without_replaying_it() {
|
||||
use bytes::Bytes;
|
||||
#[tokio::test]
|
||||
async fn error_fallback_preserves_unknown_length_get_payload_without_replaying_it() {
|
||||
use bytes::Bytes;
|
||||
|
||||
let stream = futures_util::stream::iter([Ok::<_, std::io::Error>(
|
||||
Bytes::from_static(b"unexpected-payload"),
|
||||
)]);
|
||||
let mut request = Request::builder()
|
||||
.method(Method::GET)
|
||||
.version(Version::HTTP_3)
|
||||
.uri("https://www.example.test/missing")
|
||||
.body(Body::from_stream(stream))
|
||||
.unwrap();
|
||||
let result = match prepare_error_fallback_body(&mut request, true).await {
|
||||
Ok(result) => result,
|
||||
Err(response) => panic!("unexpected status {}", response.status()),
|
||||
};
|
||||
assert!(matches!(result, ErrorFallbackBody::Unavailable));
|
||||
assert_eq!(
|
||||
request.into_body().collect().await.unwrap().to_bytes(),
|
||||
Bytes::from_static(b"unexpected-payload")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn error_fallback_does_not_poll_unknown_length_post_payload() {
|
||||
use bytes::Bytes;
|
||||
|
||||
let stream = futures_util::stream::pending::<Result<Bytes, std::io::Error>>();
|
||||
let mut request = Request::builder()
|
||||
.method(Method::POST)
|
||||
.version(Version::HTTP_2)
|
||||
.uri("https://www.example.test/form")
|
||||
.body(Body::from_stream(stream))
|
||||
.unwrap();
|
||||
let result = match prepare_error_fallback_body(&mut request, true).await {
|
||||
Ok(result) => result,
|
||||
Err(response) => panic!("unexpected status {}", response.status()),
|
||||
};
|
||||
assert!(matches!(result, ErrorFallbackBody::Unavailable));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fallback_terminal_404_html_takes_precedence_over_builtin_error_page() {
|
||||
use bytes::Bytes;
|
||||
|
||||
let mut response = Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.header("content-type", "text/html; charset=utf-8")
|
||||
.body(Body::from("<html><title>ReactPHP 404</title></html>"))
|
||||
.unwrap();
|
||||
response.extensions_mut().insert(ErrorFallbackResponse);
|
||||
let configured_page = crate::pages::Page {
|
||||
kind: crate::pages::Kind::Builtin,
|
||||
status: Some(404),
|
||||
html: String::new(),
|
||||
};
|
||||
if should_replace_error_page(&response, false) {
|
||||
crate::pages::replace_error(&mut response, &configured_page);
|
||||
let stream = futures_util::stream::iter([Ok::<_, std::io::Error>(Bytes::from_static(
|
||||
b"unexpected-payload",
|
||||
))]);
|
||||
let mut request = Request::builder()
|
||||
.method(Method::GET)
|
||||
.version(Version::HTTP_3)
|
||||
.uri("https://www.example.test/missing")
|
||||
.body(Body::from_stream(stream))
|
||||
.unwrap();
|
||||
let result = match prepare_error_fallback_body(&mut request, true).await {
|
||||
Ok(result) => result,
|
||||
Err(response) => panic!("unexpected status {}", response.status()),
|
||||
};
|
||||
assert!(matches!(result, ErrorFallbackBody::Unavailable));
|
||||
assert_eq!(
|
||||
request.into_body().collect().await.unwrap().to_bytes(),
|
||||
Bytes::from_static(b"unexpected-payload")
|
||||
);
|
||||
}
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
assert_eq!(
|
||||
response.into_body().collect().await.unwrap().to_bytes(),
|
||||
Bytes::from_static(b"<html><title>ReactPHP 404</title></html>")
|
||||
);
|
||||
|
||||
// A proxy-generated response is eligible for configured global/vhost
|
||||
// pages even when interception of *application* errors is disabled.
|
||||
let response = Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert!(should_replace_error_page(&response, false));
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn error_fallback_does_not_poll_unknown_length_post_payload() {
|
||||
use bytes::Bytes;
|
||||
|
||||
#[tokio::test]
|
||||
async fn application_404_is_not_overwritten_by_global_pages_when_interception_is_off() {
|
||||
use http_body_util::BodyExt;
|
||||
let stream = futures_util::stream::pending::<Result<Bytes, std::io::Error>>();
|
||||
let mut request = Request::builder()
|
||||
.method(Method::POST)
|
||||
.version(Version::HTTP_2)
|
||||
.uri("https://www.example.test/form")
|
||||
.body(Body::from_stream(stream))
|
||||
.unwrap();
|
||||
let result = match prepare_error_fallback_body(&mut request, true).await {
|
||||
Ok(result) => result,
|
||||
Err(response) => panic!("unexpected status {}", response.status()),
|
||||
};
|
||||
assert!(matches!(result, ErrorFallbackBody::Unavailable));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fallback_terminal_404_html_takes_precedence_over_builtin_error_page() {
|
||||
use bytes::Bytes;
|
||||
|
||||
for source in ["reverse_proxy", "web_server_php"] {
|
||||
let mut response = Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(Body::from(format!("{source}: origin 404 body")))
|
||||
.header("content-type", "text/html; charset=utf-8")
|
||||
.body(Body::from("<html><title>ReactPHP 404</title></html>"))
|
||||
.unwrap();
|
||||
// Both paths use intercept_route_origin_error to set this marker
|
||||
// once the origin response reaches Response<Body>.
|
||||
response.extensions_mut().insert(crate::pages::OriginProvidedResponse);
|
||||
assert!(!should_replace_error_page(&response, false), "{source}");
|
||||
let bytes = response.into_body().collect().await.unwrap().to_bytes();
|
||||
assert_eq!(bytes.as_ref(), format!("{source}: origin 404 body").as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compiled_vhost_origin_interception_controls_application_404() {
|
||||
for enabled in [false, true] {
|
||||
let mut config = crate::config::demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
config.pages.errors.insert(404, crate::pages::Page {
|
||||
kind: crate::pages::Kind::Html,
|
||||
status: Some(503),
|
||||
html: "RustProxy custom error page".into(),
|
||||
});
|
||||
if enabled {
|
||||
config.vhosts[0].pages.intercept_statuses = vec![404];
|
||||
response.extensions_mut().insert(ErrorFallbackResponse);
|
||||
let configured_page = crate::pages::Page {
|
||||
kind: crate::pages::Kind::Builtin,
|
||||
status: Some(404),
|
||||
html: String::new(),
|
||||
};
|
||||
if should_replace_error_page(&response, false) {
|
||||
crate::pages::replace_error(&mut response, &configured_page);
|
||||
}
|
||||
let snapshot = crate::config::compile(config, 1, &validation_policy()).unwrap();
|
||||
let route = snapshot.select("localhost", "/").unwrap();
|
||||
let response = Response::builder().status(StatusCode::NOT_FOUND)
|
||||
.body(Body::from("application custom 404"))
|
||||
.unwrap();
|
||||
let response = intercept_route_origin_error(response, route, &snapshot);
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
assert!(response.extensions().get::<crate::pages::OriginProvidedResponse>().is_some());
|
||||
assert!(!should_replace_error_page(&response, false));
|
||||
assert_eq!(response.extensions().get::<crate::pages::PageServed>().is_some(), enabled);
|
||||
if enabled {
|
||||
assert_eq!(response.headers().get("cache-control").unwrap(), "no-store");
|
||||
assert_eq!(
|
||||
response.into_body().collect().await.unwrap().to_bytes(),
|
||||
Bytes::from_static(b"<html><title>ReactPHP 404</title></html>")
|
||||
);
|
||||
|
||||
// A proxy-generated response is eligible for configured global/vhost
|
||||
// pages even when interception of *application* errors is disabled.
|
||||
let response = Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert!(should_replace_error_page(&response, false));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn application_404_is_not_overwritten_by_global_pages_when_interception_is_off() {
|
||||
use http_body_util::BodyExt;
|
||||
|
||||
for source in ["reverse_proxy", "web_server_php"] {
|
||||
let mut response = Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(Body::from(format!("{source}: origin 404 body")))
|
||||
.unwrap();
|
||||
// Both paths use intercept_route_origin_error to set this marker
|
||||
// once the origin response reaches Response<Body>.
|
||||
response
|
||||
.extensions_mut()
|
||||
.insert(crate::pages::OriginProvidedResponse);
|
||||
assert!(!should_replace_error_page(&response, false), "{source}");
|
||||
let bytes = response.into_body().collect().await.unwrap().to_bytes();
|
||||
assert_eq!(
|
||||
bytes.as_ref(),
|
||||
format!("{source}: origin 404 body").as_bytes()
|
||||
);
|
||||
}
|
||||
let bytes = response.into_body().collect().await.unwrap().to_bytes();
|
||||
if enabled {
|
||||
assert_eq!(bytes, "RustProxy custom error page");
|
||||
} else {
|
||||
assert_eq!(bytes, "application custom 404");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compiled_vhost_origin_interception_controls_application_404() {
|
||||
for enabled in [false, true] {
|
||||
let mut config = crate::config::demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
config.pages.errors.insert(
|
||||
404,
|
||||
crate::pages::Page {
|
||||
kind: crate::pages::Kind::Html,
|
||||
status: Some(503),
|
||||
html: "RustProxy custom error page".into(),
|
||||
},
|
||||
);
|
||||
if enabled {
|
||||
config.vhosts[0].pages.intercept_statuses = vec![404];
|
||||
}
|
||||
let snapshot = crate::config::compile(config, 1, &validation_policy()).unwrap();
|
||||
let route = snapshot.select("localhost", "/").unwrap();
|
||||
let response = Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(Body::from("application custom 404"))
|
||||
.unwrap();
|
||||
let response = intercept_route_origin_error(response, route, &snapshot);
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
assert!(response
|
||||
.extensions()
|
||||
.get::<crate::pages::OriginProvidedResponse>()
|
||||
.is_some());
|
||||
assert!(!should_replace_error_page(&response, false));
|
||||
assert_eq!(
|
||||
response
|
||||
.extensions()
|
||||
.get::<crate::pages::PageServed>()
|
||||
.is_some(),
|
||||
enabled
|
||||
);
|
||||
if enabled {
|
||||
assert_eq!(response.headers().get("cache-control").unwrap(), "no-store");
|
||||
}
|
||||
let bytes = response.into_body().collect().await.unwrap().to_bytes();
|
||||
if enabled {
|
||||
assert_eq!(bytes, "RustProxy custom error page");
|
||||
} else {
|
||||
assert_eq!(bytes, "application custom 404");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-2
@@ -14,13 +14,13 @@ use axum::{
|
||||
Extension, Router,
|
||||
};
|
||||
use body::{Direction, Exchange, MeteredBody};
|
||||
use bytes::Bytes;
|
||||
use http::{
|
||||
header::{HeaderValue, HOST, USER_AGENT},
|
||||
HeaderMap, Method, StatusCode, Uri, Version,
|
||||
};
|
||||
use http_body::Body as HttpBody;
|
||||
use http_body_util::BodyExt;
|
||||
use bytes::Bytes;
|
||||
#[derive(Clone)]
|
||||
struct ResponseRulesApplied;
|
||||
#[derive(Clone)]
|
||||
@@ -162,7 +162,13 @@ fn upstream_request_version(
|
||||
backend: &crate::config::CompiledBackend,
|
||||
websocket_h2: bool,
|
||||
) -> Version {
|
||||
if websocket_h2 || backend.protocol == crate::config::UpstreamProtocol::Http2 {
|
||||
// HTTPS legacy `http2` must still permit H1 after ALPN negotiation.
|
||||
// Cleartext legacy `http2` and strict `h2` use H2 (prior knowledge on HTTP).
|
||||
if websocket_h2
|
||||
|| backend.protocol == crate::config::UpstreamProtocol::H2
|
||||
|| (backend.protocol == crate::config::UpstreamProtocol::Http2
|
||||
&& backend.origin.starts_with("http://"))
|
||||
{
|
||||
Version::HTTP_2
|
||||
} else {
|
||||
Version::HTTP_11
|
||||
|
||||
Reference in new issue
Block a user