Skip to content

Commit 95cb178

Browse files
committed
Enforce content-length validation on sender and size limits on payjoin-cli
1 parent d802c9d commit 95cb178

File tree

5 files changed

+74
-38
lines changed

5 files changed

+74
-38
lines changed

payjoin-cli/Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,17 +29,19 @@ v2 = ["payjoin/v2", "payjoin/io"]
2929
anyhow = "1.0.70"
3030
async-trait = "0.1"
3131
bitcoincore-rpc = "0.19.0"
32+
bytes = "1.10.1"
3233
clap = { version = "~4.0.32", features = ["derive"] }
3334
config = "0.13.3"
3435
env_logger = "0.9.0"
36+
futures = "0.3.31"
3537
http-body-util = { version = "0.1", optional = true }
3638
hyper = { version = "1", features = ["http1", "server"], optional = true }
3739
hyper-rustls = { version = "0.26", optional = true }
3840
hyper-util = { version = "0.1", optional = true }
3941
log = "0.4.7"
4042
payjoin = { version = "0.24.0", default-features = false }
4143
rcgen = { version = "0.11.1", optional = true }
42-
reqwest = { version = "0.12", default-features = false }
44+
reqwest = { version = "0.12", features = ["stream"], default-features = false }
4345
rustls = { version = "0.22.4", optional = true }
4446
serde = { version = "1.0.160", features = ["derive"] }
4547
sled = "0.34"

payjoin-cli/src/app/v1.rs

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ use std::sync::Arc;
55

66
use anyhow::{anyhow, Context, Result};
77
use bitcoincore_rpc::bitcoin::Amount;
8+
use bytes::{BufMut, BytesMut};
9+
use futures::StreamExt;
810
use http_body_util::combinators::BoxBody;
911
use http_body_util::{BodyExt, Full};
1012
use hyper::body::{Bytes, Incoming};
@@ -29,6 +31,11 @@ use crate::db::Database;
2931
#[cfg(feature = "_danger-local-https")]
3032
pub const LOCAL_CERT_FILE: &str = "localhost.der";
3133

34+
/// 4M block size limit with base64 encoding overhead => maximum reasonable size of content-length
35+
/// 4_000_000 * 4 / 3 fits in u32
36+
const MAX_CONTENT_LENGTH: usize = 4_000_000 * 4 / 3;
37+
38+
#[derive(Clone)]
3239
struct Headers<'a>(&'a hyper::HeaderMap);
3340
impl payjoin::receive::v1::Headers for Headers<'_> {
3441
fn get_header(&self, key: &str) -> Option<&str> {
@@ -88,7 +95,29 @@ impl AppTrait for App {
8895
"Sent fallback transaction hex: {:#}",
8996
payjoin::bitcoin::consensus::encode::serialize_hex(&fallback_tx)
9097
);
91-
let psbt = ctx.process_response(&response.bytes().await?).map_err(|e| {
98+
99+
let expected_length = response
100+
.headers()
101+
.get("Content-Length")
102+
.and_then(|val| val.to_str().ok())
103+
.and_then(|s| s.parse::<usize>().ok())
104+
.unwrap_or(MAX_CONTENT_LENGTH);
105+
106+
if expected_length > MAX_CONTENT_LENGTH {
107+
return Err(anyhow!("Response body is too large: {} bytes", expected_length));
108+
}
109+
110+
let mut body_stream = response.bytes_stream();
111+
let mut body = BytesMut::with_capacity(expected_length.min(MAX_CONTENT_LENGTH));
112+
while let Some(chunk) = body_stream.next().await {
113+
let chunk = chunk.map_err(|e| anyhow!("Error reading response body: {}", e))?;
114+
if body.len() + chunk.len() > MAX_CONTENT_LENGTH {
115+
return Err(anyhow!("Response body exceeds maximum allowed size"));
116+
}
117+
body.put(chunk);
118+
}
119+
120+
let psbt = ctx.process_response(&body).map_err(|e| {
92121
log::debug!("Error processing response: {e:?}");
93122
anyhow!("Failed to process response {e}")
94123
})?;
@@ -276,9 +305,34 @@ impl App {
276305
) -> Result<Response<BoxBody<Bytes, hyper::Error>>, ReplyableError> {
277306
let (parts, body) = req.into_parts();
278307
let headers = Headers(&parts.headers);
308+
309+
let expected_length = headers
310+
.0
311+
.get("Content-Length")
312+
.and_then(|val| val.to_str().ok())
313+
.and_then(|s| s.parse::<usize>().ok())
314+
.unwrap_or(MAX_CONTENT_LENGTH);
315+
316+
if expected_length > MAX_CONTENT_LENGTH {
317+
log::error!("Error: Content length exceeds max allowed");
318+
return Err(Implementation(
319+
anyhow!("Content length too large: {expected_length}").into(),
320+
));
321+
}
322+
323+
let mut body_stream = body.into_data_stream();
324+
let mut body = BytesMut::with_capacity(expected_length.min(MAX_CONTENT_LENGTH));
325+
while let Some(chunk) = body_stream.next().await {
326+
let chunk = chunk.map_err(|e| Implementation(e.into()))?;
327+
if body.len() + chunk.len() > MAX_CONTENT_LENGTH {
328+
return Err(Implementation(anyhow!("Request body too large").into()));
329+
}
330+
body.put(chunk);
331+
}
332+
279333
let query_string = parts.uri.query().unwrap_or("");
280-
let body = body.collect().await.map_err(|e| Implementation(e.into()))?.to_bytes();
281-
let proposal = UncheckedProposal::from_request(&body, query_string, headers)?;
334+
let body = body.freeze();
335+
let proposal = UncheckedProposal::from_request(&body, query_string, headers.clone())?;
282336

283337
let payjoin_proposal = self.process_v1_proposal(proposal)?;
284338
let psbt = payjoin_proposal.psbt();

payjoin/src/core/send/error.rs

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ use bitcoin::transaction::Version;
66
use bitcoin::Sequence;
77

88
use crate::error_codes::ErrorCode;
9-
use crate::MAX_CONTENT_LENGTH;
109

1110
/// Error building a Sender from a SenderBuilder.
1211
///
@@ -95,7 +94,6 @@ pub struct ValidationError(InternalValidationError);
9594
#[derive(Debug)]
9695
pub(crate) enum InternalValidationError {
9796
Parse,
98-
ContentTooLarge,
9997
Proposal(InternalProposalError),
10098
#[cfg(feature = "v2")]
10199
V2Encapsulation(crate::send::v2::EncapsulationError),
@@ -119,7 +117,6 @@ impl fmt::Display for ValidationError {
119117

120118
match &self.0 {
121119
Parse => write!(f, "couldn't decode as PSBT or JSON",),
122-
ContentTooLarge => write!(f, "content is larger than {MAX_CONTENT_LENGTH} bytes"),
123120
Proposal(e) => write!(f, "proposal PSBT error: {e}"),
124121
#[cfg(feature = "v2")]
125122
V2Encapsulation(e) => write!(f, "v2 encapsulation error: {e}"),
@@ -133,7 +130,6 @@ impl std::error::Error for ValidationError {
133130

134131
match &self.0 {
135132
Parse => None,
136-
ContentTooLarge => None,
137133
Proposal(e) => Some(e),
138134
#[cfg(feature = "v2")]
139135
V2Encapsulation(e) => Some(e),

payjoin/src/core/send/v1.rs

Lines changed: 6 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ use url::Url;
2929
use super::*;
3030
pub use crate::output_substitution::OutputSubstitution;
3131
use crate::psbt::PsbtExt;
32-
use crate::{PjUri, Request, MAX_CONTENT_LENGTH};
32+
use crate::{PjUri, Request};
3333

3434
/// A builder to construct the properties of a `Sender`.
3535
#[derive(Clone)]
@@ -278,10 +278,6 @@ impl V1Context {
278278
/// valid you will get appropriate PSBT that you should sign and broadcast.
279279
#[inline]
280280
pub fn process_response(self, response: &[u8]) -> Result<Psbt, ResponseError> {
281-
if response.len() > MAX_CONTENT_LENGTH {
282-
return Err(ResponseError::from(InternalValidationError::ContentTooLarge));
283-
}
284-
285281
let res_str = std::str::from_utf8(response).map_err(|_| InternalValidationError::Parse)?;
286282
let proposal = Psbt::from_str(res_str).map_err(|_| ResponseError::parse(res_str))?;
287283
self.psbt_context.process_proposal(proposal).map_err(Into::into)
@@ -425,6 +421,7 @@ mod test {
425421
"message": "This version of payjoin is not supported."
426422
})
427423
.to_string();
424+
428425
match ctx.process_response(known_json_error.as_bytes()) {
429426
Err(ResponseError::WellKnown(WellKnownError {
430427
code: ErrorCode::VersionUnsupported,
@@ -439,6 +436,7 @@ mod test {
439436
"message": "This version of payjoin is not supported."
440437
})
441438
.to_string();
439+
442440
match ctx.process_response(invalid_json_error.as_bytes()) {
443441
Err(ResponseError::Validation(_)) => (),
444442
_ => panic!("Expected unrecognized JSON error"),
@@ -448,13 +446,15 @@ mod test {
448446
#[test]
449447
fn process_response_valid() {
450448
let ctx = create_v1_context();
449+
451450
let response = ctx.process_response(PAYJOIN_PROPOSAL.as_bytes());
452451
assert!(response.is_ok())
453452
}
454453

455454
#[test]
456455
fn process_response_invalid_psbt() {
457456
let ctx = create_v1_context();
457+
458458
let response = ctx.process_response(INVALID_PSBT.as_bytes());
459459
match response {
460460
Ok(_) => panic!("Invalid PSBT should have caused an error"),
@@ -476,6 +476,7 @@ mod test {
476476
let invalid_utf8 = &[0xF0];
477477

478478
let ctx = create_v1_context();
479+
479480
let response = ctx.process_response(invalid_utf8);
480481
match response {
481482
Ok(_) => panic!("Invalid UTF-8 should have caused an error"),
@@ -490,25 +491,4 @@ mod test {
490491
},
491492
}
492493
}
493-
494-
#[test]
495-
fn process_response_invalid_buffer_len() {
496-
let mut data = PAYJOIN_PROPOSAL.as_bytes().to_vec();
497-
data.extend(std::iter::repeat(0).take(MAX_CONTENT_LENGTH + 1));
498-
499-
let ctx = create_v1_context();
500-
let response = ctx.process_response(&data);
501-
match response {
502-
Ok(_) => panic!("Invalid buffer length should have caused an error"),
503-
Err(error) => match error {
504-
ResponseError::Validation(e) => {
505-
assert_eq!(
506-
e.to_string(),
507-
ValidationError::from(InternalValidationError::ContentTooLarge).to_string()
508-
);
509-
}
510-
_ => panic!("Unexpected error type"),
511-
},
512-
}
513-
}
514494
}

payjoin/tests/integration.rs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,8 @@ mod integration {
100100
// **********************
101101
// Inside the Sender:
102102
// Sender checks, signs, finalizes, extracts, and broadcasts
103-
let checked_payjoin_proposal_psbt = ctx.process_response(response.as_bytes())?;
103+
let response_body = response.as_bytes();
104+
let checked_payjoin_proposal_psbt = ctx.process_response(response_body)?;
104105
let payjoin_tx = extract_pj_tx(&sender, checked_payjoin_proposal_psbt)?;
105106
sender.send_raw_transaction(&payjoin_tx)?;
106107

@@ -585,7 +586,8 @@ mod integration {
585586
// **********************
586587
// Inside the Sender:
587588
// Sender checks, signs, finalizes, extracts, and broadcasts
588-
let checked_payjoin_proposal_psbt = ctx.process_response(response.as_bytes())?;
589+
let response_body = response.as_bytes();
590+
let checked_payjoin_proposal_psbt = ctx.process_response(response_body)?;
589591
let payjoin_tx = extract_pj_tx(&sender, checked_payjoin_proposal_psbt)?;
590592
sender.send_raw_transaction(&payjoin_tx)?;
591593

@@ -1211,7 +1213,8 @@ mod integration {
12111213
// **********************
12121214
// Inside the Sender:
12131215
// Sender checks, signs, finalizes, extracts, and broadcasts
1214-
let checked_payjoin_proposal_psbt = ctx.process_response(response.as_bytes())?;
1216+
let response_body = response.as_bytes();
1217+
let checked_payjoin_proposal_psbt = ctx.process_response(response_body)?;
12151218
let payjoin_tx = extract_pj_tx(&sender, checked_payjoin_proposal_psbt)?;
12161219
sender.send_raw_transaction(&payjoin_tx)?;
12171220

@@ -1297,7 +1300,8 @@ mod integration {
12971300
// **********************
12981301
// Inside the Sender:
12991302
// Sender checks, signs, finalizes, extracts, and broadcasts
1300-
let checked_payjoin_proposal_psbt = ctx.process_response(response.as_bytes())?;
1303+
let response_body = response.as_bytes();
1304+
let checked_payjoin_proposal_psbt = ctx.process_response(response_body)?;
13011305
let payjoin_tx = extract_pj_tx(&sender, checked_payjoin_proposal_psbt)?;
13021306
sender.send_raw_transaction(&payjoin_tx)?;
13031307

0 commit comments

Comments
 (0)