From 6588cfdd7f71702fae72f302f68448c7b7e88ab8 Mon Sep 17 00:00:00 2001 From: Rob Ede Date: Thu, 24 Mar 2022 03:36:48 +0000 Subject: [PATCH] extract hashing extractors into actix-hash crate --- Cargo.toml | 3 +- actix-hash/Cargo.toml | 63 +++++++ actix-hash/README.md | 3 + actix-hash/examples/sha2.rs | 31 ++++ actix-hash/src/CHANGELOG.md | 8 + actix-hash/src/body_extractor_fold.rs | 62 +++++++ actix-hash/src/body_hash.rs | 103 ++++++++++++ actix-hash/src/lib.rs | 83 +++++++++ actix-hash/tests/sha2.rs | 84 ++++++++++ actix-web-lab/CHANGELOG.md | 1 + actix-web-lab/Cargo.toml | 3 +- actix-web-lab/examples/hash.rs | 57 ------- actix-web-lab/src/body_hash.rs | 3 + actix-web-lab/src/extract.rs | 3 +- actix-web-lab/src/lib.rs | 1 - actix-web-lab/src/request_hash.rs | 233 -------------------------- 16 files changed, 445 insertions(+), 296 deletions(-) create mode 100644 actix-hash/Cargo.toml create mode 100644 actix-hash/README.md create mode 100644 actix-hash/examples/sha2.rs create mode 100644 actix-hash/src/CHANGELOG.md create mode 100644 actix-hash/src/body_extractor_fold.rs create mode 100644 actix-hash/src/body_hash.rs create mode 100644 actix-hash/src/lib.rs create mode 100644 actix-hash/tests/sha2.rs delete mode 100644 actix-web-lab/examples/hash.rs delete mode 100644 actix-web-lab/src/request_hash.rs diff --git a/Cargo.toml b/Cargo.toml index 49ae4e1d..fc2dc9df 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,4 +1,5 @@ [workspace] members = [ - "./actix-web-lab" + "./actix-hash", + "./actix-web-lab", ] diff --git a/actix-hash/Cargo.toml b/actix-hash/Cargo.toml new file mode 100644 index 00000000..e0f1bfd6 --- /dev/null +++ b/actix-hash/Cargo.toml @@ -0,0 +1,63 @@ +[package] +name = "actix-hash" +version = "0.1.0" +authors = ["Rob Ede "] +description = "Hashing utilities for Actix Web" +keywords = ["actix", "http", "web", "framework", "async"] +categories = [ + "web-programming::http-server", + "cryptography", +] +repository = "https://github.com/robjtede/actix-web-lab.git" +license = "MIT OR Apache-2.0" +edition = "2021" +rust-version = "1.56" + +[features] +default = [ + "blake2", + "md5", + "md4", + "sha1", + "sha2", + "sha3", +] +blake2 = ["alg-blake2"] +md5 = ["alg-md5"] +md4 = ["alg-md4"] +sha1 = ["alg-sha1"] +sha2 = ["alg-sha2"] +sha3 = ["alg-sha3"] + +[dependencies] +actix-http = "3" +actix-web = { version = "4", default-features = false } +futures-core = "0.3.7" +futures-util = { version = "0.3.7", default-features = false, features = ["std"] } +local-channel = "0.1" +tokio = { version = "1.13.1", features = ["sync"] } +tracing = { version = "0.1.30", features = ["log"] } + +digest = "0.10" +subtle = "2" +alg-blake2 = { package = "blake2", version = "0.10", optional = true } +alg-md5 = { package = "md-5", version = "0.10", optional = true } +alg-md4 = { package = "md4", version = "0.10", optional = true } +alg-sha1 = { package = "sha1", version = "0.10", optional = true } +alg-sha2 = { package = "sha2", version = "0.10", optional = true } +alg-sha3 = { package = "sha3", version = "0.10", optional = true } + +[dev-dependencies] +actix-web = "4" +actix-web-lab = "0.15" +base64 = "0.13" +env_logger = "0.9" +hex-literal = "0.3" + +[[test]] +name = "sha2" +required-features = ["sha2"] + +[[example]] +name = "sha2" +required-features = ["sha2"] diff --git a/actix-hash/README.md b/actix-hash/README.md new file mode 100644 index 00000000..5647afe9 --- /dev/null +++ b/actix-hash/README.md @@ -0,0 +1,3 @@ +# actix-hash + +> Hashing utilities for Actix Web. diff --git a/actix-hash/examples/sha2.rs b/actix-hash/examples/sha2.rs new file mode 100644 index 00000000..19883456 --- /dev/null +++ b/actix-hash/examples/sha2.rs @@ -0,0 +1,31 @@ +//! Body + checksum hash extractor usage. +//! +//! For example, sending an empty body will return the hash starting with "E3": +//! ```sh +//! $ curl -XPOST localhost:8080 +//! [E3, B0, C4, 42, 98, FC, 1C, ... +//! ``` + +use std::io; + +use actix_hash::BodySha256; +use actix_web::{middleware::Logger, web, App, HttpServer}; +use tracing::info; + +#[actix_web::main] +async fn main() -> io::Result<()> { + env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); + + info!("staring server at http://localhost:8080"); + + HttpServer::new(|| { + App::new().wrap(Logger::default().log_target("@")).route( + "/", + web::post().to(|body: BodySha256| async move { format!("{:X?}", body.hash()) }), + ) + }) + .workers(1) + .bind(("127.0.0.1", 8080))? + .run() + .await +} diff --git a/actix-hash/src/CHANGELOG.md b/actix-hash/src/CHANGELOG.md new file mode 100644 index 00000000..7934e20a --- /dev/null +++ b/actix-hash/src/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog + +## Unreleased - 2022-xx-xx +- Body hashing extractors for many popular, general purpose hashing algorithms. + + +# 0.1.0 +- Empty crate. diff --git a/actix-hash/src/body_extractor_fold.rs b/actix-hash/src/body_extractor_fold.rs new file mode 100644 index 00000000..e422063a --- /dev/null +++ b/actix-hash/src/body_extractor_fold.rs @@ -0,0 +1,62 @@ +use actix_http::BoxedPayloadStream; +use actix_web::{ + dev, + web::{BufMut as _, Bytes, BytesMut}, + FromRequest, HttpRequest, +}; +use futures_core::future::LocalBoxFuture; +use futures_util::StreamExt as _; +use local_channel::mpsc; +use tokio::try_join; +use tracing::trace; + +pub(crate) fn body_extractor_fold( + req: &HttpRequest, + payload: &mut dev::Payload, + init: Init, + mut update_fn: impl FnMut(&mut Init, &HttpRequest, Bytes) + 'static, + mut finalize_fn: impl FnMut(T, Bytes, Init) -> Out + 'static, +) -> LocalBoxFuture<'static, Result> +where + T: FromRequest, + Init: 'static, +{ + let req = req.clone(); + let payload = payload.take(); + + Box::pin(async move { + let (tx, mut rx) = mpsc::channel(); + + // wrap payload in stream that reads chunks and clones them (cheaply) back here + let proxy_stream: BoxedPayloadStream = Box::pin(payload.inspect(move |res| { + if let Ok(chunk) = res { + trace!("yielding {} byte chunk", chunk.len()); + tx.send(chunk.clone()).unwrap(); + } + })); + + trace!("creating proxy payload"); + let mut proxy_payload = dev::Payload::from(proxy_stream); + let body_fut = T::from_request(&req, &mut proxy_payload); + + let mut body_buf = BytesMut::new(); + + // run update function as chunks are yielded from channel + let hash_fut = async { + let mut accumulator = init; + while let Some(chunk) = rx.recv().await { + trace!("updating hasher with {} byte chunk", chunk.len()); + body_buf.put_slice(&chunk); + update_fn(&mut accumulator, &req, chunk) + } + Ok(accumulator) + }; + + trace!("driving both futures"); + let (body, hash) = try_join!(body_fut, hash_fut)?; + + let out = (finalize_fn)(body, body_buf.freeze(), hash); + + Ok(out) + }) +} diff --git a/actix-hash/src/body_hash.rs b/actix-hash/src/body_hash.rs new file mode 100644 index 00000000..682beafa --- /dev/null +++ b/actix-hash/src/body_hash.rs @@ -0,0 +1,103 @@ +use actix_web::{dev, web::Bytes, FromRequest, HttpRequest}; +use digest::{generic_array::GenericArray, Digest}; +use futures_core::future::LocalBoxFuture; + +use crate::body_extractor_fold::body_extractor_fold; + +/// Parts of the resulting body hash extractor. +pub struct BodyHashParts { + /// Extracted body item. + pub body: T, + + /// Bytes of the body that were extracted. + pub body_bytes: Bytes, + + /// Bytes of the calculated hash. + pub hash_bytes: Vec, +} + +/// Wraps an extractor and calculates a body checksum hash alongside. +/// +/// If your extractor would usually be `T` and you want to create a hash of type `D` then you need +/// to use `BodyHash`. It is assumed that the `T` extractor will consume the payload. +/// +/// Any hasher that implements [`Digest`] can be used. Type aliases for common hashing algorithms +/// are available at the crate root. +/// +/// # Errors +/// This extractor produces no errors of its own and all errors from the underlying extractor are +/// propagated correctly; for example, if the payload limits are exceeded. +/// +/// # Example +/// ``` +/// use actix_web::{Responder, web}; +/// use actix_web_lab::extract::BodyHash; +/// use sha2::Sha256; +/// +/// # type T = u64; +/// async fn hash_payload(form: BodyHash, Sha256>) -> impl Responder { +/// if !form.verify_slice(b"correct-signature") { +/// // return unauthorized error +/// } +/// +/// "Ok" +/// } +/// ``` +#[derive(Debug, Clone)] +pub struct BodyHash { + body: T, + bytes: Bytes, + hash: GenericArray, +} + +impl BodyHash { + /// Returns hash slice. + pub fn hash(&self) -> &[u8] { + self.hash.as_slice() + } + + /// Returns hash output size. + pub fn hash_size(&self) -> usize { + self.hash.len() + } + + /// Verifies HMAC hash against provided `tag` using constant-time equality. + pub fn verify_slice(&self, tag: &[u8]) -> bool { + use subtle::ConstantTimeEq as _; + self.hash.ct_eq(tag).into() + } + + /// Returns body type parts, including extracted body type, raw body bytes, and hash bytes. + pub fn into_parts(self) -> BodyHashParts { + let hash = self.hash().to_vec(); + + BodyHashParts { + body: self.body, + body_bytes: self.bytes, + hash_bytes: hash, + } + } +} + +impl FromRequest for BodyHash +where + T: FromRequest + 'static, + D: Digest + 'static, +{ + type Error = T::Error; + type Future = LocalBoxFuture<'static, Result>; + + fn from_request(req: &HttpRequest, payload: &mut dev::Payload) -> Self::Future { + body_extractor_fold( + req, + payload, + D::new(), + |hasher, _req, chunk| hasher.update(&chunk), + |body, bytes, hasher| Self { + body, + bytes, + hash: hasher.finalize(), + }, + ) + } +} diff --git a/actix-hash/src/lib.rs b/actix-hash/src/lib.rs new file mode 100644 index 00000000..817c5d36 --- /dev/null +++ b/actix-hash/src/lib.rs @@ -0,0 +1,83 @@ +//! Hashing utilities for Actix Web. +//! +//! # Crate Features +//! All features are enabled by default. +//! - `blake2`: Blake2 types +//! - `md5`: MD5 types +//! - `md4`: MD4 types +//! - `sha1`: SHA-1 types +//! - `sha2`: SHA-2 types +//! - `sha3`: SHA-3 types + +#![forbid(unsafe_code)] +#![deny(rust_2018_idioms, nonstandard_style)] +#![warn(future_incompatible, missing_docs)] +#![cfg_attr(docsrs, feature(doc_cfg))] + +#[cfg(feature = "blake2")] +extern crate alg_blake2 as blake2; +#[cfg(feature = "md4")] +extern crate alg_md4 as md4; +#[cfg(feature = "md5")] +extern crate alg_md5 as md5; +#[cfg(feature = "sha1")] +extern crate alg_sha1 as sha1; +#[cfg(feature = "sha2")] +extern crate alg_sha2 as sha2; +#[cfg(feature = "sha3")] +extern crate alg_sha3 as sha3; + +mod body_extractor_fold; +mod body_hash; + +pub use self::body_hash::{BodyHash, BodyHashParts}; + +macro_rules! body_hash_alias { + ($name:ident, $digest:path, $feature:literal, $desc:literal, $out_size:literal) => { + #[doc = concat!("Wraps an extractor and calculates a `", $desc, "` body checksum hash alongside.")] + /// # Example + /// + /// ``` + #[doc = concat!("use actix_hash::", stringify!($name), ";")] + /// + #[doc = concat!("async fn handler(body: ", stringify!($name), ") -> String {")] + #[doc = concat!(" assert_eq!(body.hash().len(), ", $out_size, ");")] + /// body.into_parts().body + /// } + /// # + /// # // test that the documented hash size is correct + #[doc = concat!("# type Hasher = ", stringify!($digest), ";")] + #[doc = concat!("# const OutSize: usize = ", $out_size, ";")] + /// # assert_eq!( + /// # digest::generic_array::GenericArray::::OutputSize + /// # >::default().len(), + /// # OutSize + /// # ); + /// ``` + #[cfg(feature = $feature)] + #[cfg_attr(docsrs, doc(cfg(feature = $feature)))] + pub type $name = BodyHash; + }; +} + +// Obsolete +body_hash_alias!(BodyMd4, md4::Md4, "md4", "MD4", 16); +body_hash_alias!(BodyMd5, md5::Md5, "md5", "MD5", 16); +body_hash_alias!(BodySha1, sha1::Sha1, "sha1", "SHA-1", 20); + +// SHA-2 +body_hash_alias!(BodySha224, sha2::Sha224, "sha2", "SHA-224", 28); +body_hash_alias!(BodySha256, sha2::Sha256, "sha2", "SHA-256", 32); +body_hash_alias!(BodySha384, sha2::Sha384, "sha2", "SHA-384", 48); +body_hash_alias!(BodySha512, sha2::Sha512, "sha2", "SHA-512", 64); + +// SHA-3 +body_hash_alias!(BodySha3_224, sha3::Sha3_224, "sha3", "SHA-3-224", 28); +body_hash_alias!(BodySha3_256, sha3::Sha3_256, "sha3", "SHA-3-256", 32); +body_hash_alias!(BodySha3_384, sha3::Sha3_384, "sha3", "SHA-3-384", 48); +body_hash_alias!(BodySha3_512, sha3::Sha3_512, "sha3", "SHA-3-512", 64); + +// Blake2 +body_hash_alias!(BodyBlake2b, blake2::Blake2b512, "blake2", "Blake2b", 64); +body_hash_alias!(BodyBlake2s, blake2::Blake2s256, "blake2", "Blake2s", 32); diff --git a/actix-hash/tests/sha2.rs b/actix-hash/tests/sha2.rs new file mode 100644 index 00000000..b661e962 --- /dev/null +++ b/actix-hash/tests/sha2.rs @@ -0,0 +1,84 @@ +extern crate alg_sha2 as sha2; + +use actix_hash::BodyHash; +use actix_web::{ + http::StatusCode, + test, + web::{self, Bytes}, + App, +}; +use actix_web_lab::extract::Json; +use hex_literal::hex; +use sha2::{Sha256, Sha512}; + +#[actix_web::test] +async fn correctly_hashes_payload() { + let app = test::init_service( + App::new() + .route( + "/sha512", + web::get().to(|body: BodyHash| async move { + Bytes::copy_from_slice(body.hash()) + }), + ) + .route( + "/", + web::get().to(|body: BodyHash| async move { + Bytes::copy_from_slice(body.hash()) + }), + ), + ) + .await; + + let req = test::TestRequest::default().to_request(); + let body = test::call_and_read_body(&app, req).await; + assert_eq!( + body, + hex!("e3b0c442 98fc1c14 9afbf4c8 996fb924 27ae41e4 649b934c a495991b 7852b855").as_ref() + ); + + let req = test::TestRequest::default().set_payload("abc").to_request(); + let body = test::call_and_read_body(&app, req).await; + assert_eq!( + body, + hex!("ba7816bf 8f01cfea 414140de 5dae2223 b00361a3 96177a9c b410ff61 f20015ad").as_ref() + ); + + let req = test::TestRequest::with_uri("/sha512").to_request(); + let body = test::call_and_read_body(&app, req).await; + assert_eq!( + &body[..], + hex!( + "cf83e135 7eefb8bd f1542850 d66d8007 d620e405 0b5715dc 83f4a921 d36ce9ce + 47d0d13c 5d85f2b0 ff8318d2 877eec2f 63b931bd 47417a81 a538327a f927da3e" + ) + ); +} + +#[actix_web::test] +async fn respects_inner_extractor_errors() { + let app = test::init_service(App::new().route( + "/", + web::get().to(|body: BodyHash, Sha256>| async move { + Bytes::copy_from_slice(body.hash()) + }), + )) + .await; + + let req = test::TestRequest::default().set_json(1234).to_request(); + let body = test::call_and_read_body(&app, req).await; + assert_eq!( + body, + hex!("03ac6742 16f3e15c 761ee1a5 e255f067 953623c8 b388b445 9e13f978 d7c846f4").as_ref() + ); + + // no body would expect a 400 content type error + let req = test::TestRequest::default().to_request(); + let body = test::call_service(&app, req).await; + assert_eq!(body.status(), StatusCode::BAD_REQUEST); + + // body too big would expect a 413 request payload too large + let req = test::TestRequest::default().set_json(12345).to_request(); + let body = test::call_service(&app, req).await; + assert_eq!(body.status(), StatusCode::PAYLOAD_TOO_LARGE); +} diff --git a/actix-web-lab/CHANGELOG.md b/actix-web-lab/CHANGELOG.md index 00f312a1..d69d4401 100644 --- a/actix-web-lab/CHANGELOG.md +++ b/actix-web-lab/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## Unreleased - 2022-xx-xx +- Deprecate `BodyHash`; it has migrated to the [`actix-hash`](https://crates.io/crates/actix-hash) crate. ## 0.15.0 - 2022-03-07 diff --git a/actix-web-lab/Cargo.toml b/actix-web-lab/Cargo.toml index 9aa913ad..8d5e2a67 100644 --- a/actix-web-lab/Cargo.toml +++ b/actix-web-lab/Cargo.toml @@ -8,7 +8,7 @@ categories = [ "network-programming", "asynchronous", "web-programming::http-server", - "web-programming::websocket" + "web-programming::websocket", ] repository = "https://github.com/robjtede/actix-web-lab.git" license = "MIT OR Apache-2.0" @@ -44,7 +44,6 @@ pin-project-lite = "0.2.7" serde = "1" serde_json = "1" serde_urlencoded = "0.7" -sha2 = "0.10" subtle = "2.4" tokio = { version = "1.13.1", features = ["sync", "macros"] } tracing = { version = "0.1.30", features = ["log"] } diff --git a/actix-web-lab/examples/hash.rs b/actix-web-lab/examples/hash.rs deleted file mode 100644 index 6d0dedad..00000000 --- a/actix-web-lab/examples/hash.rs +++ /dev/null @@ -1,57 +0,0 @@ -//! WIP -//! API is not in a good state yet. - -use std::io; - -use actix_web::{ - middleware::Logger, - web::{self, Bytes}, - App, HttpRequest, HttpServer, -}; -use actix_web_lab::extract::{RequestHash, RequestHasher}; -use digest::Digest; -use local_channel::mpsc::Receiver; -use sha2::Sha256; -use tracing::info; - -#[actix_web::main] -async fn main() -> io::Result<()> { - env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); - - info!("staring server at http://localhost:8080"); - - HttpServer::new(|| { - App::new() - .app_data(RequestHasher::from_fn(cf_signature_scheme)) - .wrap(Logger::default().log_target("@")) - .route( - "/", - web::post().to(|body: RequestHash| async move { - base64::encode(body.hash()) - }), - ) - }) - .workers(1) - .bind(("127.0.0.1", 8080))? - .run() - .await -} - -/// Signature scheme of `body + nonce + path`. -async fn cf_signature_scheme( - mut hasher: Sha256, - req: HttpRequest, - mut chunks: Receiver, -) -> Sha256 { - while let Some(chunk) = chunks.recv().await { - hasher.update(&chunk) - } - - // nonce optional - if let Some(hdr) = req.headers().get("Nonce") { - hasher.update(hdr.as_bytes()); - } - - hasher.update(req.path().as_bytes()); - hasher -} diff --git a/actix-web-lab/src/body_hash.rs b/actix-web-lab/src/body_hash.rs index 784587f4..76d29657 100644 --- a/actix-web-lab/src/body_hash.rs +++ b/actix-web-lab/src/body_hash.rs @@ -1,3 +1,5 @@ +#![allow(deprecated)] + use actix_web::{dev, web::Bytes, FromRequest, HttpRequest}; use digest::{generic_array::GenericArray, Digest}; use futures_core::future::LocalBoxFuture; @@ -29,6 +31,7 @@ use crate::body_extractor_fold::body_extractor_fold; /// "Ok" /// } /// ``` +#[deprecated(since = "0.16.0", note = "Migrated to `actix-hash` crate.")] #[derive(Debug, Clone)] pub struct BodyHash { body: T, diff --git a/actix-web-lab/src/extract.rs b/actix-web-lab/src/extract.rs index 57943489..42925ca8 100644 --- a/actix-web-lab/src/extract.rs +++ b/actix-web-lab/src/extract.rs @@ -3,6 +3,7 @@ /// An alias for [`actix_web::web::Data`] with a more descriptive name. pub type SharedData = actix_web::web::Data; +#[allow(deprecated)] pub use crate::body_hash::BodyHash; pub use crate::body_hmac::{BodyHmac, HmacConfig}; pub use crate::json::{Json, DEFAULT_JSON_LIMIT}; @@ -11,8 +12,6 @@ pub use crate::local_data::LocalData; pub use crate::path::Path; pub use crate::query::Query; #[doc(hidden)] -pub use crate::request_hash::{RequestHash, RequestHasher}; -#[doc(hidden)] pub use crate::request_signature::{ RequestSignature, RequestSignatureError, RequestSignatureScheme, }; diff --git a/actix-web-lab/src/lib.rs b/actix-web-lab/src/lib.rs index 25f17fbe..e81eb42d 100644 --- a/actix-web-lab/src/lib.rs +++ b/actix-web-lab/src/lib.rs @@ -47,7 +47,6 @@ mod query; mod redirect; mod redirect_to_https; mod redirect_to_www; -mod request_hash; mod request_signature; mod spa; mod swap_data; diff --git a/actix-web-lab/src/request_hash.rs b/actix-web-lab/src/request_hash.rs deleted file mode 100644 index b9a54f22..00000000 --- a/actix-web-lab/src/request_hash.rs +++ /dev/null @@ -1,233 +0,0 @@ -use std::sync::Arc; - -use actix_http::BoxedPayloadStream; -use actix_web::{dev, web::Bytes, FromRequest, HttpRequest}; -use digest::{generic_array::GenericArray, Digest}; -use futures_core::{future::LocalBoxFuture, Future}; -use futures_util::{FutureExt as _, StreamExt as _}; -use local_channel::mpsc::{self, Receiver}; -use tokio::try_join; -use tracing::trace; - -type HashFn = Arc) -> LocalBoxFuture<'static, D>>; - -/// TODO -pub struct RequestHasher -where - D: Digest + 'static, -{ - hash_fn: HashFn, -} - -impl RequestHasher -where - D: Digest + 'static, -{ - /// TODO - pub fn from_fn(hash_fn: F) -> Self - where - F: Fn(D, HttpRequest, Receiver) -> Fut + 'static, - Fut: Future + 'static, - { - Self { - hash_fn: Arc::new(move |arg1, arg2, arg3| Box::pin((hash_fn)(arg1, arg2, arg3))), - } - } - - /// TODO - pub fn digest_body() -> Self { - Self { - hash_fn: Arc::new(|mut hasher, _req, mut pl_stream| { - Box::pin(async move { - while let Some(chunk) = pl_stream.next().await { - hasher.update(&chunk); - } - - hasher - }) - }), - } - } -} - -/// Wraps an extractor and calculates a request checksum hash alongside. -/// -/// If your extractor would usually be `T` and you want to create a hash of type `D` then you need -/// to use `BodyHash`. It is assumed that the `T` extractor will consume the payload. -/// Any hasher that implements [`Digest`] can be used. -/// -/// # Errors -/// This extractor produces no errors of its own and all errors from the underlying extractor are -/// propagated correctly; for example, if the payload limits are exceeded. -/// -/// # Example -/// ``` -/// use actix_web::{Responder, web}; -/// use actix_web_lab::extract::RequestHash; -/// use sha2::Sha256; -/// -/// # type T = u64; -/// async fn hash_payload(form: RequestHash, Sha256>) -> impl Responder { -/// if !form.verify_slice(b"correct-signature") { -/// // return unauthorized error -/// } -/// -/// "Ok" -/// } -/// ``` -#[derive(Debug, Clone)] -pub struct RequestHash { - body: B, - hash: GenericArray, -} - -impl RequestHash { - /// Returns hash slice. - pub fn hash(&self) -> &[u8] { - self.hash.as_slice() - } - - /// Returns hash output size. - pub fn hash_size(&self) -> usize { - self.hash.len() - } - - /// Verifies HMAC hash against provides `tag` using constant-time equality. - pub fn verify_slice(&self, tag: &[u8]) -> bool { - use subtle::ConstantTimeEq as _; - self.hash.ct_eq(tag).into() - } - - /// Returns tuple containing body type, raw body bytes, and owned hash. - pub fn into_parts(self) -> (T, Vec) { - let hash = self.hash().to_vec(); - (self.body, hash) - } -} - -impl FromRequest for RequestHash -where - T: FromRequest + 'static, - D: Digest + 'static, -{ - type Error = T::Error; - type Future = LocalBoxFuture<'static, Result>; - - fn from_request(req: &HttpRequest, payload: &mut dev::Payload) -> Self::Future { - let req = req.clone(); - let payload = payload.take(); - - Box::pin(async move { - let hasher = D::new(); - let hash_fn = Arc::clone(&req.app_data::>().unwrap().hash_fn); - - let (tx, rx) = mpsc::channel(); - - // wrap payload in stream that reads chunks and clones them (cheaply) back here - let proxy_stream: BoxedPayloadStream = Box::pin(payload.inspect(move |res| { - if let Ok(chunk) = res { - trace!("yielding {} byte chunk", chunk.len()); - tx.send(chunk.clone()).unwrap(); - } - })); - - trace!("creating proxy payload"); - let mut proxy_payload = dev::Payload::from(proxy_stream); - let body_fut = T::from_request(&req, &mut proxy_payload); - - // run update function as chunks are yielded from channel - let hash_fut = ((hash_fn)(hasher, req, rx)).map(Ok); - - trace!("driving both futures"); - let (body, hash) = try_join!(body_fut, hash_fut)?; - - let out = Self { - body, - hash: hash.finalize(), - }; - - Ok(out) - }) - } -} - -#[cfg(test)] -mod tests { - use actix_web::{ - http::StatusCode, - test, - web::{self, Bytes}, - App, - }; - use hex_literal::hex; - use sha2::Sha256; - - use super::*; - use crate::extract::Json; - - #[actix_web::test] - async fn correctly_hashes_payload() { - let app = test::init_service( - App::new() - .app_data(RequestHasher::::digest_body()) - .route( - "/service/path", - web::get().to(|body: RequestHash| async move { - Bytes::copy_from_slice(body.hash()) - }), - ), - ) - .await; - - let req = test::TestRequest::with_uri("/service/path").to_request(); - let body = test::call_and_read_body(&app, req).await; - assert_eq!( - body, - hex!("e3b0c442 98fc1c14 9afbf4c8 996fb924 27ae41e4 649b934c a495991b 7852b855") - .as_ref() - ); - - let req = test::TestRequest::with_uri("/service/path") - .set_payload("abc") - .to_request(); - let body = test::call_and_read_body(&app, req).await; - assert_eq!( - body, - hex!("ba7816bf 8f01cfea 414140de 5dae2223 b00361a3 96177a9c b410ff61 f20015ad") - .as_ref() - ); - } - - #[actix_web::test] - async fn respects_inner_extractor_errors() { - let app = test::init_service( - App::new() - .app_data(RequestHasher::::digest_body()) - .route( - "/", - web::get().to(|body: RequestHash, Sha256>| async move { - Bytes::copy_from_slice(body.hash()) - }), - ), - ) - .await; - - let req = test::TestRequest::default().set_json(1234).to_request(); - let body = test::call_and_read_body(&app, req).await; - assert_eq!( - body, - hex!("03ac6742 16f3e15c 761ee1a5 e255f067 953623c8 b388b445 9e13f978 d7c846f4") - .as_ref() - ); - - // no body would expect a 400 content type error - let req = test::TestRequest::default().to_request(); - let body = test::call_service(&app, req).await; - assert_eq!(body.status(), StatusCode::BAD_REQUEST); - - // body too big would expect a 413 request payload too large - let req = test::TestRequest::default().set_json(12345).to_request(); - let body = test::call_service(&app, req).await; - assert_eq!(body.status(), StatusCode::PAYLOAD_TOO_LARGE); - } -}