Skip to content

Commit 6f409bb

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

File tree

6 files changed

+109
-28
lines changed

6 files changed

+109
-28
lines changed

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-ffi/src/send/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,7 @@ impl V1Context {
261261
/// Call this method with response from receiver to continue BIP78 flow. If the response is valid you will get appropriate PSBT that you should sign and broadcast.
262262
pub fn process_response(&self, response: &[u8]) -> Result<String, ResponseError> {
263263
<payjoin::send::v1::V1Context as Clone>::clone(&self.0.clone())
264-
.process_response(response)
264+
.process_response(response, None)
265265
.map(|e| e.to_string())
266266
.map_err(Into::into)
267267
}

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)