refactor(server)!: cleanup authorization boilerplate (#199)

* refactor!: use `actix-web-grants` to protect endpoints

* fix: filter out blank strings

* doc: add documentation for a function

* fix: don't return body for not exposed endpoints

* test: add fixtures

* test: fix naming

* test: remove extra step in teardown
This commit is contained in:
Artem Medvedev 2023-12-12 19:21:29 +01:00 committed by GitHub
parent b74e9ceeaf
commit e21f99ac4a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 341 additions and 105 deletions

23
Cargo.lock generated
View File

@ -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",

View File

@ -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 }

View File

@ -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

View File

@ -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
}

View File

@ -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

View File

@ -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
}

View File

@ -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

View File

@ -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
}

View File

@ -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

View File

@ -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
}

View File

@ -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) <token>`
pub fn check(host: &str, headers: &HeaderMap, tokens: Option<Vec<String>>) -> 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<HashSet<TokenType>, Error> {
let config = req
.app_data::<web::Data<RwLock<Config>>>()
.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<B>(
res: ServiceResponse<B>,
) -> actix_web::Result<ErrorHandlerResponse<B>> {
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(())
}
}

View File

@ -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<String>,
/// Authentication tokens.
pub auth_tokens: Option<Vec<String>>,
pub auth_tokens: Option<HashSet<String>>,
/// Expose version.
pub expose_version: Option<bool>,
/// Landing page text.
@ -63,7 +64,7 @@ pub struct ServerConfig {
/// Path of the JSON index.
pub expose_list: Option<bool>,
/// Authentication tokens for deleting.
pub delete_tokens: Option<Vec<String>>,
pub delete_tokens: Option<HashSet<String>>,
}
/// 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<Vec<String>> {
pub fn get_tokens(&self, token_type: TokenType) -> Option<HashSet<String>> {
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(())
}
}

View File

@ -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<String>,
config: web::Data<RwLock<Config>>,
) -> Result<HttpResponse, Error> {
@ -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<RwLock<Config>>,
) -> Result<HttpResponse, Error> {
#[actix_web_grants::protect("TokenType::Auth", ty = TokenType, error = unauthorized_error)]
async fn version(config: web::Data<RwLock<Config>>) -> Result<HttpResponse, Error> {
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<HttpResponse, Error> {
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<RwLock<Config>>,
) -> Result<HttpResponse, Error> {
#[actix_web_grants::protect("TokenType::Auth", ty = TokenType, error = unauthorized_error)]
async fn list(config: web::Data<RwLock<Config>>) -> Result<HttpResponse, Error> {
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<ListItem> = 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(())
}