Skip to content

Commit 144a8c3

Browse files
committed
Enforce content-length validation on sender and size limits on payjoin-cli
1 parent a838c95 commit 144a8c3

5 files changed

Lines changed: 108 additions & 27 deletions

File tree

payjoin-cli/src/app/v1.rs

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ use crate::db::Database;
2929
#[cfg(feature = "_danger-local-https")]
3030
pub const LOCAL_CERT_FILE: &str = "localhost.der";
3131

32+
/// 4M block size limit with base64 encoding overhead => maximum reasonable size of content-length
33+
/// 4_000_000 * 4 / 3 fits in u32
34+
pub const MAX_CONTENT_LENGTH: usize = 4_000_000 * 4 / 3;
35+
36+
#[derive(Clone)]
3237
struct Headers<'a>(&'a hyper::HeaderMap);
3338
impl payjoin::receive::v1::Headers for Headers<'_> {
3439
fn get_header(&self, key: &str) -> Option<&str> {
@@ -88,7 +93,19 @@ impl AppTrait for App {
8893
"Sent fallback transaction hex: {:#}",
8994
payjoin::bitcoin::consensus::encode::serialize_hex(&fallback_tx)
9095
);
91-
let psbt = ctx.process_response(&response.bytes().await?).map_err(|e| {
96+
97+
let content_length = response
98+
.headers()
99+
.get("content-length")
100+
.and_then(|val| val.to_str().ok())
101+
.and_then(|s| s.parse::<usize>().ok());
102+
103+
if content_length.unwrap() > MAX_CONTENT_LENGTH {
104+
log::debug!("Error in the size of content length");
105+
return Err(anyhow!("Response body is too large: {:?} bytes", content_length));
106+
}
107+
108+
let psbt = ctx.process_response(&response.bytes().await?, content_length).map_err(|e| {
92109
log::debug!("Error processing response: {e:?}");
93110
anyhow!("Failed to process response {e}")
94111
})?;
@@ -276,9 +293,24 @@ impl App {
276293
) -> Result<Response<BoxBody<Bytes, hyper::Error>>, ReplyableError> {
277294
let (parts, body) = req.into_parts();
278295
let headers = Headers(&parts.headers);
296+
297+
let content_length = headers
298+
.0
299+
.get("content-length")
300+
.and_then(|val| val.to_str().ok())
301+
.and_then(|s| s.parse::<usize>().ok())
302+
.unwrap();
303+
304+
if content_length > MAX_CONTENT_LENGTH {
305+
log::error!("Error in the size of content length");
306+
return Err(Implementation(
307+
anyhow!("Content length too large: {content_length}").into(),
308+
));
309+
};
310+
279311
let query_string = parts.uri.query().unwrap_or("");
280312
let body = body.collect().await.map_err(|e| Implementation(e.into()))?.to_bytes();
281-
let proposal = UncheckedProposal::from_request(&body, query_string, headers)?;
313+
let proposal = UncheckedProposal::from_request(&body, query_string, headers.clone())?;
282314

283315
let payjoin_proposal = self.process_v1_proposal(proposal)?;
284316
let psbt = payjoin_proposal.psbt();

payjoin-cli/src/app/v2/mod.rs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -184,9 +184,16 @@ impl App {
184184
Err(_) => {
185185
let (req, v1_ctx) = context.extract_v1();
186186
let response = post_request(req).await?;
187-
let psbt = Arc::new(
188-
v1_ctx.process_response(response.bytes().await?.to_vec().as_slice())?,
189-
);
187+
let content_length = response
188+
.headers()
189+
.get("content-length")
190+
.and_then(|val| val.to_str().ok())
191+
.and_then(|s| s.parse::<usize>().ok());
192+
193+
let psbt = Arc::new(v1_ctx.process_response(
194+
response.bytes().await?.to_vec().as_slice(),
195+
content_length,
196+
)?);
190197
self.process_pj_response((*psbt).clone())?;
191198
}
192199
}

payjoin/src/send/error.rs

Lines changed: 8 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,10 @@ pub struct ValidationError(InternalValidationError);
9594
#[derive(Debug)]
9695
pub(crate) enum InternalValidationError {
9796
Parse,
98-
ContentTooLarge,
97+
ContentLengthMismatch {
98+
expected: usize,
99+
actual: usize,
100+
},
99101
Proposal(InternalProposalError),
100102
#[cfg(feature = "v2")]
101103
V2Encapsulation(crate::send::v2::EncapsulationError),
@@ -119,7 +121,9 @@ impl fmt::Display for ValidationError {
119121

120122
match &self.0 {
121123
Parse => write!(f, "couldn't decode as PSBT or JSON",),
122-
ContentTooLarge => write!(f, "content is larger than {MAX_CONTENT_LENGTH} bytes"),
124+
ContentLengthMismatch { expected, actual } => {
125+
write!(f, "Content-Length mismatch. Expected {expected}, got {actual}")
126+
}
123127
Proposal(e) => write!(f, "proposal PSBT error: {e}"),
124128
#[cfg(feature = "v2")]
125129
V2Encapsulation(e) => write!(f, "v2 encapsulation error: {e}"),
@@ -133,7 +137,7 @@ impl std::error::Error for ValidationError {
133137

134138
match &self.0 {
135139
Parse => None,
136-
ContentTooLarge => None,
140+
ContentLengthMismatch { .. } => None,
137141
Proposal(e) => Some(e),
138142
#[cfg(feature = "v2")]
139143
V2Encapsulation(e) => Some(e),

payjoin/src/send/v1.rs

Lines changed: 39 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ use super::*;
3030
pub use crate::output_substitution::OutputSubstitution;
3131
use crate::psbt::PsbtExt;
3232
use crate::request::Request;
33-
use crate::{PjUri, MAX_CONTENT_LENGTH};
33+
use crate::PjUri;
3434

3535
/// A builder to construct the properties of a `Sender`.
3636
#[derive(Clone)]
@@ -278,9 +278,19 @@ impl V1Context {
278278
/// Call this method with response from receiver to continue BIP78 flow. If the response is
279279
/// valid you will get appropriate PSBT that you should sign and broadcast.
280280
#[inline]
281-
pub fn process_response(self, response: &[u8]) -> Result<Psbt, ResponseError> {
282-
if response.len() > MAX_CONTENT_LENGTH {
283-
return Err(ResponseError::from(InternalValidationError::ContentTooLarge));
281+
pub fn process_response(
282+
self,
283+
response: &[u8],
284+
content_length: Option<usize>,
285+
) -> Result<Psbt, ResponseError> {
286+
if let Some(expected_len) = content_length {
287+
if response.len() != expected_len {
288+
return Err(InternalValidationError::ContentLengthMismatch {
289+
expected: expected_len,
290+
actual: response.len(),
291+
}
292+
.into());
293+
}
284294
}
285295

286296
let res_str = std::str::from_utf8(response).map_err(|_| InternalValidationError::Parse)?;
@@ -426,7 +436,9 @@ mod test {
426436
"message": "This version of payjoin is not supported."
427437
})
428438
.to_string();
429-
match ctx.process_response(known_json_error.as_bytes()) {
439+
440+
let content_length = Some(known_json_error.len());
441+
match ctx.process_response(known_json_error.as_bytes(), content_length) {
430442
Err(ResponseError::WellKnown(WellKnownError {
431443
code: ErrorCode::VersionUnsupported,
432444
..
@@ -440,7 +452,9 @@ mod test {
440452
"message": "This version of payjoin is not supported."
441453
})
442454
.to_string();
443-
match ctx.process_response(invalid_json_error.as_bytes()) {
455+
456+
let content_length = Some(invalid_json_error.len());
457+
match ctx.process_response(invalid_json_error.as_bytes(), content_length) {
444458
Err(ResponseError::Validation(_)) => (),
445459
_ => panic!("Expected unrecognized JSON error"),
446460
}
@@ -449,14 +463,20 @@ mod test {
449463
#[test]
450464
fn process_response_valid() {
451465
let ctx = create_v1_context();
452-
let response = ctx.process_response(PAYJOIN_PROPOSAL.as_bytes());
466+
let body = PAYJOIN_PROPOSAL.as_bytes();
467+
468+
let content_length = Some(body.len());
469+
let response = ctx.process_response(PAYJOIN_PROPOSAL.as_bytes(), content_length);
453470
assert!(response.is_ok())
454471
}
455472

456473
#[test]
457474
fn process_response_invalid_psbt() {
458475
let ctx = create_v1_context();
459-
let response = ctx.process_response(INVALID_PSBT.as_bytes());
476+
let body = INVALID_PSBT.as_bytes();
477+
478+
let content_length = Some(body.len());
479+
let response = ctx.process_response(INVALID_PSBT.as_bytes(), content_length);
460480
match response {
461481
Ok(_) => panic!("Invalid PSBT should have caused an error"),
462482
Err(error) => match error {
@@ -477,7 +497,9 @@ mod test {
477497
let invalid_utf8 = &[0xF0];
478498

479499
let ctx = create_v1_context();
480-
let response = ctx.process_response(invalid_utf8);
500+
501+
let content_length = Some(invalid_utf8.len());
502+
let response = ctx.process_response(invalid_utf8, content_length);
481503
match response {
482504
Ok(_) => panic!("Invalid UTF-8 should have caused an error"),
483505
Err(error) => match error {
@@ -494,18 +516,22 @@ mod test {
494516

495517
#[test]
496518
fn process_response_invalid_buffer_len() {
497-
let mut data = PAYJOIN_PROPOSAL.as_bytes().to_vec();
498-
data.extend(std::iter::repeat(0).take(MAX_CONTENT_LENGTH + 1));
519+
let data = PAYJOIN_PROPOSAL.as_bytes().to_vec();
520+
let bad_content_length = data.len() + 10;
499521

500522
let ctx = create_v1_context();
501-
let response = ctx.process_response(&data);
523+
let response = ctx.process_response(&data, Some(bad_content_length));
502524
match response {
503525
Ok(_) => panic!("Invalid buffer length should have caused an error"),
504526
Err(error) => match error {
505527
ResponseError::Validation(e) => {
506528
assert_eq!(
507529
e.to_string(),
508-
ValidationError::from(InternalValidationError::ContentTooLarge).to_string()
530+
ValidationError::from(InternalValidationError::ContentLengthMismatch {
531+
expected: bad_content_length,
532+
actual: data.len()
533+
})
534+
.to_string()
509535
);
510536
}
511537
_ => panic!("Unexpected error type"),

payjoin/tests/integration.rs

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,10 @@ 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 content_length = Some(response_body.len());
105+
let checked_payjoin_proposal_psbt =
106+
ctx.process_response(response_body, content_length)?;
104107
let payjoin_tx = extract_pj_tx(&sender, checked_payjoin_proposal_psbt)?;
105108
sender.send_raw_transaction(&payjoin_tx)?;
106109

@@ -585,7 +588,10 @@ mod integration {
585588
// **********************
586589
// Inside the Sender:
587590
// Sender checks, signs, finalizes, extracts, and broadcasts
588-
let checked_payjoin_proposal_psbt = ctx.process_response(response.as_bytes())?;
591+
let response_body = response.as_bytes();
592+
let content_length = Some(response_body.len());
593+
let checked_payjoin_proposal_psbt =
594+
ctx.process_response(response_body, content_length)?;
589595
let payjoin_tx = extract_pj_tx(&sender, checked_payjoin_proposal_psbt)?;
590596
sender.send_raw_transaction(&payjoin_tx)?;
591597

@@ -710,7 +716,7 @@ mod integration {
710716
assert!(response.status().is_success(), "error response: {}", response.status());
711717

712718
let checked_payjoin_proposal_psbt =
713-
send_ctx.process_response(&response.bytes().await?)?;
719+
send_ctx.process_response(&response.bytes().await?, None)?;
714720
let payjoin_tx = extract_pj_tx(&sender, checked_payjoin_proposal_psbt)?;
715721
sender.send_raw_transaction(&payjoin_tx)?;
716722
log::info!("sent");
@@ -1211,7 +1217,10 @@ mod integration {
12111217
// **********************
12121218
// Inside the Sender:
12131219
// Sender checks, signs, finalizes, extracts, and broadcasts
1214-
let checked_payjoin_proposal_psbt = ctx.process_response(response.as_bytes())?;
1220+
let response_body = response.as_bytes();
1221+
let content_length = Some(response_body.len());
1222+
let checked_payjoin_proposal_psbt =
1223+
ctx.process_response(response_body, content_length)?;
12151224
let payjoin_tx = extract_pj_tx(&sender, checked_payjoin_proposal_psbt)?;
12161225
sender.send_raw_transaction(&payjoin_tx)?;
12171226

@@ -1297,7 +1306,10 @@ mod integration {
12971306
// **********************
12981307
// Inside the Sender:
12991308
// Sender checks, signs, finalizes, extracts, and broadcasts
1300-
let checked_payjoin_proposal_psbt = ctx.process_response(response.as_bytes())?;
1309+
let response_body = response.as_bytes();
1310+
let content_length = Some(response_body.len());
1311+
let checked_payjoin_proposal_psbt =
1312+
ctx.process_response(response_body, content_length)?;
13011313
let payjoin_tx = extract_pj_tx(&sender, checked_payjoin_proposal_psbt)?;
13021314
sender.send_raw_transaction(&payjoin_tx)?;
13031315

0 commit comments

Comments
 (0)