Skip to content

Commit d3bcd0a

Browse files
committed
Request size limiter
This pr addresses #941, it implements a body size limit layer to reject request whose size is greater than 7168. This also ensures both v1 and v2 have the same request size which makes it hard for an attacker to tell if a request is v1/v2 baed on payload.
1 parent 27cc8a1 commit d3bcd0a

5 files changed

Lines changed: 102 additions & 10 deletions

File tree

Cargo-minimal.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2872,6 +2872,7 @@ dependencies = [
28722872
"tokio-stream",
28732873
"tokio-tungstenite",
28742874
"tower",
2875+
"tower-http",
28752876
"tracing",
28762877
"tracing-subscriber",
28772878
"uuid",

Cargo-recent.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2872,6 +2872,7 @@ dependencies = [
28722872
"tokio-stream",
28732873
"tokio-tungstenite",
28742874
"tower",
2875+
"tower-http",
28752876
"tracing",
28762877
"tracing-subscriber",
28772878
"uuid",

payjoin-mailroom/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ tokio-rustls-acme = { version = "0.9.0", features = ["axum"], optional = true }
7272
tokio-stream = { version = "0.1.17", features = ["net"] }
7373
tokio-tungstenite = { version = "0.27.0", optional = true }
7474
tower = "0.5"
75+
tower-http = { version = "0.6", features = ["limit"] }
7576
tracing = "0.1"
7677
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
7778

payjoin-mailroom/src/directory.rs

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ const CHACHA20_POLY1305_NONCE_LEN: usize = 32; // chacha20poly1305 n_k
1818
const POLY1305_TAG_SIZE: usize = 16;
1919
pub const BHTTP_REQ_BYTES: usize =
2020
ENCAPSULATED_MESSAGE_BYTES - (CHACHA20_POLY1305_NONCE_LEN + POLY1305_TAG_SIZE);
21-
const V1_MAX_BUFFER_SIZE: usize = 65536;
21+
pub const V1_MAX_BUFFER_SIZE: usize = 7168;
2222

2323
const V1_REJECT_RES_JSON: &str =
2424
r#"{{"errorCode": "original-psbt-rejected ", "message": "Body is not a string"}}"#;
@@ -276,9 +276,7 @@ impl<D: Db> Service<D> {
276276
.await
277277
.map_err(|e| HandlerError::InternalServerError(e.into()))?
278278
.to_bytes();
279-
if req.len() > V1_MAX_BUFFER_SIZE {
280-
return Err(HandlerError::PayloadTooLarge);
281-
}
279+
282280
match self.db.post_v2_payload(&id, req.into()).await {
283281
Ok(_) => Ok(none_response),
284282
Err(e) => Err(HandlerError::InternalServerError(e.into())),
@@ -322,9 +320,6 @@ impl<D: Db> Service<D> {
322320
.await
323321
.map_err(|e| HandlerError::InternalServerError(e.into()))?
324322
.to_bytes();
325-
if req.len() > V1_MAX_BUFFER_SIZE {
326-
return Err(HandlerError::PayloadTooLarge);
327-
}
328323

329324
let body_str = std::str::from_utf8(&req).map_err(|e| HandlerError::BadRequest(e.into()))?;
330325
self.check_v1_blocklist(body_str).await?;
@@ -455,7 +450,6 @@ async fn handle_directory_home_path() -> Result<Response<Body>, HandlerError> {
455450

456451
#[derive(Debug)]
457452
enum HandlerError {
458-
PayloadTooLarge,
459453
InternalServerError(anyhow::Error),
460454
ServiceUnavailable(anyhow::Error),
461455
SenderGone(anyhow::Error),
@@ -470,11 +464,11 @@ impl HandlerError {
470464
fn to_response(&self) -> Response<Body> {
471465
let mut res = Response::new(empty());
472466
match self {
473-
HandlerError::PayloadTooLarge => *res.status_mut() = StatusCode::PAYLOAD_TOO_LARGE,
474467
HandlerError::InternalServerError(e) => {
475468
error!("Internal server error: {}", e);
476469
*res.status_mut() = StatusCode::INTERNAL_SERVER_ERROR
477470
}
471+
478472
HandlerError::ServiceUnavailable(e) => {
479473
error!("Service temporarily unavailable: {}", e);
480474
*res.status_mut() = StatusCode::SERVICE_UNAVAILABLE

payjoin-mailroom/src/lib.rs

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ use opentelemetry_sdk::metrics::SdkMeterProvider;
1111
use rand::Rng;
1212
use tokio_listener::{Listener, SystemOptions, UserOptions};
1313
use tower::{Service, ServiceBuilder};
14+
use tower_http::limit::RequestBodyLimitLayer;
1415
use tracing::info;
1516

1617
use crate::ohttp_relay::SentinelTag;
@@ -360,7 +361,8 @@ fn build_app(services: Services) -> Router {
360361
.layer(
361362
ServiceBuilder::new()
362363
.layer(axum::middleware::from_fn_with_state(metrics.clone(), track_metrics))
363-
.layer(axum::middleware::from_fn_with_state(metrics, track_connections)),
364+
.layer(axum::middleware::from_fn_with_state(metrics, track_connections))
365+
.layer(RequestBodyLimitLayer::new(crate::directory::V1_MAX_BUFFER_SIZE)),
364366
)
365367
.with_state(services);
366368

@@ -565,4 +567,97 @@ mod tests {
565567
assert!(metric_names.contains(&TOTAL_CONNECTIONS), "missing total_connections");
566568
assert!(metric_names.contains(&ACTIVE_CONNECTIONS), "missing active_connections");
567569
}
570+
571+
/// Exercises the full middleware stack (track_metrics, track_connections, RequestBodyLimit)
572+
/// with a request that has a body, ensuring the layer ordering compiles and runs correctly.
573+
#[tokio::test]
574+
async fn middleware_stack_accepts_request_with_body() {
575+
use axum::body::Body;
576+
use axum::http::header::CONTENT_TYPE;
577+
use axum::http::Request;
578+
use tower::ServiceExt;
579+
580+
let tempdir = tempdir().unwrap();
581+
let config = Config::new(
582+
"[::]:0".parse().expect("valid listener address"),
583+
tempdir.path().to_path_buf(),
584+
Duration::from_secs(2),
585+
None,
586+
);
587+
588+
let sentinel_tag = generate_sentinel_tag();
589+
let services = Services {
590+
directory: init_directory(&config, sentinel_tag).await.unwrap(),
591+
relay: ohttp_relay::Service::new(sentinel_tag).await,
592+
metrics: MetricsService::new(None),
593+
#[cfg(feature = "access-control")]
594+
geoip: None,
595+
};
596+
597+
let app = build_app(services);
598+
599+
let body = b"small payload under 65k limit";
600+
let request = Request::builder()
601+
.method("POST")
602+
.uri("/some-path")
603+
.header(CONTENT_TYPE, ohttp_relay::EXPECTED_MEDIA_TYPE.clone())
604+
.body(Body::from(body.as_slice()))
605+
.unwrap();
606+
607+
let response = ServiceExt::<Request<Body>>::oneshot(app, request).await.unwrap();
608+
609+
assert!(
610+
!response.status().is_server_error(),
611+
"middleware stack should accept request with body (no 5xx)"
612+
);
613+
assert_ne!(
614+
response.status(),
615+
axum::http::StatusCode::PAYLOAD_TOO_LARGE,
616+
"small body should not be rejected by body limit"
617+
);
618+
}
619+
620+
/// Ensures RequestBodyLimitLayer (65_536 bytes) rejects oversized bodies (413 or 415).
621+
#[tokio::test]
622+
async fn request_body_limit_rejects_oversized_body() {
623+
use axum::body::Body;
624+
use axum::http::header::CONTENT_TYPE;
625+
use axum::http::Request;
626+
use tower::ServiceExt;
627+
628+
let tempdir = tempdir().unwrap();
629+
let config = Config::new(
630+
"[::]:0".parse().expect("valid listener address"),
631+
tempdir.path().to_path_buf(),
632+
Duration::from_secs(2),
633+
None,
634+
);
635+
636+
let sentinel_tag = generate_sentinel_tag();
637+
let services = Services {
638+
directory: init_directory(&config, sentinel_tag).await.unwrap(),
639+
relay: ohttp_relay::Service::new(sentinel_tag).await,
640+
metrics: MetricsService::new(None),
641+
#[cfg(feature = "access-control")]
642+
geoip: None,
643+
};
644+
645+
let app = build_app(services);
646+
647+
let oversized: Vec<u8> = (0..65_537).map(|_| 0u8).collect();
648+
let request = Request::builder()
649+
.method("POST")
650+
.uri("/")
651+
.header(CONTENT_TYPE, ohttp_relay::EXPECTED_MEDIA_TYPE.clone())
652+
.body(Body::from(oversized))
653+
.unwrap();
654+
655+
let response = ServiceExt::<Request<Body>>::oneshot(app, request).await.unwrap();
656+
657+
assert!(
658+
response.status().is_client_error(),
659+
"body over 65_536 bytes should be rejected with a 4xx status, got {}",
660+
response.status()
661+
);
662+
}
568663
}

0 commit comments

Comments
 (0)