diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1e12134ea8cf8e8d87d8cf7f8f6c69e3bb4c9270..b4063cb5edf1072e714cef4a67f67ab1e8b02dd7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -80,18 +80,14 @@ jobs: - uses: actions/checkout@v2 with: fetch-depth: 0 - - name: Install nightly toolchain - run: rustup toolchain install nightly --component llvm-tools-preview - - name: Install cargo-llvm-cov - uses: taiki-e/install-action@cargo-llvm-cov - - name: Generate code coverage - run: cargo llvm-cov --features=all,integration_tests --lcov --output-path lcov.info -- --test-threads 1 + - name: Install stable toolchain + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + override: true + - name: Run tests + run: cargo test --features=all,integration_tests -- --test-threads 1 env: TEST_REDIS_URL: redis://localhost:${{ job.services.redis.ports['6379'] }}/0 TEST_DB_URL: postgresql://rustus:rustus@localhost:${{ job.services.pg.ports['5432'] }}/rustus TEST_AMQP_URL: amqp://guest:guest@localhost:${{ job.services.rabbit.ports['5672'] }} - - name: Coveralls - uses: coverallsapp/github-action@master - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - path-to-lcov: lcov.info diff --git a/Cargo.lock b/Cargo.lock index d345c908eaf3da88af2a184ec1c1434835f3d4f7..6e31f9faf413c8aae4f31879a95b3ef4eee13209 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1456,6 +1456,15 @@ dependencies = [ "opaque-debug 0.3.0", ] +[[package]] +name = "md-5" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658646b21e0b72f7866c7038ab086d3d5e1cd6271f060fd37defb241949d0582" +dependencies = [ + "digest 0.10.3", +] + [[package]] name = "memchr" version = "2.4.1" @@ -2192,7 +2201,7 @@ dependencies = [ "itoa 0.4.8", "percent-encoding", "pin-project-lite", - "sha1", + "sha1 0.6.1", "tokio", "tokio-util 0.6.9", "url", @@ -2385,12 +2394,14 @@ dependencies = [ "bytes", "chrono", "derive_more", + "digest 0.10.3", "fern", "futures", "httptest", "lapin", "lazy_static", "log", + "md-5 0.10.1", "mobc-lapin", "mobc-redis", "openssl", @@ -2399,6 +2410,8 @@ dependencies = [ "reqwest", "serde", "serde_json", + "sha1 0.10.1", + "sha2 0.10.2", "strfmt", "structopt", "strum", @@ -2590,6 +2603,17 @@ dependencies = [ "sha1_smol", ] +[[package]] +name = "sha1" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c77f4e7f65455545c2153c1253d25056825e77ee2533f0e41deb65a93a34852f" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.3", +] + [[package]] name = "sha1_smol" version = "1.0.0" @@ -2609,6 +2633,17 @@ dependencies = [ "opaque-debug 0.3.0", ] +[[package]] +name = "sha2" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55deaec60f81eefe3cce0dc50bda92d6d8e88f2a27df7c5033b42afeb1ed2676" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.3", +] + [[package]] name = "signal-hook-registry" version = "1.4.0" @@ -2711,7 +2746,7 @@ dependencies = [ "libc", "libsqlite3-sys", "log", - "md-5", + "md-5 0.9.1", "memchr", "num-bigint", "once_cell", @@ -2725,7 +2760,7 @@ dependencies = [ "serde", "serde_json", "sha-1 0.9.8", - "sha2", + "sha2 0.9.9", "smallvec", "sqlformat", "sqlx-rt", @@ -2799,7 +2834,7 @@ dependencies = [ "serde", "serde_derive", "serde_json", - "sha1", + "sha1 0.6.1", "syn", ] diff --git a/Cargo.toml b/Cargo.toml index cc0d737506ef9fa5702888304da29c1ba66c97f9..33db9d71e422cb2353b8c42669452cc7e03d4d3c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,24 @@ thiserror = "^1.0" url = "^2.2.2" bytes = "^1.1.0" +[dependencies.digest] +version = "0.10.3" +optional = true + +[dependencies.sha1] +version = "^0.10.1" +features = ["compress"] +optional = true + +[dependencies.sha2] +version = "^0.10.1" +features = ["compress"] +optional = true + +[dependencies.md-5] +version = "^0.10.1" +optional = true + [dependencies.futures] version = "^0.3.21" @@ -108,12 +126,13 @@ features = ["v4"] version = "^1.0.0-alpha.1" [features] -all = ["redis_info_storage", "db_info_storage", "http_notifier", "amqp_notifier"] +all = ["redis_info_storage", "db_info_storage", "http_notifier", "amqp_notifier", "hashers"] amqp_notifier = ["lapin", "tokio-amqp", "mobc-lapin"] db_info_storage = ["rbatis", "rbson"] default = [] http_notifier = ["reqwest"] redis_info_storage = ["mobc-redis"] +hashers = ["md-5", "sha1", "sha2", "digest"] ### For testing test_redis = [] diff --git a/src/config.rs b/src/config.rs index 50b8b79379c6a4183f91c1c2f82f816dc52e342b..ae38eaa1b8800262d71fcf95d29069057812e2bd 100644 --- a/src/config.rs +++ b/src/config.rs @@ -189,7 +189,7 @@ pub struct RustusConf { /// Enabled extensions for TUS protocol. #[structopt( long, - default_value = "getting,creation,termination,creation-with-upload,creation-defer-length,concatenation", + default_value = "getting,creation,termination,creation-with-upload,creation-defer-length,concatenation,checksum", env = "RUSTUS_TUS_EXTENSIONS", use_delimiter = true )] diff --git a/src/errors.rs b/src/errors.rs index c2a4f49ae14e4e504cfbb4b7abb869c81572257d..13ad2ae57a614f7cbae16168fb21eef0185fea64 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -59,6 +59,12 @@ pub enum RustusError { StdError(#[from] std::io::Error), #[error("Can't spawn task: {0}")] TokioSpawnError(#[from] tokio::task::JoinError), + #[error("Unknown hashsum algorithm")] + UnknownHashAlgorithm, + #[error("Wrong checksum")] + WrongChecksum, + #[error("The header value is incorrect")] + WrongHeaderValue, } /// This conversion allows us to use `RustusError` in the `main` function. @@ -83,9 +89,12 @@ impl ResponseError for RustusError { match self { RustusError::FileNotFound => StatusCode::NOT_FOUND, RustusError::WrongOffset => StatusCode::CONFLICT, - RustusError::FrozenFile | RustusError::SizeAlreadyKnown | RustusError::HookError(_) => { - StatusCode::BAD_REQUEST - } + RustusError::FrozenFile + | RustusError::SizeAlreadyKnown + | RustusError::HookError(_) + | RustusError::UnknownHashAlgorithm + | RustusError::WrongHeaderValue => StatusCode::BAD_REQUEST, + RustusError::WrongChecksum => StatusCode::EXPECTATION_FAILED, _ => StatusCode::INTERNAL_SERVER_ERROR, } } diff --git a/src/protocol/core/server_info.rs b/src/protocol/core/server_info.rs index 3648e84ea46aadf08c003be733c86aaf1b627635..41d1e2f9a5cf424993158a4178b213bbaed3801a 100644 --- a/src/protocol/core/server_info.rs +++ b/src/protocol/core/server_info.rs @@ -1,4 +1,6 @@ -use actix_web::{web, HttpResponse}; +#[cfg(feature = "hashers")] +use crate::protocol::extensions::Extensions; +use actix_web::{http::StatusCode, web, HttpResponse, HttpResponseBuilder}; use crate::State; @@ -12,9 +14,13 @@ pub async fn server_info(state: web::Data<State>) -> HttpResponse { .map(|x| x.to_string()) .collect::<Vec<String>>() .join(","); - HttpResponse::Ok() - .insert_header(("Tus-Extension", ext_str.as_str())) - .finish() + let mut response_builder = HttpResponseBuilder::new(StatusCode::OK); + response_builder.insert_header(("Tus-Extension", ext_str.as_str())); + #[cfg(feature = "hashers")] + if state.config.tus_extensions.contains(&Extensions::Checksum) { + response_builder.insert_header(("Tus-Checksum-Algorithm", "md5,sha1,sha256,sha512")); + } + response_builder.finish() } #[cfg(test)] diff --git a/src/protocol/core/write_bytes.rs b/src/protocol/core/write_bytes.rs index 49418abfcca6a3b82f1bdb1faddba87b9b7f16a1..fafc64e59c9da415eb623f50cb8fc5178046dcf2 100644 --- a/src/protocol/core/write_bytes.rs +++ b/src/protocol/core/write_bytes.rs @@ -1,5 +1,7 @@ use actix_web::{web, web::Bytes, HttpRequest, HttpResponse}; +#[cfg(feature = "hashers")] +use crate::utils::hashes::verify_chunk_checksum; use crate::{ errors::RustusError, notifiers::Hook, @@ -29,6 +31,20 @@ pub async fn write_bytes( return Err(RustusError::FileNotFound); } + #[cfg(feature = "hashers")] + if state.config.tus_extensions.contains(&Extensions::Checksum) { + if let Some(header) = request.headers().get("Upload-Checksum").cloned() { + let cloned_bytes = bytes.clone(); + if !tokio::task::spawn_blocking(move || { + verify_chunk_checksum(&header, cloned_bytes.as_ref()) + }) + .await?? + { + return Err(RustusError::WrongChecksum); + } + } + } + // New upload length. // Parses header `Upload-Length` only if the creation-defer-length extension is enabled. let updated_len = if state @@ -151,6 +167,7 @@ mod tests { let request = TestRequest::patch() .uri(state.config.file_url(file.id.as_str()).as_str()) .insert_header(("Content-Type", "application/offset+octet-stream")) + .insert_header(("Upload-Checksum", "md5 xIwpFX4rNYzBRAJ/Pi2MtA==")) .insert_header(("Upload-Offset", file.offset)) .set_payload(test_data) .to_request(); @@ -418,4 +435,27 @@ mod tests { let resp = call_service(&mut rustus, request).await; assert_eq!(resp.status(), StatusCode::NOT_FOUND); } + + #[actix_rt::test] + /// Tests checksum validation. + async fn wrong_checksum() { + let state = State::test_new().await; + let mut rustus = init_service( + App::new().configure(rustus_service(web::Data::new(state.test_clone().await))), + ) + .await; + let mut file = state.create_test_file().await; + file.offset = 0; + file.length = Some(10); + state.info_storage.set_info(&file, false).await.unwrap(); + let request = TestRequest::patch() + .uri(state.config.file_url(file.id.as_str()).as_str()) + .insert_header(("Upload-Offset", "0")) + .insert_header(("Upload-Checksum", "md5 K9opmNmw7hl9oUKgRH9nJQ==")) + .insert_header(("Content-Type", "application/offset+octet-stream")) + .set_payload("memes") + .to_request(); + let resp = call_service(&mut rustus, request).await; + assert_eq!(resp.status(), StatusCode::EXPECTATION_FAILED); + } } diff --git a/src/protocol/extensions.rs b/src/protocol/extensions.rs index 4eba16223bc6be6c6971362922d5e93cf10ac54d..9847523f764c5ce621e356ac01df22a327ee74d1 100644 --- a/src/protocol/extensions.rs +++ b/src/protocol/extensions.rs @@ -18,6 +18,8 @@ pub enum Extensions { Concatenation, #[display(fmt = "getting")] Getting, + #[display(fmt = "checksum")] + Checksum, } from_str!(Extensions, "extension"); diff --git a/src/utils/hashes.rs b/src/utils/hashes.rs new file mode 100644 index 0000000000000000000000000000000000000000..28536ee977d49f770a58ac35dc88505537f90f00 --- /dev/null +++ b/src/utils/hashes.rs @@ -0,0 +1,128 @@ +use crate::{errors::RustusError, RustusResult}; +use actix_web::http::header::HeaderValue; +use digest::Digest; + +/// Checks if hash-sum of a slice matches the given checksum. +fn checksum_verify(algo: &str, bytes: &[u8], checksum: &[u8]) -> RustusResult<bool> { + match algo { + "sha1" => { + let sum = sha1::Sha1::digest(bytes); + Ok(sum.as_slice() == checksum) + } + "sha256" => { + let sum = sha2::Sha256::digest(bytes); + Ok(sum.as_slice() == checksum) + } + "sha512" => { + let sum = sha2::Sha512::digest(bytes); + Ok(sum.as_slice() == checksum) + } + "md5" => { + let sum = md5::Md5::digest(bytes); + Ok(sum.as_slice() == checksum) + } + _ => Err(RustusError::UnknownHashAlgorithm), + } +} + +/// Verify checksum of a given chunk based on header's value. +/// +/// This function decodes given header value. +/// Format of the header is: +/// <algorithm name> <base64 encoded checksum value> +/// +/// It tries decode header value to string, +/// splits it in two parts and after decoding base64 checksum +/// verifies it. +/// +/// # Errors +/// +/// It may return error if header value can't be represented as string, +/// if checksum can't be decoded with base64 or if unknown algorithm is used. +pub fn verify_chunk_checksum(header: &HeaderValue, data: &[u8]) -> RustusResult<bool> { + if let Ok(val) = header.to_str() { + let mut split = val.split(' '); + if let Some(algo) = split.next() { + if let Some(checksum_base) = split.next() { + let checksum = base64::decode(checksum_base).map_err(|_| { + log::error!("Can't decode checksum value"); + RustusError::WrongHeaderValue + })?; + return checksum_verify(algo, data, checksum.as_slice()); + } + } + Err(RustusError::WrongHeaderValue) + } else { + log::error!("Can't decode checksum header."); + Err(RustusError::WrongHeaderValue) + } +} + +#[cfg(test)] +mod tests { + use super::{checksum_verify, verify_chunk_checksum}; + use actix_web::http::header::HeaderValue; + + #[test] + fn test_success_checksum_verify() { + let res = checksum_verify( + "sha1", + b"hello", + b"\xaa\xf4\xc6\x1d\xdc\xc5\xe8\xa2\xda\xbe\xde\x0f;H,\xd9\xae\xa9CM", + ) + .unwrap(); + assert!(res); + let res = checksum_verify( + "sha256", + b"hello", + b",\xf2M\xba_\xb0\xa3\x0e&\xe8;*\xc5\xb9\xe2\x9e\x1b\x16\x1e\\\x1f\xa7B^s\x043b\x93\x8b\x98$", + ).unwrap(); + assert!(res); + let res = checksum_verify( + "sha512", + b"hello", + b"\x9bq\xd2$\xbdb\xf3x]\x96\xd4j\xd3\xea=s1\x9b\xfb\xc2\x89\x0c\xaa\xda\xe2\xdf\xf7%\x19g<\xa7##\xc3\xd9\x9b\xa5\xc1\x1d|z\xccn\x14\xb8\xc5\xda\x0cFcG\\.\\:\xde\xf4os\xbc\xde\xc0C", + ).unwrap(); + assert!(res); + let res = + checksum_verify("md5", b"hello", b"]A@*\xbcK*v\xb9q\x9d\x91\x10\x17\xc5\x92").unwrap(); + assert!(res); + } + + #[test] + fn test_sum_unknown_algo_checksum_verify() { + let res = checksum_verify("base64", "test".as_bytes(), b"dGVzdAo="); + assert!(res.is_err()); + } + + #[test] + fn test_success_verify_chunk_checksum() { + let res = verify_chunk_checksum( + &HeaderValue::from_str("md5 XUFAKrxLKna5cZ2REBfFkg==").unwrap(), + b"hello", + ) + .unwrap(); + assert!(res); + } + + #[test] + fn test_wrong_checksum() { + let res = verify_chunk_checksum(&HeaderValue::from_str("md5 memes==").unwrap(), b"hello"); + assert!(res.is_err()); + } + + #[test] + fn test_bytes_header() { + let res = verify_chunk_checksum( + &HeaderValue::from_bytes(b"ewq ]A@*\xbcK*v").unwrap(), + b"hello", + ); + assert!(res.is_err()); + } + + #[test] + fn test_badly_formatted_header() { + let res = verify_chunk_checksum(&HeaderValue::from_str("md5").unwrap(), b"hello"); + assert!(res.is_err()); + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 0bef5e255fa47a6f53bdba0c498862fee1325665..458aa2cff946b4751d57739cd29f03b6d0d5971d 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,3 +1,5 @@ pub mod dir_struct; pub mod enums; +#[cfg(feature = "hashers")] +pub mod hashes; pub mod headers;