Skip to content

Commit 3e2a5d3

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 ded33f1 commit 3e2a5d3

File tree

6 files changed

+95
-14
lines changed

6 files changed

+95
-14
lines changed

payjoin-cli/src/app/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ pub(crate) mod v2;
2121
#[cfg(feature = "_danger-local-https")]
2222
pub const LOCAL_CERT_FILE: &str = "localhost.der";
2323

24+
/// 4M block size limit with base64 encoding overhead => maximum reasonable size of content-length
25+
/// 4_000_000 * 4 / 3 fits in u32
26+
#[cfg(feature = "v1")]
27+
pub(crate) const MAX_CONTENT_LENGTH: usize = 4_000_000 * 4 / 3;
28+
2429
#[async_trait::async_trait]
2530
pub trait App: Send + Sync {
2631
fn new(config: Config) -> Result<Self>

payjoin-cli/src/app/v1.rs

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ use tokio::sync::watch;
2424

2525
use super::config::Config;
2626
use super::wallet::BitcoindWallet;
27-
use super::App as AppTrait;
27+
use super::{App as AppTrait, MAX_CONTENT_LENGTH};
2828
use crate::app::{handle_interrupt, http_agent};
2929
use crate::db::Database;
3030
#[cfg(feature = "_danger-local-https")]
@@ -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: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,8 @@ 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+
#[cfg(feature = "_core")]
74+
pub(crate) 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
@@ -7,6 +7,7 @@ use bitcoin::Sequence;
77
use crate::error_codes::{
88
NOT_ENOUGH_MONEY, ORIGINAL_PSBT_REJECTED, UNAVAILABLE, VERSION_UNSUPPORTED,
99
};
10+
use crate::MAX_CONTENT_LENGTH;
1011

1112
/// Error building a Sender from a SenderBuilder.
1213
///
@@ -96,6 +97,7 @@ pub struct ValidationError(InternalValidationError);
9697
pub(crate) enum InternalValidationError {
9798
Parse,
9899
Io(std::io::Error),
100+
ContentTooLarge,
99101
Proposal(InternalProposalError),
100102
#[cfg(feature = "v2")]
101103
V2Encapsulation(crate::send::v2::EncapsulationError),
@@ -120,6 +122,7 @@ impl fmt::Display for ValidationError {
120122
match &self.0 {
121123
Parse => write!(f, "couldn't decode as PSBT or JSON",),
122124
Io(e) => write!(f, "couldn't read PSBT: {}", e),
125+
ContentTooLarge => write!(f, "content is larger than {} bytes", MAX_CONTENT_LENGTH),
123126
Proposal(e) => write!(f, "proposal PSBT error: {}", e),
124127
#[cfg(feature = "v2")]
125128
V2Encapsulation(e) => write!(f, "v2 encapsulation error: {}", e),
@@ -134,6 +137,7 @@ impl std::error::Error for ValidationError {
134137
match &self.0 {
135138
Parse => None,
136139
Io(error) => Some(error),
140+
ContentTooLarge => None,
137141
Proposal(e) => Some(e),
138142
#[cfg(feature = "v2")]
139143
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,14 +293,20 @@ 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::send::error::{ResponseError, WellKnownError};
290298
use crate::send::test::create_psbt_context;
291299
use crate::{Uri, UriExt};
292300

293301
const PJ_URI: &str =
294302
"bitcoin:2N47mmrWXsNBvQR6k78hWJoTji57zXwNcU7?amount=0.02&pjos=0&pj=HTTPS://EXAMPLE.COM/";
295303

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

0 commit comments

Comments
 (0)