Introduction
What We’re Building
When to Use a Protected Upload Endpoint
Dependencies Required
Folder Setup (Create Upload Directories on Boot)
Make Upload Route Protected in main.rs
Step 1: Protect Upload Handler Using Session
Step 2: Optional Role-Based Upload Restriction
Step 3: CSRF Protection for Upload POST
Step 4: Full Production Upload Handler (Copy-Paste)
Frontend Request Example (React)
CORS Must Support Credentials
Security Best Practices for Upload Storage
Common Mistakes Developers Make
Summary Table
Introduction
File upload is one of the easiest attack surfaces in any backend. If you allow uploads without strong protection, attackers can:
Upload malware or scripts
Fill your disk with huge files (DoS)
Upload unexpected types (HTML/JS) leading to XSS
Use dangerous filenames like ../../something to overwrite system files
So a “working upload” is not enough. We need a protected upload endpoint that is safe for production.
In this blog, we will build:
✅ Auth-protected upload route (session-based)
✅ Optional role restriction (admin/vendor only)
✅ CSRF validation for POST requests
✅ File size limit
✅ Extension + MIME checks
✅ Safe filename + unique name
✅ Store under safe folder structure
✅ Return clean API response
2️⃣ What We’re Building
Endpoint:
POST /api/vehicles/upload-partner-image
Purpose:
Only logged-in users can upload partner images
Files are validated and stored safely
Response returns uploaded file path/name
3️⃣ When to Use a Protected Upload Endpoint
Use this approach when you upload:
Vehicle images
RC / insurance / pollution documents
Profile pictures
Admin documents
Do NOT expose open upload endpoints on production domains.
4️⃣ Dependencies Required
Your project already uses actix-multipart and futures-util. Add uuid and sanitize-filename if not already.
Cargo.toml
actix-multipart = "0.7"
futures-util = "0.3"
uuid = { version = "1", features = ["v4"] }
sanitize-filename = "0.5"
5️⃣ Folder Setup (Create Upload Directories on Boot)
In main.rs you already create upload folders:
std::fs::create_dir_all("uploads/rc_document")?;
std::fs::create_dir_all("uploads/insurance")?;
std::fs::create_dir_all("uploads/pollution")?;
std::fs::create_dir_all("uploads/brand_models")?;
Add a dedicated folder for partner images:
std::fs::create_dir_all("uploads/partner_vehicle")?;
Why this is needed
It avoids runtime crashes when folder doesn’t exist and keeps storage clean and organized.
6️⃣ Make Upload Route Protected in main.rs
You already have:
.route(
"/vehicles/upload-partner-image",
web::post().to(handlers::upload_vehicle_partner_image_handler),
)
Because you are using:
web::scope("/api")
The final URL becomes:
POST /api/vehicles/upload-partner-image
7️⃣ Step 1: Protect Upload Handler Using Session
The best practice is to block upload if user is not logged in.
You already have helper:
ensure_login_user_id(&session)?
So first line in upload handler should be:
let user_id = ensure_login_user_id(&session)?;
Why this matters
Without this, any anonymous user can fill your storage.
8️⃣ Step 2: Optional Role-Based Upload Restriction
If only vendors/admins should upload:
let role = session.get::<String>("role")?
.unwrap_or_else(|| "viewer".to_string());
if role != "admin" && role != "vendor" {
return Err(error::ErrorForbidden("Upload allowed only for vendor/admin"));
}
When to use role restriction
Use it when upload affects marketplace data or public listings.
9️⃣ Step 3: CSRF Protection for Upload POST
If your system uses sessions, uploads should also be CSRF-protected.
Check token from header:
Header name: X-CSRF-TOKEN
Stored in session: csrf_token
let expected = session.get::<String>("csrf_token")?
.unwrap_or_default();
let provided = req.headers()
.get("X-CSRF-TOKEN")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if expected.is_empty() || expected != provided {
return Err(error::ErrorForbidden("Invalid CSRF token"));
}
Why CSRF matters for uploads
An attacker site could force a logged-in user’s browser to upload something automatically.
🔟 Step 4: Full Production Upload Handler (Copy-Paste)
Create/Update:
src/handlers/vehicle.rs (or src/handlers/upload.rs)
use actix_multipart::Multipart;
use actix_session::Session;
use actix_web::{error, web, HttpRequest, HttpResponse, Result};
use futures_util::StreamExt;
use sanitize_filename::sanitize;
use std::io::Write;
use uuid::Uuid;
use super::common::{ensure_login_user_id, ApiResponse};
pub async fn upload_vehicle_partner_image_handler(
req: HttpRequest,
session: Session,
mut payload: Multipart,
) -> Result<HttpResponse> {
// ✅ 1) Auth check
let user_id = ensure_login_user_id(&session)?;
// ✅ 2) Optional role restriction
let role = session
.get::<String>("role")
.map_err(error::ErrorInternalServerError)?
.unwrap_or_else(|| "viewer".to_string());
if role != "admin" && role != "vendor" {
return Err(error::ErrorForbidden("Upload allowed only for vendor/admin"));
}
// ✅ 3) CSRF check (recommended for session-based apps)
let expected = session
.get::<String>("csrf_token")
.map_err(error::ErrorInternalServerError)?
.unwrap_or_default();
let provided = req
.headers()
.get("X-CSRF-TOKEN")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if expected.is_empty() || expected != provided {
return Err(error::ErrorForbidden("Invalid CSRF token"));
}
// ✅ 4) Single-file upload logic
let mut saved_files: Vec<String> = Vec::new();
while let Some(item) = payload.next().await {
let mut field = item.map_err(error::ErrorInternalServerError)?;
// filename
let orig = field
.content_disposition()
.get_filename()
.map(|f| sanitize(f))
.unwrap_or_else(|| "file.bin".to_string());
// ✅ 5) Extension allowlist
let lower = orig.to_lowercase();
let allowed_ext = lower.ends_with(".jpg")
|| lower.ends_with(".jpeg")
|| lower.ends_with(".png")
|| lower.ends_with(".webp");
if !allowed_ext {
return Err(error::ErrorBadRequest("Only jpg/jpeg/png/webp allowed"));
}
// ✅ 6) Content-Type allowlist (extra safety)
let ct = field.content_type().to_string();
let allowed_ct = ct.starts_with("image/");
if !allowed_ct {
return Err(error::ErrorBadRequest("Invalid content type"));
}
// ✅ 7) Unique filename (prevents overwrite)
let unique = format!("{}_{}_{}", user_id, Uuid::new_v4(), orig);
let filepath = format!("uploads/partner_vehicle/{}", unique);
// ✅ 8) Save with size limit
let mut f = std::fs::File::create(&filepath).map_err(error::ErrorInternalServerError)?;
let mut size: usize = 0;
let max_size: usize = 5_000_000; // 5MB
while let Some(chunk) = field.next().await {
let data = chunk.map_err(error::ErrorInternalServerError)?;
size += data.len();
if size > max_size {
return Err(error::ErrorBadRequest("File too large (max 5MB)"));
}
f.write_all(&data).map_err(error::ErrorInternalServerError)?;
}
saved_files.push(filepath);
}
Ok(HttpResponse::Ok().json(ApiResponse {
message: "Upload successful".to_string(),
data: saved_files,
}))
}
1️⃣1️⃣ Frontend Request Example (React)
Make sure cookies and CSRF token are included.
await fetch("/api/vehicles/upload-partner-image", {
method: "POST",
credentials: "include",
headers: {
"X-CSRF-TOKEN": csrfToken
},
body: formData
});
Why credentials: "include" is required
Because session cookie won’t be sent without it.
1️⃣2️⃣ CORS Must Support Credentials
In backend CORS config:
.supports_credentials()
And in production:
.allowed_origin("https://your-frontend-domain.com")
Do not use * origin with credentials.
1️⃣3️⃣ Security Best Practices for Upload Storage
✅ Store uploads outside static public root if possible
✅ Do not allow HTML/JS/PHP uploads
✅ Do not allow user-controlled file paths
✅ Generate unique filenames
✅ Keep size limit
✅ Log upload attempts
✅ Virus scan if needed (enterprise level)
1️⃣4️⃣ Common Mistakes Developers Make
❌ Allowing uploads without auth
❌ Using original filename directly
❌ No size limit
❌ Serving uploads as executable files
❌ Allowing .svg without sanitizing (SVG can contain scripts)
Summary Table
Top comments (0)