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();
}
Source: axum: extract::multipart and axum example: stream-to-file
$ 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
Source: axum: extract::multipart and axum example: stream-to-file
Limits
-
MultiparthonoursDefaultBodyLimit(2 MB by default). Raise it per route as above, not globally. Since 0.8.9 exceeding it is reported as a specific error —413with "Request payload is too large" fromMultipartError::statusandbody_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/RequestBodyDeadlineLayeron 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.
Source: axum example: multipart-form
Validating what was uploaded
-
Never trust
file_name()orcontent_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_imagedoes, or with a crate such asinfer. For images, decoding them (theimagecrate) and re-encoding is the strongest check and strips metadata such as GPS coordinates — do it inspawn_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();
}
Source: axum-extra: FileStream
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.