Skip to content

Commit f2ab913

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

File tree

5 files changed

+88
-25
lines changed

5 files changed

+88
-25
lines changed

payjoin-cli/src/app/v1.rs

Lines changed: 31 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,21 @@ 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.get("content-length")
299+
.and_then(|val| val.to_str().ok())
300+
.and_then(|s| s.parse::<usize>().ok())
301+
.unwrap();
302+
303+
if content_length > MAX_CONTENT_LENGTH {
304+
log::error!("Error in the size of content length");
305+
return Err(Implementation(anyhow!("Content length too large: {content_length}").into()));
306+
};
307+
279308
let query_string = parts.uri.query().unwrap_or("");
280309
let body = body.collect().await.map_err(|e| Implementation(e.into()))?.to_bytes();
281-
let proposal = UncheckedProposal::from_request(&body, query_string, headers)?;
310+
let proposal = UncheckedProposal::from_request(&body, query_string, headers.clone())?;
282311

283312
let payjoin_proposal = self.process_v1_proposal(proposal)?;
284313
let psbt = payjoin_proposal.psbt();

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,8 +184,14 @@ impl App {
184184
Err(_) => {
185185
let (req, v1_ctx) = context.extract_v1();
186186
let response = post_request(req).await?;
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+
187193
let psbt = Arc::new(
188-
v1_ctx.process_response(response.bytes().await?.to_vec().as_slice())?,
194+
v1_ctx.process_response(response.bytes().await?.to_vec().as_slice(), content_length)?,
189195
);
190196
self.process_pj_response((*psbt).clone())?;
191197
}

payjoin/src/send/error.rs

Lines changed: 5 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,7 @@ pub struct ValidationError(InternalValidationError);
9594
#[derive(Debug)]
9695
pub(crate) enum InternalValidationError {
9796
Parse,
98-
ContentTooLarge,
97+
ContentLengthMismatch { expected: usize, actual: usize },
9998
Proposal(InternalProposalError),
10099
#[cfg(feature = "v2")]
101100
V2Encapsulation(crate::send::v2::EncapsulationError),
@@ -119,7 +118,9 @@ impl fmt::Display for ValidationError {
119118

120119
match &self.0 {
121120
Parse => write!(f, "couldn't decode as PSBT or JSON",),
122-
ContentTooLarge => write!(f, "content is larger than {MAX_CONTENT_LENGTH} bytes"),
121+
ContentLengthMismatch { expected, actual } => {
122+
write!(f, "Content-Length mismatch. Expected {expected}, got {actual}")
123+
},
123124
Proposal(e) => write!(f, "proposal PSBT error: {e}"),
124125
#[cfg(feature = "v2")]
125126
V2Encapsulation(e) => write!(f, "v2 encapsulation error: {e}"),
@@ -133,7 +134,7 @@ impl std::error::Error for ValidationError {
133134

134135
match &self.0 {
135136
Parse => None,
136-
ContentTooLarge => None,
137+
ContentLengthMismatch { .. } => None,
137138
Proposal(e) => Some(e),
138139
#[cfg(feature = "v2")]
139140
V2Encapsulation(e) => Some(e),

payjoin/src/send/v1.rs

Lines changed: 32 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,16 @@ 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(self, response: &[u8], content_length: Option<usize>) -> Result<Psbt, ResponseError> {
282+
if let Some(expected_len) = content_length {
283+
284+
if response.len() != expected_len {
285+
return Err(InternalValidationError::ContentLengthMismatch {
286+
expected: expected_len,
287+
actual: response.len(),
288+
}
289+
.into());
290+
}
284291
}
285292

286293
let res_str = std::str::from_utf8(response).map_err(|_| InternalValidationError::Parse)?;
@@ -426,7 +433,9 @@ mod test {
426433
"message": "This version of payjoin is not supported."
427434
})
428435
.to_string();
429-
match ctx.process_response(known_json_error.as_bytes()) {
436+
437+
let content_length = Some(known_json_error.len());
438+
match ctx.process_response(known_json_error.as_bytes(), content_length) {
430439
Err(ResponseError::WellKnown(WellKnownError {
431440
code: ErrorCode::VersionUnsupported,
432441
..
@@ -440,7 +449,9 @@ mod test {
440449
"message": "This version of payjoin is not supported."
441450
})
442451
.to_string();
443-
match ctx.process_response(invalid_json_error.as_bytes()) {
452+
453+
let content_length = Some(invalid_json_error.len());
454+
match ctx.process_response(invalid_json_error.as_bytes(), content_length) {
444455
Err(ResponseError::Validation(_)) => (),
445456
_ => panic!("Expected unrecognized JSON error"),
446457
}
@@ -449,14 +460,20 @@ mod test {
449460
#[test]
450461
fn process_response_valid() {
451462
let ctx = create_v1_context();
452-
let response = ctx.process_response(PAYJOIN_PROPOSAL.as_bytes());
463+
let body = PAYJOIN_PROPOSAL.as_bytes();
464+
465+
let content_length = Some(body.len());
466+
let response = ctx.process_response(PAYJOIN_PROPOSAL.as_bytes(), content_length);
453467
assert!(response.is_ok())
454468
}
455469

456470
#[test]
457471
fn process_response_invalid_psbt() {
458472
let ctx = create_v1_context();
459-
let response = ctx.process_response(INVALID_PSBT.as_bytes());
473+
let body = INVALID_PSBT.as_bytes();
474+
475+
let content_length = Some(body.len());
476+
let response = ctx.process_response(INVALID_PSBT.as_bytes(), content_length);
460477
match response {
461478
Ok(_) => panic!("Invalid PSBT should have caused an error"),
462479
Err(error) => match error {
@@ -477,7 +494,9 @@ mod test {
477494
let invalid_utf8 = &[0xF0];
478495

479496
let ctx = create_v1_context();
480-
let response = ctx.process_response(invalid_utf8);
497+
498+
let content_length = Some(invalid_utf8.len());
499+
let response = ctx.process_response(invalid_utf8, content_length);
481500
match response {
482501
Ok(_) => panic!("Invalid UTF-8 should have caused an error"),
483502
Err(error) => match error {
@@ -494,18 +513,18 @@ mod test {
494513

495514
#[test]
496515
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));
516+
let data = PAYJOIN_PROPOSAL.as_bytes().to_vec();
517+
let bad_content_length = Some(data.len() + 10);
499518

500519
let ctx = create_v1_context();
501-
let response = ctx.process_response(&data);
520+
let response = ctx.process_response(&data, bad_content_length);
502521
match response {
503522
Ok(_) => panic!("Invalid buffer length should have caused an error"),
504523
Err(error) => match error {
505524
ResponseError::Validation(e) => {
506525
assert_eq!(
507526
e.to_string(),
508-
ValidationError::from(InternalValidationError::ContentTooLarge).to_string()
527+
ValidationError::from(InternalValidationError::ContentLengthMismatch { expected: bad_content_length.unwrap(), actual: data.len() }).to_string()
509528
);
510529
}
511530
_ => panic!("Unexpected error type"),

payjoin/tests/integration.rs

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,9 @@ 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 = ctx.process_response(response_body, content_length)?;
104106
let payjoin_tx = extract_pj_tx(&sender, checked_payjoin_proposal_psbt)?;
105107
sender.send_raw_transaction(&payjoin_tx)?;
106108

@@ -585,7 +587,9 @@ mod integration {
585587
// **********************
586588
// Inside the Sender:
587589
// Sender checks, signs, finalizes, extracts, and broadcasts
588-
let checked_payjoin_proposal_psbt = ctx.process_response(response.as_bytes())?;
590+
let response_body = response.as_bytes();
591+
let content_length = Some(response_body.len());
592+
let checked_payjoin_proposal_psbt = ctx.process_response(response_body, content_length)?;
589593
let payjoin_tx = extract_pj_tx(&sender, checked_payjoin_proposal_psbt)?;
590594
sender.send_raw_transaction(&payjoin_tx)?;
591595

@@ -710,7 +714,7 @@ mod integration {
710714
assert!(response.status().is_success(), "error response: {}", response.status());
711715

712716
let checked_payjoin_proposal_psbt =
713-
send_ctx.process_response(&response.bytes().await?)?;
717+
send_ctx.process_response(&response.bytes().await?, None)?;
714718
let payjoin_tx = extract_pj_tx(&sender, checked_payjoin_proposal_psbt)?;
715719
sender.send_raw_transaction(&payjoin_tx)?;
716720
log::info!("sent");
@@ -1211,7 +1215,9 @@ mod integration {
12111215
// **********************
12121216
// Inside the Sender:
12131217
// Sender checks, signs, finalizes, extracts, and broadcasts
1214-
let checked_payjoin_proposal_psbt = ctx.process_response(response.as_bytes())?;
1218+
let response_body = response.as_bytes();
1219+
let content_length = Some(response_body.len());
1220+
let checked_payjoin_proposal_psbt = ctx.process_response(response_body, content_length)?;
12151221
let payjoin_tx = extract_pj_tx(&sender, checked_payjoin_proposal_psbt)?;
12161222
sender.send_raw_transaction(&payjoin_tx)?;
12171223

@@ -1297,7 +1303,9 @@ mod integration {
12971303
// **********************
12981304
// Inside the Sender:
12991305
// Sender checks, signs, finalizes, extracts, and broadcasts
1300-
let checked_payjoin_proposal_psbt = ctx.process_response(response.as_bytes())?;
1306+
let response_body = response.as_bytes();
1307+
let content_length = Some(response_body.len());
1308+
let checked_payjoin_proposal_psbt = ctx.process_response(response_body, content_length)?;
13011309
let payjoin_tx = extract_pj_tx(&sender, checked_payjoin_proposal_psbt)?;
13021310
sender.send_raw_transaction(&payjoin_tx)?;
13031311

0 commit comments

Comments
 (0)