mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-07 13:53:12 +00:00
805 lines
26 KiB
Rust
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());
|
|
}
|
|
}
|