-
Notifications
You must be signed in to change notification settings - Fork 106
Enforce response body size limits in payjoin-cli #808
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,7 +21,7 @@ path = "src/main.rs" | |
| default = ["v2"] | ||
| native-certs = ["reqwest/rustls-tls-native-roots"] | ||
| _manual-tls = ["reqwest/rustls-tls", "payjoin/_manual-tls", "tokio-rustls"] | ||
| v1 = ["payjoin/v1", "hyper", "hyper-util", "http-body-util"] | ||
| v1 = ["payjoin/v1", "futures", "hyper", "hyper-util", "http-body-util"] | ||
| v2 = ["payjoin/v2", "payjoin/io"] | ||
|
|
||
| [dependencies] | ||
|
|
@@ -32,6 +32,7 @@ bitcoind-async-client = "0.14.0" | |
| clap = { version = "4.5.45", features = ["derive"] } | ||
| config = "0.15.17" | ||
| dirs = "6.0.0" | ||
| futures = { version = "0.3.21", optional = true } | ||
| http-body-util = { version = "0.1.3", optional = true } | ||
| hyper = { version = "1.8.0", features = ["http1", "server"], optional = true } | ||
| hyper-util = { version = "0.1.18", optional = true } | ||
|
|
@@ -42,6 +43,7 @@ r2d2_sqlite = "0.22.0" | |
| reqwest = { version = "0.12.23", default-features = false, features = [ | ||
| "json", | ||
| "rustls-tls", | ||
| "stream", | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This feature seems to only be used in v1, so rather than defining it here you should be able to define it in |
||
| ] } | ||
| rusqlite = { version = "0.29.0", features = ["bundled"] } | ||
| serde = { version = "1.0.228", features = ["derive"] } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,12 @@ | ||
| use std::collections::HashMap; | ||
|
|
||
| #[cfg(feature = "v1")] | ||
| use anyhow::anyhow; | ||
| use anyhow::Result; | ||
| #[cfg(feature = "v1")] | ||
| use futures::{Stream, StreamExt}; | ||
| #[cfg(feature = "v1")] | ||
| use hyper::body::Bytes; | ||
| use payjoin::bitcoin::psbt::Psbt; | ||
| use payjoin::bitcoin::{self, Address, Amount, FeeRate}; | ||
| use tokio::signal; | ||
|
|
@@ -103,3 +109,22 @@ async fn handle_interrupt(tx: watch::Sender<()>) { | |
| } | ||
| let _ = tx.send(()); | ||
| } | ||
|
|
||
| #[cfg(feature = "v1")] | ||
| pub async fn read_limited_body<S, E>(mut stream: S, expected_len: usize) -> Result<Vec<u8>> | ||
| where | ||
| S: Stream<Item = Result<Bytes, E>> + Unpin, | ||
| E: std::error::Error + Send + Sync + 'static, | ||
| { | ||
| let mut body = Vec::with_capacity(expected_len); | ||
|
|
||
| while let Some(chunk) = stream.next().await { | ||
| let chunk = chunk.map_err(|e| anyhow!("Error reading body chunk: {}", e))?; | ||
| if body.len() + chunk.len() > expected_len { | ||
| return Err(anyhow!("Body exceeds expected size of {expected_len} bytes")); | ||
| } | ||
| body.extend_from_slice(&chunk); | ||
| } | ||
|
|
||
| Ok(body) | ||
| } | ||
|
Comment on lines
+113
to
+130
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Because this is only called in |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -22,9 +22,14 @@ use tokio::sync::watch; | |
| use super::config::Config; | ||
| use super::wallet::BitcoindWallet; | ||
| use super::App as AppTrait; | ||
| use crate::app::{handle_interrupt, http_agent}; | ||
| use crate::app::{handle_interrupt, http_agent, read_limited_body}; | ||
| use crate::db::Database; | ||
|
|
||
| /// 4M block size limit with base64 encoding overhead => maximum reasonable size of content-length | ||
| /// 4_000_000 * 4 / 3 fits in u32 | ||
| const MAX_CONTENT_LENGTH: usize = 4_000_000 * 4 / 3; | ||
|
|
||
| #[derive(Clone)] | ||
| struct Headers<'a>(&'a hyper::HeaderMap); | ||
| impl payjoin::receive::v1::Headers for Headers<'_> { | ||
| fn get_header(&self, key: &str) -> Option<&str> { | ||
|
|
@@ -71,36 +76,33 @@ impl AppTrait for App { | |
| let http = http_agent(&self.config)?; | ||
| let body = String::from_utf8(req.body.clone()).unwrap(); | ||
| println!("Sending Original PSBT to {}", req.url); | ||
| let response = match http | ||
| let response = http | ||
| .post(req.url) | ||
| .header("Content-Type", req.content_type) | ||
| .body(body.clone()) | ||
| .send() | ||
| .await | ||
| { | ||
| Ok(response) => response, | ||
| Err(e) => { | ||
| tracing::error!("HTTP request failed: {e}"); | ||
| println!("Payjoin failed. To broadcast the fallback transaction, run:"); | ||
| println!( | ||
| " bitcoin-cli -rpcwallet=<wallet> sendrawtransaction {:#}", | ||
| serialize_hex(&fallback_tx) | ||
| ); | ||
| return Err(anyhow!("HTTP request failed: {e}")); | ||
| } | ||
| }; | ||
| let psbt = match ctx.process_response(&response.bytes().await?) { | ||
| Ok(psbt) => psbt, | ||
| Err(e) => { | ||
| tracing::error!("Error processing response: {e:?}"); | ||
| println!("Payjoin failed. To broadcast the fallback transaction, run:"); | ||
| println!( | ||
| " bitcoin-cli -rpcwallet=<wallet> sendrawtransaction {:#}", | ||
| serialize_hex(&fallback_tx) | ||
| ); | ||
| return Err(anyhow!("Failed to process response {e}")); | ||
| } | ||
| }; | ||
| .with_context(|| "HTTP request failed")?; | ||
| println!("Sent fallback transaction txid: {}", fallback_tx.compute_txid()); | ||
| println!("Sent fallback transaction hex: {:#}", serialize_hex(&fallback_tx)); | ||
|
|
||
| let expected_length = response | ||
| .headers() | ||
| .get("Content-Length") | ||
| .and_then(|val| val.to_str().ok()) | ||
| .and_then(|s| s.parse::<usize>().ok()) | ||
| .unwrap_or(MAX_CONTENT_LENGTH); | ||
|
|
||
| if expected_length > MAX_CONTENT_LENGTH { | ||
| return Err(anyhow!("Response body is too large: {} bytes", expected_length)); | ||
| } | ||
|
|
||
| let body = read_limited_body(response.bytes_stream(), MAX_CONTENT_LENGTH).await?; | ||
|
|
||
| let psbt = ctx.process_response(&body).map_err(|e| { | ||
| tracing::debug!("Error processing response: {e:?}"); | ||
| anyhow!("Failed to process response {e}") | ||
| })?; | ||
|
|
||
| self.process_pj_response(psbt)?; | ||
|
Comment on lines
-74
to
105
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The println instructions may have changed since your first attempt at this. Please sure the println/error handling behavior doesn't change except for what you've introduced with Content-Length here. I imagine the lines like this can and should stay. |
||
| Ok(()) | ||
|
|
@@ -323,12 +325,27 @@ impl App { | |
| ) -> Result<Response<BoxBody<Bytes, hyper::Error>>, Error> { | ||
| let (parts, body) = req.into_parts(); | ||
| let headers = Headers(&parts.headers); | ||
|
|
||
| let expected_length = headers | ||
| .0 | ||
| .get("Content-Length") | ||
| .and_then(|val| val.to_str().ok()) | ||
| .and_then(|s| s.parse::<usize>().ok()) | ||
| .unwrap_or(MAX_CONTENT_LENGTH); | ||
|
|
||
| if expected_length > MAX_CONTENT_LENGTH { | ||
| tracing::error!("Error: Content length exceeds max allowed"); | ||
| return Err(Error::Implementation(ImplementationError::from( | ||
| anyhow!("Content length too large: {expected_length}").into_boxed_dyn_error(), | ||
| ))); | ||
| } | ||
|
|
||
| let body = | ||
| read_limited_body(body.into_data_stream(), expected_length).await.map_err(|e| { | ||
| Error::Implementation(ImplementationError::from(e.into_boxed_dyn_error())) | ||
| })?; | ||
|
|
||
| let query_string = parts.uri.query().unwrap_or(""); | ||
| let body = body | ||
| .collect() | ||
| .await | ||
| .map_err(|e| Error::Implementation(ImplementationError::new(e)))? | ||
| .to_bytes(); | ||
| let proposal = UncheckedOriginalPayload::from_request(&body, query_string, headers)?; | ||
|
|
||
| let payjoin_proposal = self.process_v1_proposal(proposal)?; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -172,7 +172,7 @@ impl AppTrait for App { | |
| let fallback_tx = psbt.clone().extract_tx()?; | ||
| let (req, ctx) = payjoin::send::v1::SenderBuilder::from_parts( | ||
| psbt, | ||
| pj_param, | ||
| &PjParam::V1(pj_param.clone()), | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This function's type signature does not change. Why then introduce this change? |
||
| &address, | ||
| Some(amount), | ||
| ) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -120,10 +120,7 @@ impl fmt::Display for ValidationError { | |
| match &self.0 { | ||
| Parse => write!(f, "couldn't decode as PSBT or JSON",), | ||
| #[cfg(feature = "v1")] | ||
| ContentTooLarge => { | ||
| use crate::MAX_CONTENT_LENGTH; | ||
| write!(f, "content is larger than {MAX_CONTENT_LENGTH} bytes") | ||
| } | ||
| ContentTooLarge => write!(f, "The response body is too large"), | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe I'm missing context. What's the rationale for this change? |
||
| Proposal(e) => write!(f, "proposal PSBT error: {e}"), | ||
| #[cfg(feature = "v2")] | ||
| V2Decapsulation(e) => write!(f, "v2 encapsulation error: {e}"), | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.