Files
rustfs/crates/protocols/src/swift/formpost.rs
T

805 lines
26 KiB
Rust

// 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
//! <form action="http://swift.example.com/v1/AUTH_account/container"
//! method="POST" enctype="multipart/form-data">
//! <input type="hidden" name="redirect" value="https://example.com/success" />
//! <input type="hidden" name="max_file_size" value="10485760" />
//! <input type="hidden" name="max_file_count" value="5" />
//! <input type="hidden" name="expires" value="1640000000" />
//! <input type="hidden" name="signature" value="abcdef1234567890" />
//! <input type="file" name="file1" />
//! <input type="file" name="file2" />
//! <input type="submit" value="Upload" />
//! </form>
//! ```
//!
//! # 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<Sha1>;
/// 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<String>,
/// 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<String, String>) -> SwiftResult<Self> {
// 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::<u64>()
.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::<u64>()
.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::<u64>()
.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<String> {
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<u8>,
/// Content type
pub content_type: Option<String>,
}
/// 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<String> {
// 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<String, String>, Vec<UploadedFile>)> {
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<String> {
// 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<u8>,
tempurl_key: &str,
credentials: &rustfs_credentials::Credentials,
) -> SwiftResult<http::Response<s3s::Body>> {
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());
}
}