diff --git a/Cargo.lock b/Cargo.lock index 869b69b..2cd59d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -269,6 +269,16 @@ dependencies = [ "syn 2.0.39", ] +[[package]] +name = "actix-web-grants" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf5941a5bdf4cc022ca7721dae70d9818d7b13f93040b0543cb901410c8d3172" +dependencies = [ + "actix-web", + "protect-endpoints-proc-macro", +] + [[package]] name = "addr2line" version = "0.21.0" @@ -2014,6 +2024,18 @@ dependencies = [ "prost 0.12.3", ] +[[package]] +name = "protect-endpoints-proc-macro" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfff647917bb00f5e9c57a5c15d95db74db5387139ac03052358e38462c86a76" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.39", +] + [[package]] name = "quote" version = "1.0.33" @@ -2226,6 +2248,7 @@ dependencies = [ "actix-multipart", "actix-rt", "actix-web", + "actix-web-grants", "awc", "byte-unit", "config", diff --git a/Cargo.toml b/Cargo.toml index b4b96d6..90b87da 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ shuttle = ["dep:shuttle-actix-web", "dep:shuttle-runtime", "dep:tokio"] [dependencies] actix-web = { version = "4.4.0" } +actix-web-grants = { version = "4.0.3" } actix-multipart = "0.6.1" actix-files = "0.6.2" shuttle-actix-web = { version = "0.35.0", optional = true } diff --git a/fixtures/test-list-files-not-exposed/config.toml b/fixtures/test-list-files-not-exposed/config.toml new file mode 100644 index 0000000..3766b54 --- /dev/null +++ b/fixtures/test-list-files-not-exposed/config.toml @@ -0,0 +1,10 @@ +[server] +address = "127.0.0.1:8000" +max_content_length = "10MB" +upload_path = "./upload" +expose_list = false + +[paste] +random_url = { type = "petname", words = 2, separator = "-" } +default_extension = "txt" +duplicate_files = true diff --git a/fixtures/test-list-files-not-exposed/test.sh b/fixtures/test-list-files-not-exposed/test.sh new file mode 100755 index 0000000..2b51e9d --- /dev/null +++ b/fixtures/test-list-files-not-exposed/test.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash + +setup() { + :; +} + +run_test() { + result=$(curl -s --write-out "%{http_code}" http://localhost:8000/list) + test "404" = "$result" +} + +teardown() { + rm -r upload +} diff --git a/fixtures/test-server-auth-tokens-unset/config.toml b/fixtures/test-server-auth-tokens-unset/config.toml new file mode 100644 index 0000000..13838f8 --- /dev/null +++ b/fixtures/test-server-auth-tokens-unset/config.toml @@ -0,0 +1,8 @@ +[server] +address = "127.0.0.1:8000" +max_content_length = "10MB" +upload_path = "./upload" + +[paste] +default_extension = "txt" +duplicate_files = false diff --git a/fixtures/test-server-auth-tokens-unset/test.sh b/fixtures/test-server-auth-tokens-unset/test.sh new file mode 100755 index 0000000..2c0a745 --- /dev/null +++ b/fixtures/test-server-auth-tokens-unset/test.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +content="topsecret" + +setup() { + echo "$content" > file +} + +run_test() { + result=$(curl -s -F "file=@file" localhost:8000) + test "unauthorized" != "$result" + test "$content" = "$(cat upload/file.txt)" + test "$content" = "$(curl -s $result)" +} + +teardown() { + rm file + rm -r upload +} diff --git a/fixtures/test-server-delete-tokens-unset/config.toml b/fixtures/test-server-delete-tokens-unset/config.toml new file mode 100644 index 0000000..13838f8 --- /dev/null +++ b/fixtures/test-server-delete-tokens-unset/config.toml @@ -0,0 +1,8 @@ +[server] +address = "127.0.0.1:8000" +max_content_length = "10MB" +upload_path = "./upload" + +[paste] +default_extension = "txt" +duplicate_files = false diff --git a/fixtures/test-server-delete-tokens-unset/test.sh b/fixtures/test-server-delete-tokens-unset/test.sh new file mode 100755 index 0000000..77b0307 --- /dev/null +++ b/fixtures/test-server-delete-tokens-unset/test.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +content="test data" + +setup() { + echo "$content" > file +} + +run_test() { + file_url=$(curl -s -F "file=@file" localhost:8000) + test "$file_url" = "http://localhost:8000/file.txt" + + result=$(curl -s --write-out "%{http_code}" -X DELETE http://localhost:8000/file.txt) + test "404" = "$result" +} + +teardown() { + rm file + rm -r upload +} diff --git a/fixtures/test-version-not-exposed/config.toml b/fixtures/test-version-not-exposed/config.toml new file mode 100644 index 0000000..f833e89 --- /dev/null +++ b/fixtures/test-version-not-exposed/config.toml @@ -0,0 +1,10 @@ +[server] +address = "127.0.0.1:8000" +max_content_length = "10MB" +upload_path = "./upload" +expose_version = false + +[paste] +random_url = { type = "petname", words = 2, separator = "-" } +default_extension = "txt" +duplicate_files = true diff --git a/fixtures/test-version-not-exposed/test.sh b/fixtures/test-version-not-exposed/test.sh new file mode 100755 index 0000000..073fbfb --- /dev/null +++ b/fixtures/test-version-not-exposed/test.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +setup() { + echo "$content" > file +} + +run_test() { + result=$(curl -s --write-out "%{http_code}" http://localhost:8000/version) + test "404" = "$result" +} + +teardown() { + rm file + rm -r upload +} diff --git a/src/auth.rs b/src/auth.rs index 7ebf142..12e8d94 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,55 +1,163 @@ -use actix_web::http::header::{HeaderMap, AUTHORIZATION}; -use actix_web::{error, Error}; +use crate::config::{Config, TokenType}; +use actix_web::dev::{ServiceRequest, ServiceResponse}; +use actix_web::http::header::AUTHORIZATION; +use actix_web::http::Method; +use actix_web::middleware::ErrorHandlerResponse; +use actix_web::{error, web, Error}; +use std::collections::HashSet; +use std::sync::RwLock; -/// Checks the authorization header for the specified token. +/// Extracts the tokens from the authorization header by token type. /// /// `Authorization: (type) ` -pub fn check(host: &str, headers: &HeaderMap, tokens: Option>) -> Result<(), Error> { - if let Some(tokens) = tokens { - let auth_header = headers - .get(AUTHORIZATION) - .map(|v| v.to_str().unwrap_or_default()) - .map(|v| v.split_whitespace().last().unwrap_or_default()); - if !tokens.iter().any(|v| v == auth_header.unwrap_or_default()) { - #[cfg(debug_assertions)] - warn!( - "authorization failure for {host} (token: {})", - auth_header.unwrap_or("none"), - ); - #[cfg(not(debug_assertions))] - warn!("authorization failure for {host}"); - return Err(error::ErrorUnauthorized("unauthorized\n")); +pub(crate) async fn extract_tokens(req: &ServiceRequest) -> Result, Error> { + let config = req + .app_data::>>() + .map(|cfg| cfg.read()) + .and_then(Result::ok) + .ok_or_else(|| error::ErrorInternalServerError("cannot acquire config"))?; + + let mut user_tokens = HashSet::with_capacity(2); + + let auth_header = req + .headers() + .get(AUTHORIZATION) + .map(|v| v.to_str().unwrap_or_default()) + .map(|v| v.split_whitespace().last().unwrap_or_default()); + + for token_type in [TokenType::Auth, TokenType::Delete] { + let maybe_tokens = config.get_tokens(token_type); + if let Some(configured_tokens) = maybe_tokens { + if configured_tokens.contains(auth_header.unwrap_or_default()) { + user_tokens.insert(token_type); + } + } else if token_type == TokenType::Auth { + // not configured `auth_tokens` means that the user is allowed to access the endpoints + warn!("auth_tokens not configured, allowing the request without auth header"); + user_tokens.insert(token_type); + } else if token_type == TokenType::Delete && req.method() == Method::DELETE { + // explicitly disable `DELETE` methods if no `delete_tokens` are set + warn!("delete endpoints is not served because there are no delete_tokens set"); + Err(error::ErrorNotFound(""))?; } } - Ok(()) + + Ok(user_tokens) +} + +/// Returns `HttpResponse` with unauthorized (`401`) error and `unauthorized\n` as body. +pub(crate) fn unauthorized_error() -> actix_web::HttpResponse { + error::ErrorUnauthorized("unauthorized\n").into() +} + +/// Log all unauthorized requests. +pub(crate) fn handle_unauthorized_error( + res: ServiceResponse, +) -> actix_web::Result> { + let connection = res.request().connection_info().clone(); + let host = connection.realip_remote_addr().unwrap_or("unknown host"); + + #[cfg(debug_assertions)] + { + let auth_header = res + .request() + .headers() + .get(AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .unwrap_or("none"); + + warn!("authorization failure for {host} (token: {auth_header})",); + } + #[cfg(not(debug_assertions))] + warn!("authorization failure for {host}"); + + Ok(ErrorHandlerResponse::Response(res.map_into_left_body())) } #[cfg(test)] mod tests { use super::*; use actix_web::http::header::HeaderValue; + use actix_web::test::TestRequest; + use actix_web::web::Data; + use actix_web::HttpResponse; + use awc::http::StatusCode; + + #[actix_web::test] + async fn test_extract_tokens() -> Result<(), Error> { + let mut config = Config::default(); + + // request without configured auth-tokens + let request = TestRequest::default() + .app_data(Data::new(RwLock::new(config.clone()))) + .insert_header((AUTHORIZATION, HeaderValue::from_static("basic test_token"))) + .to_srv_request(); + let tokens = extract_tokens(&request).await?; + assert_eq!(HashSet::from([TokenType::Auth]), tokens); + + // request with configured auth-tokens + config.server.auth_tokens = Some(["test_token".to_string()].into()); + let request = TestRequest::default() + .app_data(Data::new(RwLock::new(config.clone()))) + .insert_header((AUTHORIZATION, HeaderValue::from_static("basic test_token"))) + .to_srv_request(); + let tokens = extract_tokens(&request).await?; + assert_eq!(HashSet::from([TokenType::Auth]), tokens); + + // request with configured auth-tokens but wrong token in request + config.server.auth_tokens = Some(["test_token".to_string()].into()); + let request = TestRequest::default() + .app_data(Data::new(RwLock::new(config.clone()))) + .insert_header(( + AUTHORIZATION, + HeaderValue::from_static("basic invalid_token"), + )) + .to_srv_request(); + let tokens = extract_tokens(&request).await?; + assert_eq!(HashSet::new(), tokens); + + // DELETE request without configured delete-tokens + let request = TestRequest::default() + .method(Method::DELETE) + .app_data(Data::new(RwLock::new(config.clone()))) + .insert_header((AUTHORIZATION, HeaderValue::from_static("basic test_token"))) + .to_srv_request(); + let res = extract_tokens(&request).await; + assert!(res.is_err()); + assert_eq!( + Some(StatusCode::NOT_FOUND), + res.err() + .as_ref() + .map(Error::error_response) + .as_ref() + .map(HttpResponse::status) + ); + + // DELETE request with configured delete-tokens + config.server.delete_tokens = Some(["delete_token".to_string()].into()); + let request = TestRequest::default() + .method(Method::DELETE) + .app_data(Data::new(RwLock::new(config.clone()))) + .insert_header(( + AUTHORIZATION, + HeaderValue::from_static("basic delete_token"), + )) + .to_srv_request(); + let tokens = extract_tokens(&request).await?; + assert_eq!(HashSet::from([TokenType::Delete]), tokens); + + // DELETE request with configured delete-tokens but wrong token in request + let request = TestRequest::default() + .method(Method::DELETE) + .app_data(Data::new(RwLock::new(config.clone()))) + .insert_header(( + AUTHORIZATION, + HeaderValue::from_static("basic invalid_token"), + )) + .to_srv_request(); + let tokens = extract_tokens(&request).await?; + assert_eq!(HashSet::new(), tokens); - #[test] - fn test_check_auth() -> Result<(), Error> { - let mut headers = HeaderMap::new(); - headers.insert(AUTHORIZATION, HeaderValue::from_static("basic test_token")); - assert!(check("", &headers, Some(vec!["test_token".to_string()])).is_ok()); - assert!(check("", &headers, Some(vec!["invalid_token".to_string()])).is_err()); - assert!(check( - "", - &headers, - Some(vec!["invalid1".to_string(), "test_token".to_string()]) - ) - .is_ok()); - assert!(check( - "", - &headers, - Some(vec!["invalid1".to_string(), "invalid2".to_string()]) - ) - .is_err()); - assert!(check("", &headers, None).is_ok()); - assert!(check("", &HeaderMap::new(), None).is_ok()); - assert!(check("", &HeaderMap::new(), Some(vec!["token".to_string()])).is_err()); Ok(()) } } diff --git a/src/config.rs b/src/config.rs index 7ddc37c..a7aac7e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -3,6 +3,7 @@ use crate::random::RandomURLConfig; use crate::{AUTH_TOKEN_ENV, DELETE_TOKEN_ENV}; use byte_unit::Byte; use config::{self, ConfigError}; +use std::collections::HashSet; use std::env; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -49,7 +50,7 @@ pub struct ServerConfig { #[deprecated(note = "use [server].auth_tokens instead")] pub auth_token: Option, /// Authentication tokens. - pub auth_tokens: Option>, + pub auth_tokens: Option>, /// Expose version. pub expose_version: Option, /// Landing page text. @@ -63,7 +64,7 @@ pub struct ServerConfig { /// Path of the JSON index. pub expose_list: Option, /// Authentication tokens for deleting. - pub delete_tokens: Option>, + pub delete_tokens: Option>, } /// Enum representing different strategies for handling spaces in filenames. @@ -130,6 +131,7 @@ pub struct CleanupConfig { } /// Type of access token. +#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)] pub enum TokenType { /// Token for authentication. Auth, @@ -148,29 +150,33 @@ impl Config { } /// Retrieves all configured auth/delete tokens. - pub fn get_tokens(&self, token_type: TokenType) -> Option> { + pub fn get_tokens(&self, token_type: TokenType) -> Option> { let mut tokens = match token_type { TokenType::Auth => { - let mut tokens = self.server.auth_tokens.clone().unwrap_or_default(); + let mut tokens: HashSet<_> = self.server.auth_tokens.clone().unwrap_or_default(); + #[allow(deprecated)] if let Some(token) = &self.server.auth_token { - tokens.insert(0, token.to_string()); + tokens.insert(token.to_string()); } if let Ok(env_token) = env::var(AUTH_TOKEN_ENV) { - tokens.insert(0, env_token); + tokens.insert(env_token); } tokens } TokenType::Delete => { - let mut tokens = self.server.delete_tokens.clone().unwrap_or_default(); + let mut tokens: HashSet<_> = self.server.delete_tokens.clone().unwrap_or_default(); + if let Ok(env_token) = env::var(DELETE_TOKEN_ENV) { - tokens.insert(0, env_token); + tokens.insert(env_token); } tokens } }; - tokens.retain(|v| !v.is_empty()); - (!tokens.is_empty()).then_some(tokens) + + // filter out blank tokens + tokens.retain(|v| !v.trim().is_empty()); + Some(tokens).filter(|v| !v.is_empty()) } /// Print deprecation warnings. @@ -241,24 +247,33 @@ mod tests { env::set_var("AUTH_TOKEN", "env_auth"); env::set_var("DELETE_TOKEN", "env_delete"); let mut config = Config::parse(&config_path)?; - config.server.auth_tokens = Some(vec!["may_the_force_be_with_you".to_string()]); - config.server.delete_tokens = Some(vec!["i_am_your_father".to_string()]); + // empty tokens will be filtered + config.server.auth_tokens = + Some(["may_the_force_be_with_you".to_string(), "".to_string()].into()); + config.server.delete_tokens = Some(["i_am_your_father".to_string(), "".to_string()].into()); assert_eq!( - Some(vec![ + Some(HashSet::from([ "env_auth".to_string(), "may_the_force_be_with_you".to_string() - ]), + ])), config.get_tokens(TokenType::Auth) ); assert_eq!( - Some(vec![ + Some(HashSet::from([ "env_delete".to_string(), "i_am_your_father".to_string() - ]), + ])), config.get_tokens(TokenType::Delete) ); env::remove_var("AUTH_TOKEN"); env::remove_var("DELETE_TOKEN"); + + // `get_tokens` returns `None` if no tokens are configured + config.server.auth_tokens = Some([" ".to_string()].into()); + config.server.delete_tokens = Some(HashSet::new()); + assert_eq!(None, config.get_tokens(TokenType::Auth)); + assert_eq!(None, config.get_tokens(TokenType::Delete)); + Ok(()) } } diff --git a/src/server.rs b/src/server.rs index d2f752e..106d3ae 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,4 +1,4 @@ -use crate::auth; +use crate::auth::{extract_tokens, handle_unauthorized_error, unauthorized_error}; use crate::config::{Config, LandingPageConfig, TokenType}; use crate::file::Directory; use crate::header::{self, ContentDisposition}; @@ -7,7 +7,10 @@ use crate::paste::{Paste, PasteType}; use crate::util; use actix_files::NamedFile; use actix_multipart::Multipart; +use actix_web::http::StatusCode; +use actix_web::middleware::ErrorHandlers; use actix_web::{delete, error, get, post, web, Error, HttpRequest, HttpResponse}; +use actix_web_grants::GrantsMiddleware; use awc::Client; use byte_unit::{Byte, UnitType}; use futures_util::stream::StreamExt; @@ -148,8 +151,8 @@ async fn serve( /// Remove a file from the upload directory. #[delete("/{file}")] +#[actix_web_grants::protect("TokenType::Delete", ty = TokenType, error = unauthorized_error)] async fn delete( - request: HttpRequest, file: web::Path, config: web::Data>, ) -> Result { @@ -158,14 +161,6 @@ async fn delete( .map_err(|_| error::ErrorInternalServerError("cannot acquire config"))?; let path = config.server.upload_path.join(&*file); let path = util::glob_match_file(path)?; - let connection = request.connection_info().clone(); - let host = connection.realip_remote_addr().unwrap_or("unknown host"); - let tokens = config.get_tokens(TokenType::Delete); - if tokens.is_none() { - warn!("delete endpoint is not served because there are no delete_tokens set"); - return Err(error::ErrorForbidden("endpoint is not exposed\n")); - } - auth::check(host, request.headers(), tokens)?; if !path.is_file() || !path.exists() { return Err(error::ErrorNotFound("file is not found or expired :(\n")); } @@ -181,27 +176,23 @@ async fn delete( /// Expose version endpoint #[get("/version")] -async fn version( - request: HttpRequest, - config: web::Data>, -) -> Result { +#[actix_web_grants::protect("TokenType::Auth", ty = TokenType, error = unauthorized_error)] +async fn version(config: web::Data>) -> Result { let config = config .read() .map_err(|_| error::ErrorInternalServerError("cannot acquire config"))?; - let connection = request.connection_info().clone(); - let host = connection.realip_remote_addr().unwrap_or("unknown host"); - let tokens = config.get_tokens(TokenType::Auth); - auth::check(host, request.headers(), tokens)?; if !config.server.expose_version.unwrap_or(false) { warn!("server is not configured to expose version endpoint"); - Err(error::ErrorForbidden("endpoint is not exposed\n"))?; + Err(error::ErrorNotFound(""))?; } + let version = env!("CARGO_PKG_VERSION"); Ok(HttpResponse::Ok().body(version.to_owned() + "\n")) } /// Handles file upload by processing `multipart/form-data`. #[post("/")] +#[actix_web_grants::protect("TokenType::Auth", ty = TokenType, error = unauthorized_error)] async fn upload( request: HttpRequest, mut payload: Multipart, @@ -210,13 +201,6 @@ async fn upload( ) -> Result { let connection = request.connection_info().clone(); let host = connection.realip_remote_addr().unwrap_or("unknown host"); - { - let config = config - .read() - .map_err(|_| error::ErrorInternalServerError("cannot acquire config"))?; - let tokens = config.get_tokens(TokenType::Auth); - auth::check(host, request.headers(), tokens)?; - } let server_url = match config .read() .map_err(|_| error::ErrorInternalServerError("cannot acquire config"))? @@ -340,21 +324,15 @@ pub struct ListItem { /// Returns the list of files. #[get("/list")] -async fn list( - request: HttpRequest, - config: web::Data>, -) -> Result { +#[actix_web_grants::protect("TokenType::Auth", ty = TokenType, error = unauthorized_error)] +async fn list(config: web::Data>) -> Result { let config = config .read() .map_err(|_| error::ErrorInternalServerError("cannot acquire config"))? .clone(); - let connection = request.connection_info().clone(); - let host = connection.realip_remote_addr().unwrap_or("unknown host"); - let tokens = config.get_tokens(TokenType::Auth); - auth::check(host, request.headers(), tokens)?; if !config.server.expose_list.unwrap_or(false) { warn!("server is not configured to expose list endpoint"); - Err(error::ErrorForbidden("endpoint is not exposed\n"))?; + Err(error::ErrorNotFound(""))?; } let entries: Vec = fs::read_dir(config.server.upload_path)? .filter_map(|entry| { @@ -400,13 +378,20 @@ async fn list( /// Configures the server routes. pub fn configure_routes(cfg: &mut web::ServiceConfig) { - cfg.service(index) - .service(version) - .service(list) - .service(serve) - .service(upload) - .service(delete) - .route("", web::head().to(HttpResponse::MethodNotAllowed)); + cfg.service( + web::scope("") + .service(index) + .service(version) + .service(list) + .service(serve) + .service(upload) + .service(delete) + .route("", web::head().to(HttpResponse::MethodNotAllowed)) + .wrap(GrantsMiddleware::with_extractor(extract_tokens)) + .wrap( + ErrorHandlers::new().handler(StatusCode::UNAUTHORIZED, handle_unauthorized_error), + ), + ); } #[cfg(test)] @@ -566,7 +551,7 @@ mod tests { #[actix_web::test] async fn test_version_without_auth() -> Result<(), Error> { let mut config = Config::default(); - config.server.auth_tokens = Some(vec!["test".to_string()]); + config.server.auth_tokens = Some(["test".to_string()].into()); let app = test::init_service( App::new() .app_data(Data::new(RwLock::new(config))) @@ -600,8 +585,8 @@ mod tests { .uri("/version") .to_request(); let response = test::call_service(&app, request).await; - assert_eq!(StatusCode::FORBIDDEN, response.status()); - assert_body(response.into_body(), "endpoint is not exposed\n").await?; + assert_eq!(StatusCode::NOT_FOUND, response.status()); + assert_body(response.into_body(), "").await?; Ok(()) } @@ -721,7 +706,7 @@ mod tests { #[actix_web::test] async fn test_auth() -> Result<(), Error> { let mut config = Config::default(); - config.server.auth_tokens = Some(vec!["test".to_string()]); + config.server.auth_tokens = Some(["test".to_string()].into()); let app = test::init_service( App::new() @@ -764,7 +749,7 @@ mod tests { #[actix_web::test] async fn test_delete_file() -> Result<(), Error> { let mut config = Config::default(); - config.server.delete_tokens = Some(vec!["test".to_string()]); + config.server.delete_tokens = Some(["test".to_string()].into()); config.server.upload_path = env::current_dir()?; let app = test::init_service( @@ -818,8 +803,8 @@ mod tests { .to_request(); let response = test::call_service(&app, request).await; - assert_eq!(StatusCode::FORBIDDEN, response.status()); - assert_body(response.into_body(), "endpoint is not exposed\n").await?; + assert_eq!(StatusCode::NOT_FOUND, response.status()); + assert_body(response.into_body(), "").await?; Ok(()) }