Skip to content

Commit 8fe7fad

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 5529df3 commit 8fe7fad

File tree

6 files changed

+66
-8
lines changed

6 files changed

+66
-8
lines changed

payjoin-cli/src/app/mod.rs

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

24+
/// 4_000_000 * 4 / 3 fits in u32
25+
pub(crate) const MAX_CONTENT_LENGTH: usize = 4_000_000 * 4 / 3;
26+
2427
#[async_trait::async_trait]
2528
pub trait App: Send + Sync {
2629
fn new(config: Config) -> Result<Self>

payjoin-cli/src/app/v1.rs

Lines changed: 7 additions & 2 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,7 +89,12 @@ 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(
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(
9398
|e| {
9499
log::debug!("Error processing response: {:?}", e);
95100
anyhow!("Failed to process response {}", e)

payjoin/src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,7 @@ pub use uri::{PjParseError, PjUri, Uri, UriExt};
6464
pub use url::{ParseError, Url};
6565
#[cfg(feature = "_core")]
6666
pub(crate) mod error_codes;
67+
68+
/// 4_000_000 * 4 / 3 fits in u32
69+
#[cfg(feature = "_core")]
70+
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: 47 additions & 4 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};
@@ -29,7 +31,7 @@ use url::Url;
2931
use super::*;
3032
use crate::psbt::PsbtExt;
3133
use crate::request::Request;
32-
use crate::PjUri;
34+
use crate::{PjUri, MAX_CONTENT_LENGTH};
3335

3436
#[derive(Clone)]
3537
pub struct SenderBuilder<'a> {
@@ -272,9 +274,15 @@ impl V1Context {
272274
self,
273275
response: &mut impl std::io::Read,
274276
) -> Result<Psbt, ResponseError> {
275-
let mut res_str = String::new();
276-
response.read_to_string(&mut res_str).map_err(InternalValidationError::Io)?;
277-
let proposal = Psbt::from_str(&res_str).map_err(|_| ResponseError::parse(&res_str))?;
277+
let mut buf_reader = BufReader::with_capacity(MAX_CONTENT_LENGTH + 1, response);
278+
let buffer = buf_reader.fill_buf().map_err(InternalValidationError::Io)?;
279+
280+
if buffer.len() > MAX_CONTENT_LENGTH {
281+
return Err(ResponseError::from(InternalValidationError::ContentTooLarge));
282+
}
283+
284+
let res_str = std::str::from_utf8(buffer).map_err(|_| InternalValidationError::Parse)?;
285+
let proposal = Psbt::from_str(res_str).map_err(|_| ResponseError::parse(&res_str))?;
278286
self.psbt_context.process_proposal(proposal).map_err(Into::into)
279287
}
280288
}
@@ -283,6 +291,12 @@ impl V1Context {
283291
mod test {
284292
use crate::send::error::{ResponseError, WellKnownError};
285293

294+
/// From BIP-174 test vector
295+
const INVALID_PSBT: &str = "AgAAAAEmgXE3Ht/yhek3re6ks3t4AAwFZsuzrWRkFxPKQhcb9gAAAABqRzBEAiBwsiRRI+a/R01gxbUMBD1MaRpdJDXwmjSnZiqdwlF5CgIgATKcqdrPKAvfMHQOwDkEIkIsgctFg5RXrrdvwS7dlbMBIQJlfRGNM1e44PTCzUbbezn22cONmnCry5st5dyNv+TOMf7///8C09/1BQAAAAAZdqkU0MWZA8W6woaHYOkP1SGkZlqnZSCIrADh9QUAAAAAF6kUNUXm4zuDLEcFDyTT7rk8nAOUi8eHsy4TAA&#61;&#61;";
296+
297+
/// From the BIP-78 test vector
298+
const PJ_PROPOSAL_PSBT: &str = "cHNidP8BAJwCAAAAAo8nutGgJdyYGXWiBEb45Hoe9lWGbkxh/6bNiOJdCDuDAAAAAAD+////jye60aAl3JgZdaIERvjkeh72VYZuTGH/ps2I4l0IO4MBAAAAAP7///8CJpW4BQAAAAAXqRQd6EnwadJ0FQ46/q6NcutaawlEMIcACT0AAAAAABepFHdAltvPSGdDwi9DR+m0af6+i2d6h9MAAAAAAQEgqBvXBQAAAAAXqRTeTh6QYcpZE1sDWtXm1HmQRUNU0IcAAQEggIQeAAAAAAAXqRTI8sv5ymFHLIjkZNRrNXSEXZHY1YcBBxcWABRfgGZV5ZJMkgTC1RvlOU9L+e2iEAEIawJHMEQCIGe7e0DfJaVPRYEKWxddL2Pr0G37BoKz0lyNa02O2/tWAiB7ZVgBoF4s8MHocYWWmo4Q1cyV2wl7MX0azlqa8NBENAEhAmXWPPW0G3yE3HajBOb7gO7iKzHSmZ0o0w0iONowcV+tAAAA";
299+
286300
fn create_v1_context() -> super::V1Context {
287301
super::V1Context { psbt_context: crate::send::test::create_psbt_context() }
288302
}
@@ -311,4 +325,33 @@ mod test {
311325
_ => panic!("Expected unrecognized JSON error"),
312326
}
313327
}
328+
329+
#[test]
330+
fn process_response_valid() {
331+
let mut cursor = std::io::Cursor::new(PJ_PROPOSAL_PSBT.as_bytes());
332+
333+
let ctx = create_v1_context();
334+
let response = ctx.process_response(&mut cursor);
335+
assert!(response.is_ok())
336+
}
337+
338+
#[test]
339+
fn process_response_invalid_psbt() {
340+
let mut cursor = std::io::Cursor::new(INVALID_PSBT.as_bytes());
341+
342+
let ctx = create_v1_context();
343+
let response = ctx.process_response(&mut cursor).unwrap_err();
344+
assert!(matches!(response, ResponseError::Validation(_)))
345+
}
346+
347+
#[test]
348+
fn process_response_invalid_utf8() {
349+
// In UTF-8, 0xF0 represents the start of a 4-byte sequence, so 0xF0 by itself is invalid
350+
let invalid_utf8 = [0xF0];
351+
let mut cursor = std::io::Cursor::new(invalid_utf8);
352+
353+
let ctx = create_v1_context();
354+
let response = ctx.process_response(&mut cursor).unwrap_err();
355+
assert!(matches!(response, ResponseError::Validation(_)))
356+
}
314357
}

0 commit comments

Comments
 (0)