File Uploads

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against axum 0.8.9 (feature multipart) and axum-extra 0.12.6 on Rust 1.99 (edition 2024), checked on 2026-10-06. Bookshelf librarians upload cover images for books, and members download exports. Uploads are one of the riskiest features of a web application: they bring large bodies, attacker-controlled bytes and attacker-controlled file names. This page treats each concern in turn.

The Multipart extractor

HTML forms with enctype="multipart/form-data" send each field as a separate part with its own headers. Multipart (axum’s multipart feature, built on the multer crate) yields the parts one by one with next_field(). Each Field exposes name(), file_name(), content_type() and the data — all at once with bytes()/text(), or incrementally with chunk():

use std::path::PathBuf;

use axum::{
    Json, Router,
    extract::{DefaultBodyLimit, Multipart, Path},
    http::StatusCode,
    routing::post,
};
use serde::Serialize;
use tokio::io::AsyncWriteExt;

const MAX_COVER_BYTES: usize = 5 * 1024 * 1024;

#[derive(Serialize)]
struct Stored {
    id: String,
    bytes: usize,
    mime: &'static str,
}

/// Content sniffing: trust the bytes, not the client's file name or Content-Type.
fn sniff_image(head: &[u8]) -> Option<&'static str> {
    match head {
        [0xFF, 0xD8, 0xFF, ..] => Some("image/jpeg"),
        [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, ..] => Some("image/png"),
        [b'R', b'I', b'F', b'F', _, _, _, _, b'W', b'E', b'B', b'P', ..] => Some("image/webp"),
        _ => None,
    }
}

async fn upload_cover(Path(isbn): Path<String>, mut multipart: Multipart) -> Result<Json<Stored>, (StatusCode, String)> {
    let bad = |msg: String| (StatusCode::BAD_REQUEST, msg);
    let upload_dir = PathBuf::from(std::env::var("UPLOAD_DIR").unwrap_or_else(|_| "uploads".into())); // NOT under static/
    tokio::fs::create_dir_all(&upload_dir).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

    while let Some(mut field) = multipart.next_field().await.map_err(|e| (e.status(), e.body_text()))? {
        if field.name() != Some("cover") {
            continue; // ignore unexpected fields
        }
        tracing::debug!(client_name = ?field.file_name(), client_type = ?field.content_type(), "upload started");

        // sniff the first chunk before touching the disk
        let first = field.chunk().await.map_err(|e| (e.status(), e.body_text()))?.ok_or_else(|| bad("empty file".into()))?;
        let mime = sniff_image(&first).ok_or_else(|| bad("only JPEG, PNG or WebP images".into()))?;

        // a server-generated name: no path traversal, no overwriting, no executable extensions
        let id = format!("{isbn}-{}", uuid::Uuid::new_v4().simple());
        let tmp_path = upload_dir.join(format!("{id}.part"));
        let result = async {
            let mut file = tokio::fs::File::create(&tmp_path).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
            file.write_all(&first).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
            let mut written = first.len();
            // stream chunk by chunk: memory use stays at one chunk whatever the file size
            while let Some(chunk) = field.chunk().await.map_err(|e| (e.status(), e.body_text()))? {
                written += chunk.len();
                file.write_all(&chunk).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
            }
            file.flush().await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
            Ok::<_, (StatusCode, String)>(written)
        }
        .await;
        let written = match result {
            Ok(n) => n,
            Err(err) => {
                let _ = tokio::fs::remove_file(&tmp_path).await; // no half-written files left behind
                return Err(err);
            }
        };

        // only a complete, valid upload gets its final name
        let ext = mime.trim_start_matches("image/");
        tokio::fs::rename(&tmp_path, upload_dir.join(format!("{id}.{ext}")))
            .await
            .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
        return Ok(Json(Stored { id, bytes: written, mime }));
    }
    Err(bad("missing 'cover' field".into()))
}

#[tokio::main]
async fn main() {
    let app = Router::new().route(
        "/api/v1/books/{isbn}/cover",
        post(upload_cover).layer(DefaultBodyLimit::max(MAX_COVER_BYTES)), // this route only
    );
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}
$ printf '\x89PNG\r\n\x1a\n...rest of a png...' > cover.png
$ curl -s -F 'cover=@cover.png' localhost:3000/api/v1/books/9780441013593/cover
{"id":"9780441013593-a7eab4e710f8441fb9e49f3a9cf64f81","bytes":27,"mime":"image/png"}
$ curl -s -F 'cover=@notes.txt;type=image/png' localhost:3000/api/v1/books/9780441013593/cover
only JPEG, PNG or WebP images
$ (printf '\x89PNG\r\n\x1a\n'; head -c 6000000 /dev/zero) > big.png
$ curl -s -w ' %{http_code}\n' -F 'cover=@big.png' localhost:3000/api/v1/books/9780441013593/cover
Request payload is too large 413

Limits

  • Multipart honours DefaultBodyLimit (2 MB by default). Raise it per route as above, not globally. Since 0.8.9 exceeding it is reported as a specific error — 413 with "Request payload is too large" from MultipartError::status and body_text — where earlier versions surfaced a generic parsing error. The partial file is deleted by the error branch above.

  • Option<Multipart> (0.8.5) lets an endpoint accept requests with or without a multipart body.

  • Large uploads are also bounded by time: add RequestBodyTimeoutLayer/RequestBodyDeadlineLayer on the upload route (see Request Limits & Timeouts).

  • For files of hundreds of megabytes, let clients upload directly to object storage (S3, GCS, Azure Blob) with a pre-signed URL issued by the API; the application then only records the object key.

Validating what was uploaded

  • Never trust file_name() or content_type(). Both come from the client. The file name may contain ../ or absolute paths, and the content type is whatever the client claims. Use them only for logging or a display name.

  • Sniff the content: check the first bytes against known signatures ("magic numbers"), as sniff_image does, or with a crate such as infer. For images, decoding them (the image crate) and re-encoding is the strongest check and strips metadata such as GPS coordinates — do it in spawn_blocking.

  • Generate the stored name (UUID or content hash) and keep the original name, if needed, in the database.

  • Scan for malware when files are shared with other users.

Storing and serving uploads

Uploaded files must not land in a directory that ServeDir serves directly: a crafted HTML or SVG file would then be served from your origin and could run scripts (stored XSS). Store them elsewhere and serve them through a handler that sets the content type you verified, plus X-Content-Type-Options: nosniff and, for anything not displayed inline, Content-Disposition: attachment. axum-extra’s FileStream streams a file with a download name and range support:

use axum::{
    Router,
    extract::Path,
    http::{StatusCode, header},
    response::{IntoResponse, Response},
    routing::get,
};
use axum_extra::response::FileStream;
use tokio_util::io::ReaderStream;

async fn download_export(Path(id): Path<String>) -> Response {
    // only ids we generated: hex characters, so no path tricks are possible
    if id.len() != 32 || !id.bytes().all(|b| b.is_ascii_hexdigit()) {
        return StatusCode::NOT_FOUND.into_response();
    }
    let path = std::path::Path::new("exports").join(format!("{id}.csv"));
    let Ok(file) = tokio::fs::File::open(&path).await else {
        return StatusCode::NOT_FOUND.into_response();
    };
    let size = file.metadata().await.map(|m| m.len()).ok();
    let mut stream = FileStream::new(ReaderStream::new(file)).file_name("bookshelf-export.csv");
    if let Some(size) = size {
        stream = stream.content_size(size);
    }
    let mut response = stream.into_response();
    response.headers_mut().insert(header::CONTENT_TYPE, "text/csv".parse().expect("static value"));
    response.headers_mut().insert(header::X_CONTENT_TYPE_OPTIONS, "nosniff".parse().expect("static value"));
    response
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/exports/{id}", get(download_export));
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

FileStream sets Content-Disposition: attachment; filename="..." (with quotes and backslashes escaped since 0.12.6) and, with into_range_response, answers Range requests. For in-memory data, Attachment does the same (see axum-extra).

Where the books are outdated

Yoon (Beginning Axum, chapter 6) reads uploaded files with field.bytes() into memory and saves them under the client-supplied file name. Stream with chunk() (or wrap the field in a StreamReader) and generate names on the server. Before axum 0.8.9 a body-limit violation inside Multipart surfaced as a generic parse error.