Skip to content

Commit eda4782

Browse files
committed
Limit response sizes for v1
BIP 78 doesn't enforce a size on the response, only that the content length must be included. However, if there is not a limit, then some of the send logic could make unbounded allocations.
1 parent d674bd4 commit eda4782

File tree

5 files changed

+89
-14
lines changed

5 files changed

+89
-14
lines changed

payjoin-cli/src/app/v1.rs

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ use payjoin::receive::v1::{PayjoinProposal, UncheckedProposal};
1818
use payjoin::receive::ImplementationError;
1919
use payjoin::receive::ReplyableError::{self, Implementation, V1};
2020
use payjoin::send::v1::SenderBuilder;
21-
use payjoin::{Uri, UriExt};
21+
use payjoin::{Uri, UriExt, MAX_CONTENT_LENGTH};
2222
use tokio::net::TcpListener;
2323
use tokio::sync::watch;
2424

@@ -89,12 +89,15 @@ impl AppTrait for App {
8989
"Sent fallback transaction hex: {:#}",
9090
payjoin::bitcoin::consensus::encode::serialize_hex(&fallback_tx)
9191
);
92-
let psbt = ctx.process_response(&mut response.bytes().await?.to_vec().as_slice()).map_err(
93-
|e| {
94-
log::debug!("Error processing response: {:?}", e);
95-
anyhow!("Failed to process response {}", e)
96-
},
97-
)?;
92+
let response_bytes = response.bytes().await?;
93+
if response_bytes.len() > MAX_CONTENT_LENGTH {
94+
return Err(anyhow!("Response bytes exceeded the limit of {MAX_CONTENT_LENGTH} bytes"));
95+
}
96+
97+
let psbt = ctx.process_response(&mut response_bytes.to_vec().as_slice()).map_err(|e| {
98+
log::debug!("Error processing response: {:?}", e);
99+
anyhow!("Failed to process response {}", e)
100+
})?;
98101

99102
self.process_pj_response(psbt)?;
100103
Ok(())

payjoin/src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,7 @@ pub use uri::{PjParseError, PjUri, Uri, UriExt};
6767
pub use url::{ParseError, Url};
6868
#[cfg(feature = "_core")]
6969
pub(crate) mod error_codes;
70+
71+
/// 4M block size limit with base64 encoding overhead => maximum reasonable size of content-length
72+
/// 4_000_000 * 4 / 3 fits in u32
73+
pub const MAX_CONTENT_LENGTH: usize = 4_000_000 * 4 / 3;

payjoin/src/receive/v1/exclusive/mod.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,8 @@ pub use error::RequestError;
44

55
use super::*;
66
use crate::into_url::IntoUrl;
7+
use crate::MAX_CONTENT_LENGTH;
78

8-
/// 4_000_000 * 4 / 3 fits in u32
9-
const MAX_CONTENT_LENGTH: usize = 4_000_000 * 4 / 3;
109
const SUPPORTED_VERSIONS: &[usize] = &[1];
1110

1211
pub trait Headers {

payjoin/src/send/error.rs

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

88
use crate::error_codes::ErrorCode;
9+
use crate::MAX_CONTENT_LENGTH;
910

1011
/// Error building a Sender from a SenderBuilder.
1112
///
@@ -95,6 +96,7 @@ pub struct ValidationError(InternalValidationError);
9596
pub(crate) enum InternalValidationError {
9697
Parse,
9798
Io(std::io::Error),
99+
ContentTooLarge,
98100
Proposal(InternalProposalError),
99101
#[cfg(feature = "v2")]
100102
V2Encapsulation(crate::send::v2::EncapsulationError),
@@ -119,6 +121,7 @@ impl fmt::Display for ValidationError {
119121
match &self.0 {
120122
Parse => write!(f, "couldn't decode as PSBT or JSON",),
121123
Io(e) => write!(f, "couldn't read PSBT: {}", e),
124+
ContentTooLarge => write!(f, "content is larger than {} bytes", MAX_CONTENT_LENGTH),
122125
Proposal(e) => write!(f, "proposal PSBT error: {}", e),
123126
#[cfg(feature = "v2")]
124127
V2Encapsulation(e) => write!(f, "v2 encapsulation error: {}", e),
@@ -133,6 +136,7 @@ impl std::error::Error for ValidationError {
133136
match &self.0 {
134137
Parse => None,
135138
Io(error) => Some(error),
139+
ContentTooLarge => None,
136140
Proposal(e) => Some(e),
137141
#[cfg(feature = "v2")]
138142
V2Encapsulation(e) => Some(e),

payjoin/src/send/v1.rs

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
//! [`bitmask-core`](https://github.com/diba-io/bitmask-core) BDK integration. Bring your own
2222
//! wallet and http client.
2323
24+
use std::io::{BufRead, BufReader};
25+
2426
use bitcoin::psbt::Psbt;
2527
use bitcoin::{FeeRate, ScriptBuf, Weight};
2628
use error::{BuildSenderError, InternalBuildSenderError};
@@ -30,7 +32,7 @@ use super::*;
3032
pub use crate::output_substitution::OutputSubstitution;
3133
use crate::psbt::PsbtExt;
3234
use crate::request::Request;
33-
pub use crate::PjUri;
35+
use crate::{PjUri, MAX_CONTENT_LENGTH};
3436

3537
#[derive(Clone)]
3638
pub struct SenderBuilder<'a> {
@@ -273,9 +275,15 @@ impl V1Context {
273275
self,
274276
response: &mut impl std::io::Read,
275277
) -> Result<Psbt, ResponseError> {
276-
let mut res_str = String::new();
277-
response.read_to_string(&mut res_str).map_err(InternalValidationError::Io)?;
278-
let proposal = Psbt::from_str(&res_str).map_err(|_| ResponseError::parse(&res_str))?;
278+
let mut buf_reader = BufReader::with_capacity(MAX_CONTENT_LENGTH + 1, response);
279+
let buffer = buf_reader.fill_buf().map_err(InternalValidationError::Io)?;
280+
281+
if buffer.len() > MAX_CONTENT_LENGTH {
282+
return Err(ResponseError::from(InternalValidationError::ContentTooLarge));
283+
}
284+
285+
let res_str = std::str::from_utf8(buffer).map_err(|_| InternalValidationError::Parse)?;
286+
let proposal = Psbt::from_str(res_str).map_err(|_| ResponseError::parse(res_str))?;
279287
self.psbt_context.process_proposal(proposal).map_err(Into::into)
280288
}
281289
}
@@ -285,7 +293,7 @@ mod test {
285293
use bitcoin::FeeRate;
286294
use payjoin_test_utils::{BoxError, PARSED_ORIGINAL_PSBT};
287295

288-
use super::SenderBuilder;
296+
use super::*;
289297
use crate::error_codes::ErrorCode;
290298
use crate::send::error::{ResponseError, WellKnownError};
291299
use crate::send::test::create_psbt_context;
@@ -294,6 +302,12 @@ mod test {
294302
const PJ_URI: &str =
295303
"bitcoin:2N47mmrWXsNBvQR6k78hWJoTji57zXwNcU7?amount=0.02&pjos=0&pj=HTTPS://EXAMPLE.COM/";
296304

305+
/// From the BIP-174 test vector
306+
const INVALID_PSBT: &str = "AgAAAAEmgXE3Ht/yhek3re6ks3t4AAwFZsuzrWRkFxPKQhcb9gAAAABqRzBEAiBwsiRRI+a/R01gxbUMBD1MaRpdJDXwmjSnZiqdwlF5CgIgATKcqdrPKAvfMHQOwDkEIkIsgctFg5RXrrdvwS7dlbMBIQJlfRGNM1e44PTCzUbbezn22cONmnCry5st5dyNv+TOMf7///8C09/1BQAAAAAZdqkU0MWZA8W6woaHYOkP1SGkZlqnZSCIrADh9QUAAAAAF6kUNUXm4zuDLEcFDyTT7rk8nAOUi8eHsy4TAA&#61;&#61;";
307+
308+
/// From the BIP-78 test vector
309+
const PJ_PROPOSAL_PSBT: &str = "cHNidP8BAJwCAAAAAo8nutGgJdyYGXWiBEb45Hoe9lWGbkxh/6bNiOJdCDuDAAAAAAD+////jye60aAl3JgZdaIERvjkeh72VYZuTGH/ps2I4l0IO4MBAAAAAP7///8CJpW4BQAAAAAXqRQd6EnwadJ0FQ46/q6NcutaawlEMIcACT0AAAAAABepFHdAltvPSGdDwi9DR+m0af6+i2d6h9MAAAAAAQEgqBvXBQAAAAAXqRTeTh6QYcpZE1sDWtXm1HmQRUNU0IcAAQEggIQeAAAAAAAXqRTI8sv5ymFHLIjkZNRrNXSEXZHY1YcBBxcWABRfgGZV5ZJMkgTC1RvlOU9L+e2iEAEIawJHMEQCIGe7e0DfJaVPRYEKWxddL2Pr0G37BoKz0lyNa02O2/tWAiB7ZVgBoF4s8MHocYWWmo4Q1cyV2wl7MX0azlqa8NBENAEhAmXWPPW0G3yE3HajBOb7gO7iKzHSmZ0o0w0iONowcV+tAAAA";
310+
297311
fn create_v1_context() -> super::V1Context {
298312
let psbt_context = create_psbt_context().expect("failed to create context");
299313
super::V1Context { psbt_context }
@@ -341,4 +355,55 @@ mod test {
341355
_ => panic!("Expected unrecognized JSON error"),
342356
}
343357
}
358+
359+
#[test]
360+
fn process_response_valid() {
361+
let mut cursor = std::io::Cursor::new(PJ_PROPOSAL_PSBT.as_bytes());
362+
363+
let ctx = create_v1_context();
364+
let response = ctx.process_response(&mut cursor);
365+
assert!(response.is_ok())
366+
}
367+
368+
#[test]
369+
fn process_response_invalid_psbt() {
370+
let mut cursor = std::io::Cursor::new(INVALID_PSBT.as_bytes());
371+
372+
let ctx = create_v1_context();
373+
let response = ctx.process_response(&mut cursor);
374+
match response {
375+
Ok(_) => panic!("Invalid PSBT should have caused an error"),
376+
Err(error) => match error {
377+
ResponseError::Validation(e) => {
378+
assert_eq!(
379+
e.to_string(),
380+
ValidationError::from(InternalValidationError::Parse).to_string()
381+
);
382+
}
383+
_ => panic!("Unexpected error type"),
384+
},
385+
}
386+
}
387+
388+
#[test]
389+
fn process_response_invalid_utf8() {
390+
// In UTF-8, 0xF0 represents the start of a 4-byte sequence, so 0xF0 by itself is invalid
391+
let invalid_utf8 = [0xF0];
392+
let mut cursor = std::io::Cursor::new(invalid_utf8);
393+
394+
let ctx = create_v1_context();
395+
let response = ctx.process_response(&mut cursor);
396+
match response {
397+
Ok(_) => panic!("Invalid UTF-8 should have caused an error"),
398+
Err(error) => match error {
399+
ResponseError::Validation(e) => {
400+
assert_eq!(
401+
e.to_string(),
402+
ValidationError::from(InternalValidationError::Parse).to_string()
403+
);
404+
}
405+
_ => panic!("Unexpected error type"),
406+
},
407+
}
408+
}
344409
}

0 commit comments

Comments
 (0)