Debug School

rakesh kumar
rakesh kumar

Posted on

Create a Protected File Upload Endpoint in Actix-Web (Rust) – Secure + Production Ready

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Purpose:

Only logged-in users can upload partner images

Files are validated and stored safely

Response returns uploaded file path/name
Enter fullscreen mode Exit fullscreen mode

3️⃣ When to Use a Protected Upload Endpoint

Use this approach when you upload:

Vehicle images

RC / insurance / pollution documents

Profile pictures

Admin documents
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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")?;
Enter fullscreen mode Exit fullscreen mode

Add a dedicated folder for partner images:

std::fs::create_dir_all("uploads/partner_vehicle")?;
Enter fullscreen mode Exit fullscreen mode

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),
)

Enter fullscreen mode Exit fullscreen mode

Because you are using:

web::scope("/api")
Enter fullscreen mode Exit fullscreen mode

The final URL becomes:

POST /api/vehicles/upload-partner-image
Enter fullscreen mode Exit fullscreen mode

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)?
Enter fullscreen mode Exit fullscreen mode

So first line in upload handler should be:

let user_id = ensure_login_user_id(&session)?;
Enter fullscreen mode Exit fullscreen mode

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"));
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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"));
}
Enter fullscreen mode Exit fullscreen mode

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,
    }))
}
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

And in production:

.allowed_origin("https://your-frontend-domain.com")
Enter fullscreen mode Exit fullscreen mode

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)