// Copyright 2024 RustFS Team // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //! FormPost Support for Swift API //! //! This module implements HTML form-based file uploads to Swift containers //! without requiring authentication. FormPost uses HMAC-SHA1 signatures to //! validate that forms were generated by an authorized user. //! //! # Overview //! //! FormPost allows users to upload files from HTML forms directly to Swift //! without exposing authentication credentials to the browser. The container //! owner generates a signed form with embedded signature, and browsers can //! POST files to that form. //! //! # Configuration //! //! FormPost uses the same TempURL key mechanism: //! //! ```bash //! # Set TempURL key for account //! swift post -m "Temp-URL-Key:mykey" //! ``` //! //! # Form Fields //! //! Required fields: //! - `redirect` - URL to redirect to on success //! - `max_file_size` - Maximum size per file (bytes) //! - `max_file_count` - Maximum number of files //! - `expires` - Unix timestamp when form expires //! - `signature` - HMAC-SHA1 signature of form parameters //! //! Optional fields: //! - `redirect_error` - URL to redirect to on error (default: redirect) //! //! File fields: //! - `file` or `file1`, `file2`, etc. - Files to upload //! //! # Signature Generation //! //! ```text //! HMAC-SHA1(key, "{path}\n{redirect}\n{max_file_size}\n{max_file_count}\n{expires}") //! ``` //! //! # Example HTML Form //! //! ```html //!
//! //! //! //! //! //! //! //! //!
//! ``` //! //! # Response //! //! On success: 303 See Other redirect to `redirect` URL with query params: //! - `?status=201&message=Created` //! //! On error: 303 See Other redirect to `redirect_error` URL (or `redirect`) with: //! - `?status=400&message=Error+description` use super::{SwiftError, SwiftResult}; use hmac::{Hmac, KeyInit, Mac}; use sha1::Sha1; use std::time::{SystemTime, UNIX_EPOCH}; use tracing::debug; const LOG_COMPONENT_PROTOCOLS: &str = "protocols"; const LOG_SUBSYSTEM_SWIFT_FORMPOST: &str = "swift_formpost"; const EVENT_SWIFT_FORMPOST_STATE: &str = "swift_formpost_state"; type HmacSha1 = Hmac; /// FormPost request parameters #[derive(Debug, Clone)] pub struct FormPostRequest { /// URL to redirect to on success pub redirect: String, /// URL to redirect to on error (defaults to redirect) pub redirect_error: Option, /// Maximum size per file in bytes pub max_file_size: u64, /// Maximum number of files pub max_file_count: u64, /// Unix timestamp when form expires pub expires: u64, /// HMAC-SHA1 signature pub signature: String, } impl FormPostRequest { /// Parse FormPost parameters from form fields pub fn from_form_fields(fields: &std::collections::HashMap) -> SwiftResult { // Extract required fields let redirect = fields .get("redirect") .ok_or_else(|| SwiftError::BadRequest("Missing 'redirect' field".to_string()))? .clone(); let max_file_size = fields .get("max_file_size") .ok_or_else(|| SwiftError::BadRequest("Missing 'max_file_size' field".to_string()))? .parse::() .map_err(|_| SwiftError::BadRequest("Invalid 'max_file_size' value".to_string()))?; let max_file_count = fields .get("max_file_count") .ok_or_else(|| SwiftError::BadRequest("Missing 'max_file_count' field".to_string()))? .parse::() .map_err(|_| SwiftError::BadRequest("Invalid 'max_file_count' value".to_string()))?; let expires = fields .get("expires") .ok_or_else(|| SwiftError::BadRequest("Missing 'expires' field".to_string()))? .parse::() .map_err(|_| SwiftError::BadRequest("Invalid 'expires' value".to_string()))?; let signature = fields .get("signature") .ok_or_else(|| SwiftError::BadRequest("Missing 'signature' field".to_string()))? .clone(); // Optional redirect_error let redirect_error = fields.get("redirect_error").cloned(); Ok(FormPostRequest { redirect, redirect_error, max_file_size, max_file_count, expires, signature, }) } /// Get redirect URL for errors (falls back to redirect if redirect_error not set) pub fn error_redirect_url(&self) -> &str { self.redirect_error.as_deref().unwrap_or(&self.redirect) } } /// Generate FormPost signature /// /// Signature format: HMAC-SHA1(key, "{path}\n{redirect}\n{max_file_size}\n{max_file_count}\n{expires}") pub fn generate_signature( path: &str, redirect: &str, max_file_size: u64, max_file_count: u64, expires: u64, key: &str, ) -> SwiftResult { let message = format!("{}\n{}\n{}\n{}\n{}", path, redirect, max_file_size, max_file_count, expires); let mut mac = HmacSha1::new_from_slice(key.as_bytes()).map_err(|e| SwiftError::InternalServerError(format!("HMAC error: {}", e)))?; mac.update(message.as_bytes()); let result = mac.finalize(); let signature = hex::encode(result.into_bytes()); Ok(signature) } /// Validate FormPost signature and expiration pub fn validate_formpost(path: &str, request: &FormPostRequest, key: &str) -> SwiftResult<()> { // Check expiration let now = SystemTime::now() .duration_since(UNIX_EPOCH) .map_err(|e| SwiftError::InternalServerError(format!("Time error: {}", e)))? .as_secs(); if now > request.expires { return Err(SwiftError::Unauthorized("FormPost expired".to_string())); } // Validate signature let expected_sig = generate_signature( path, &request.redirect, request.max_file_size, request.max_file_count, request.expires, key, )?; // Compare signatures in constant time to avoid a timing side-channel, matching // the sibling TempURL/SFTP checks. Decode the hex first so the comparison runs // over the raw HMAC bytes and does not leak via string length; a non-hex // provided signature can never match and is rejected the same way. let expected_bytes = hex::decode(&expected_sig).map_err(|e| SwiftError::InternalServerError(format!("Signature encoding error: {}", e)))?; let signatures_match = match hex::decode(request.signature.trim()) { Ok(provided_bytes) => super::tempurl::constant_time_compare(&provided_bytes, &expected_bytes), Err(_) => false, }; if !signatures_match { debug!( event = EVENT_SWIFT_FORMPOST_STATE, component = LOG_COMPONENT_PROTOCOLS, subsystem = LOG_SUBSYSTEM_SWIFT_FORMPOST, result = "signature_mismatch", "swift formpost state changed" ); return Err(SwiftError::Unauthorized("Invalid FormPost signature".to_string())); } Ok(()) } /// File uploaded via FormPost #[derive(Debug)] pub struct UploadedFile { /// Field name (e.g., "file", "file1", "file2") pub field_name: String, /// Original filename pub filename: String, /// File contents pub contents: Vec, /// Content type pub content_type: Option, } /// Build redirect URL with status and message pub fn build_redirect_url(base_url: &str, status: u16, message: &str) -> String { let encoded_message = urlencoding::encode(message); format!("{}?status={}&message={}", base_url, status, encoded_message) } /// Parse multipart/form-data boundary from Content-Type header pub fn parse_boundary(content_type: &str) -> Option { // Content-Type: multipart/form-data; boundary=----WebKitFormBoundary... if !content_type.starts_with("multipart/form-data") { return None; } for part in content_type.split(';') { let part = part.trim(); if let Some(boundary) = part.strip_prefix("boundary=") { return Some(boundary.to_string()); } } None } /// Simple multipart form data parser /// /// This is a basic implementation that extracts form fields and file uploads. /// For production use, consider using a dedicated multipart library. pub fn parse_multipart_form( body: &[u8], boundary: &str, ) -> SwiftResult<(std::collections::HashMap, Vec)> { let mut fields = std::collections::HashMap::new(); let mut files = Vec::new(); let boundary_marker = format!("--{}", boundary); let body_str = String::from_utf8_lossy(body); // Split by boundary let parts: Vec<&str> = body_str.split(&boundary_marker).collect(); for part in parts.iter().skip(1) { // Skip empty parts and final boundary if part.trim().is_empty() || part.starts_with("--") { continue; } // Split headers from content let lines = part.lines(); let mut headers = Vec::new(); let mut content_start = 0; for (i, line) in lines.clone().enumerate() { if line.trim().is_empty() { content_start = i + 1; break; } headers.push(line); } // Parse Content-Disposition header let content_disposition = headers .iter() .find(|h| h.to_lowercase().starts_with("content-disposition:")) .map(|h| h.to_string()); if let Some(disposition) = content_disposition { let field_name = extract_field_name(&disposition); let filename = extract_filename(&disposition); // Get content (everything after headers) let content: Vec<&str> = part.lines().skip(content_start).collect(); let content_str = content.join("\n"); let content_bytes = content_str.trim_end().as_bytes().to_vec(); if let Some(fname) = filename { // This is a file upload let content_type = headers .iter() .find(|h| h.to_lowercase().starts_with("content-type:")) .and_then(|h| h.split(':').nth(1)) .map(|s| s.trim().to_string()); files.push(UploadedFile { field_name: field_name.clone(), filename: fname, contents: content_bytes, content_type, }); } else { // This is a regular form field fields.insert(field_name, String::from_utf8_lossy(&content_bytes).to_string()); } } } Ok((fields, files)) } /// Extract field name from Content-Disposition header fn extract_field_name(disposition: &str) -> String { // Content-Disposition: form-data; name="field_name" for part in disposition.split(';') { let part = part.trim(); if let Some(name) = part.strip_prefix("name=\"") && let Some(end) = name.find('"') { return name[..end].to_string(); } } String::new() } /// Extract filename from Content-Disposition header fn extract_filename(disposition: &str) -> Option { // Content-Disposition: form-data; name="file"; filename="document.pdf" for part in disposition.split(';') { let part = part.trim(); if let Some(fname) = part.strip_prefix("filename=\"") && let Some(end) = fname.find('"') { return Some(fname[..end].to_string()); } } None } /// Handle FormPost upload request pub async fn handle_formpost( account: &str, container: &str, path: &str, content_type: &str, body: Vec, tempurl_key: &str, credentials: &rustfs_credentials::Credentials, ) -> SwiftResult> { use axum::http::{Response, StatusCode}; // Parse multipart boundary let boundary = parse_boundary(content_type).ok_or_else(|| SwiftError::BadRequest("Invalid Content-Type for FormPost".to_string()))?; // Parse multipart form let (fields, files) = parse_multipart_form(&body, &boundary)?; // Parse FormPost request parameters let request = FormPostRequest::from_form_fields(&fields)?; // Validate signature and expiration if let Err(e) = validate_formpost(path, &request, tempurl_key) { // Redirect to error URL let redirect_url = build_redirect_url(request.error_redirect_url(), 401, &format!("Unauthorized: {}", e)); return Response::builder() .status(StatusCode::SEE_OTHER) .header("location", redirect_url) .body(s3s::Body::empty()) .map_err(|e| SwiftError::InternalServerError(format!("Failed to build response: {}", e))); } // Check file count if files.len() as u64 > request.max_file_count { let redirect_url = build_redirect_url( request.error_redirect_url(), 400, &format!("Too many files: {} > {}", files.len(), request.max_file_count), ); return Response::builder() .status(StatusCode::SEE_OTHER) .header("location", redirect_url) .body(s3s::Body::empty()) .map_err(|e| SwiftError::InternalServerError(format!("Failed to build response: {}", e))); } // Upload files let mut upload_errors = Vec::new(); for file in &files { // Check file size if file.contents.len() as u64 > request.max_file_size { upload_errors.push(format!( "{}: File too large ({} > {})", file.filename, file.contents.len(), request.max_file_size )); continue; } // Upload file to container let object_name = &file.filename; let reader = std::io::Cursor::new(file.contents.clone()); // Create headers for upload let mut upload_headers = http::HeaderMap::new(); if let Some(ct) = &file.content_type && let Ok(header_value) = http::HeaderValue::from_str(ct) { upload_headers.insert("content-type", header_value); } match super::object::put_object(account, container, object_name, credentials, reader, &upload_headers).await { Ok(_) => { debug!( event = EVENT_SWIFT_FORMPOST_STATE, component = LOG_COMPONENT_PROTOCOLS, subsystem = LOG_SUBSYSTEM_SWIFT_FORMPOST, state = "uploaded", account = %account, container = %container, object = %object_name, "swift formpost state changed" ); } Err(e) => { upload_errors.push(format!("{}: {}", file.filename, e)); } } } // Build redirect response let (status, message, redirect_url_str) = if upload_errors.is_empty() { (201, "Created".to_string(), request.redirect.clone()) } else { ( 400, format!("Upload errors: {}", upload_errors.join(", ")), request.error_redirect_url().to_string(), ) }; let redirect_url = build_redirect_url(&redirect_url_str, status, &message); Response::builder() .status(StatusCode::SEE_OTHER) .header("location", redirect_url) .body(s3s::Body::empty()) .map_err(|e| SwiftError::InternalServerError(format!("Failed to build response: {}", e))) } #[cfg(test)] mod tests { use super::*; #[test] fn test_generate_signature() { let sig = generate_signature("/v1/AUTH_test/container", "https://example.com/success", 10485760, 5, 1640000000, "mykey") .unwrap(); // Signature should be consistent let sig2 = generate_signature("/v1/AUTH_test/container", "https://example.com/success", 10485760, 5, 1640000000, "mykey") .unwrap(); assert_eq!(sig, sig2); assert_eq!(sig.len(), 40); // SHA1 hex is 40 characters } #[test] fn test_signature_path_sensitive() { let sig1 = generate_signature( "/v1/AUTH_test/container1", "https://example.com/success", 10485760, 5, 1640000000, "mykey", ) .unwrap(); let sig2 = generate_signature( "/v1/AUTH_test/container2", "https://example.com/success", 10485760, 5, 1640000000, "mykey", ) .unwrap(); assert_ne!(sig1, sig2); } #[test] fn test_signature_redirect_sensitive() { let sig1 = generate_signature( "/v1/AUTH_test/container", "https://example.com/success1", 10485760, 5, 1640000000, "mykey", ) .unwrap(); let sig2 = generate_signature( "/v1/AUTH_test/container", "https://example.com/success2", 10485760, 5, 1640000000, "mykey", ) .unwrap(); assert_ne!(sig1, sig2); } #[test] fn test_signature_max_file_size_sensitive() { let sig1 = generate_signature("/v1/AUTH_test/container", "https://example.com/success", 10485760, 5, 1640000000, "mykey") .unwrap(); let sig2 = generate_signature("/v1/AUTH_test/container", "https://example.com/success", 20971520, 5, 1640000000, "mykey") .unwrap(); assert_ne!(sig1, sig2); } #[test] fn test_signature_max_file_count_sensitive() { let sig1 = generate_signature("/v1/AUTH_test/container", "https://example.com/success", 10485760, 5, 1640000000, "mykey") .unwrap(); let sig2 = generate_signature( "/v1/AUTH_test/container", "https://example.com/success", 10485760, 10, 1640000000, "mykey", ) .unwrap(); assert_ne!(sig1, sig2); } #[test] fn test_signature_expires_sensitive() { let sig1 = generate_signature("/v1/AUTH_test/container", "https://example.com/success", 10485760, 5, 1640000000, "mykey") .unwrap(); let sig2 = generate_signature("/v1/AUTH_test/container", "https://example.com/success", 10485760, 5, 1740000000, "mykey") .unwrap(); assert_ne!(sig1, sig2); } #[test] fn test_signature_key_sensitive() { let sig1 = generate_signature( "/v1/AUTH_test/container", "https://example.com/success", 10485760, 5, 1640000000, "mykey1", ) .unwrap(); let sig2 = generate_signature( "/v1/AUTH_test/container", "https://example.com/success", 10485760, 5, 1640000000, "mykey2", ) .unwrap(); assert_ne!(sig1, sig2); } #[test] fn test_validate_formpost_valid() { let key = "mykey"; let path = "/v1/AUTH_test/container"; let redirect = "https://example.com/success"; let max_file_size = 10485760; let max_file_count = 5; let expires = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() + 3600; // 1 hour from now let signature = generate_signature(path, redirect, max_file_size, max_file_count, expires, key).unwrap(); let request = FormPostRequest { redirect: redirect.to_string(), redirect_error: None, max_file_size, max_file_count, expires, signature, }; let result = validate_formpost(path, &request, key); assert!(result.is_ok()); } #[test] fn test_validate_formpost_expired() { let key = "mykey"; let path = "/v1/AUTH_test/container"; let redirect = "https://example.com/success"; let max_file_size = 10485760; let max_file_count = 5; let expires = 1000000000; // Past timestamp let signature = generate_signature(path, redirect, max_file_size, max_file_count, expires, key).unwrap(); let request = FormPostRequest { redirect: redirect.to_string(), redirect_error: None, max_file_size, max_file_count, expires, signature, }; let result = validate_formpost(path, &request, key); assert!(result.is_err()); match result { Err(SwiftError::Unauthorized(msg)) => assert!(msg.contains("expired")), _ => panic!("Expected Unauthorized error"), } } #[test] fn test_validate_formpost_wrong_signature() { let key = "mykey"; let path = "/v1/AUTH_test/container"; let expires = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() + 3600; let request = FormPostRequest { redirect: "https://example.com/success".to_string(), redirect_error: None, max_file_size: 10485760, max_file_count: 5, expires, signature: "invalid_signature".to_string(), }; let result = validate_formpost(path, &request, key); assert!(result.is_err()); match result { Err(SwiftError::Unauthorized(msg)) => assert!(msg.contains("Invalid")), _ => panic!("Expected Unauthorized error"), } } #[test] fn test_validate_formpost_wrong_hex_signature() { // A well-formed hex signature that does not match the expected one must still // be rejected. This exercises the constant-time comparison path (the decoded // bytes differ) rather than the hex-decode failure path. let key = "mykey"; let path = "/v1/AUTH_test/container"; let expires = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() + 3600; let request = FormPostRequest { redirect: "https://example.com/success".to_string(), redirect_error: None, max_file_size: 10485760, max_file_count: 5, expires, // Valid 40-char hex, but not the correct signature for these params. signature: "da39a3ee5e6b4b0d3255bfef95601890afd80709".to_string(), }; let result = validate_formpost(path, &request, key); assert!(result.is_err()); assert!(matches!(result.unwrap_err(), SwiftError::Unauthorized(_))); } #[test] fn test_build_redirect_url() { let url = build_redirect_url("https://example.com/success", 201, "Created"); assert_eq!(url, "https://example.com/success?status=201&message=Created"); let url = build_redirect_url("https://example.com/error", 400, "File too large"); assert_eq!(url, "https://example.com/error?status=400&message=File%20too%20large"); } #[test] fn test_formpost_request_error_redirect_url() { let request = FormPostRequest { redirect: "https://example.com/success".to_string(), redirect_error: Some("https://example.com/error".to_string()), max_file_size: 10485760, max_file_count: 5, expires: 1640000000, signature: "sig".to_string(), }; assert_eq!(request.error_redirect_url(), "https://example.com/error"); let request_no_error = FormPostRequest { redirect: "https://example.com/success".to_string(), redirect_error: None, max_file_size: 10485760, max_file_count: 5, expires: 1640000000, signature: "sig".to_string(), }; assert_eq!(request_no_error.error_redirect_url(), "https://example.com/success"); } #[test] fn test_from_form_fields_valid() { let mut fields = std::collections::HashMap::new(); fields.insert("redirect".to_string(), "https://example.com/success".to_string()); fields.insert("max_file_size".to_string(), "10485760".to_string()); fields.insert("max_file_count".to_string(), "5".to_string()); fields.insert("expires".to_string(), "1640000000".to_string()); fields.insert("signature".to_string(), "abcdef".to_string()); let result = FormPostRequest::from_form_fields(&fields); assert!(result.is_ok()); let request = result.unwrap(); assert_eq!(request.redirect, "https://example.com/success"); assert_eq!(request.max_file_size, 10485760); assert_eq!(request.max_file_count, 5); assert_eq!(request.expires, 1640000000); assert_eq!(request.signature, "abcdef"); } #[test] fn test_from_form_fields_missing_redirect() { let mut fields = std::collections::HashMap::new(); fields.insert("max_file_size".to_string(), "10485760".to_string()); fields.insert("max_file_count".to_string(), "5".to_string()); fields.insert("expires".to_string(), "1640000000".to_string()); fields.insert("signature".to_string(), "abcdef".to_string()); let result = FormPostRequest::from_form_fields(&fields); assert!(result.is_err()); } #[test] fn test_from_form_fields_invalid_max_file_size() { let mut fields = std::collections::HashMap::new(); fields.insert("redirect".to_string(), "https://example.com/success".to_string()); fields.insert("max_file_size".to_string(), "not_a_number".to_string()); fields.insert("max_file_count".to_string(), "5".to_string()); fields.insert("expires".to_string(), "1640000000".to_string()); fields.insert("signature".to_string(), "abcdef".to_string()); let result = FormPostRequest::from_form_fields(&fields); assert!(result.is_err()); } }