Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Uploads

multipart/form-data request bodies: bounded, sanitized, held to their own bytes, and written to a storage disk under a key derived from those bytes.

Off by default, and that is the security decision rather than a packaging one. An upload endpoint is the largest attacker-authored surface a web application has — the filename, the declared content type and the byte count all come from the client — and a build with no upload route has no business carrying a multipart parser for one.

Turning it on

uploads is not in default. It is in fullstack.

arcature = { version = "0.1", features = ["uploads"] }
It pullsBecause
axum/multipartthe parser (multer, via axum::extract::Multipart)
validationthe extractor lives beside the other validated extractors and reports RFC 9457 problem details
storage-fsan upload is written to a storage disk, never to a path the request named
dep:tokioone thing only: tokio::time::timeout. A byte cap cannot express “the client stopped sending”, because a request that never finishes never exceeds anything
unicode-normalizationan attacker-authored filename goes into NFC before the reserved-name and extension checks look at it
sha2the content address
inferthe magic-number table

arc make:upload avatar generates a controller with the whole shape in it.

The UploadedFile extractor

use arcature::prelude::*;
use arcature::storage::StorageError;
use arcature::validation::upload::UploadedFile;

/// The disk sub-tree these uploads live under. A constant, and the
/// application's own string -- nothing off the request is ever a prefix.
const PREFIX: &str = "avatars";

pub async fn store(State(state): State<AppState>, upload: UploadedFile) -> Result<Response> {
    let storage = state
        .storage
        .as_ref()
        .ok_or_else(|| Error::Storage("no storage disk is configured".to_string()))?;

    let address = upload.store_under(&storage.default_disk(), PREFIX).await?;
    let key = address.path_under(PREFIX).map_err(StorageError::from)?;

    Ok((
        StatusCode::CREATED,
        json(serde_json::json!({
            "path": key.as_str(),
            "bytes": address.byte_len(),
            "filename": upload.filename().to_string(),
        })),
    )
        .into_response())
}

That is the generated blueprint’s shape, not a standalone example: it needs an AppState with a storage field and a route to hang off, so it will not compile on its own.

Five things are true by the time the handler body starts, because extraction is what makes them true:

  1. Bounds. Total size, per-part size, part count and a read timeout, from the MultipartLimits in the request extensions.
  2. A whitelisted extension, from the UploadPolicy in the request extensions.
  3. A sanitized filename. The path, the controls, the bidi overrides and the Windows device names are gone, and the result is metadata.
  4. Bytes that match the extension. shell.php renamed avatar.png is refused here, not discovered later.
  5. A client-safe rejection, if any of the above failed.

UploadedFile implements FromRequest, not FromRequestParts. It consumes the body, so it has to be the last handler argument.

MethodReturns
filename()&SafeFilenamemetadata: show it, send it in a Content-Disposition, never resolve it as a path
content_type()Option<SniffedType>, derived from the object, never from the part’s header
bytes()&Bytes
byte_len()usize
into_bytes()Bytes, consuming the wrapper
store(&disk)writes under ab/cd/<digest>.<ext>
store_under(&disk, prefix)writes under <prefix>/ab/cd/<digest>.<ext>

Both store methods return a ContentAddress and fail with UploadError, which has exactly two variants: UploadError::Storage (the backend failed — a 5xx) and UploadError::Content (the bytes and the extension disagree — a 4xx). They are kept apart so that a disk that is down and a file that was refused do not answer with the same status. From<UploadError> for Error preserves the split, which is why the example uses ? and not a map_err.

store_under writes under the prefix, so address.path() — the address on its own — will not find the object afterwards. address.path_under(PREFIX) is the key that will.

What a refusal looks like

Every refusal is an RFC 9457 problem document shaped exactly like any other field validation failure, reported under the fixed key file (upload::UPLOAD_FIELD) and never under the part’s own name.

CodeStatusWhen
missing422the body ended without a part that carried a filename= the policy would accept
extension422the filename could not be sanitized, or its extension is off the whitelist
contents422the bytes and the extension disagree

A body that is not multipart/form-data at all is a 415. A bound that was crossed is a 413 or a 408 — see MultipartLimits.

The code and the message are both fixed strings chosen in the framework. Nothing from the request reaches the response body: not the filename, not the declared content type, and not the sniffed one — reporting the last of those would let an uploader use the endpoint as a free file-type oracle.

It buffers, and that is the small-file path

An extractor runs to completion before the handler is called, so it has nowhere to put the bytes except memory. That buffer is bounded by MultipartLimits::field_bytes, which defaults to 8 MiB; lowering it on an upload route is how an application keeps the buffer small.

For anything bigger than an avatar, do not use this extractor. Drive BoundedMultipart in the handler and write each chunk through UploadWriter, which never holds more than one chunk regardless of object size. The extractor is the convenience; that is the load-bearing path.

It is not authorization

A validated upload is not an authorized upload, exactly as a validated request is not an authorized request. Whether this user may write to this disk under this prefix is a separate, explicit decision.

UploadPolicy and AllowedExtensions

UploadPolicy is a tower::Layer. Applying it to a route puts the policy in that request’s extensions, where the extractor reads it back.

use arcature::storage::AllowedExtensions;
use arcature::validation::upload::UploadPolicy;

let router = axum::Router::new()
    .route("/attachments", axum::routing::post(store))
    .layer(UploadPolicy::new(AllowedExtensions::documents()).with_field("attachment"));

With no policy layer installed, the default is images and nothing else. UploadPolicy::from_extensions never returns None; it falls back to UploadPolicy::default(), which is UploadPolicy::new(AllowedExtensions::images()). A route that forgot the layer fails closed, because “no policy configured” must not mean “anything goes”. Widening the whitelist is the thing an application has to do on purpose.

Built-inExtensions
AllowedExtensions::images()jpg, jpeg, png, gif, webp
AllowedExtensions::documents()pdf, txt, csv
AllowedExtensions::new(["JPG", "pdf"])?whatever is listed, lowercased; errors on an entry that is not a valid extension
AllowedExtensions::default()empty — and an empty whitelist stores nothing at all, which is the right behaviour for a misconfiguration

svg is deliberately absent from images(). An SVG is an XML document that may carry script, so serving one inline is a stored-XSS primitive; an application that needs SVG adds it knowingly with .with("svg")?.

It is a whitelist and never a blacklist. A blacklist of dangerous extensions is a list of the ones somebody thought of, and the interesting ones are always the other ones — .phtml, .php7, .cgi, .jsp, .svgz, .htaccess.

The rest of the surface: contains(&extension), iter(), len(), is_empty(), and with(extension) to add one.

An Extension is one to sixteen ASCII alphanumerics, lowercased on parse, and nothing else. That is not tidiness. An extension is the string a web server, an operating system and a browser each use to decide what a file is, and allowing only [0-9a-z]{1,16} leaves them no encoding, no separator and no homoglyph to disagree about.

UploadPolicy::with_field("attachment") requires the file to arrive in the part of that name. Without it the first part carrying a filename= wins, which is convenient and slightly sloppy: a form with two file inputs becomes order-dependent.

Filenames are sanitized, and then they are metadata

SafeFilename::parse(filename, &allowed) is the one place a client-authored filename= is disarmed. It handles six different attacks, and a filter that knows about one of them is a filter that has not been written yet.

InputWhat it wantsWhat happens
../../etc/passwd.pngescape the storage rootthe directory part is discarded before anything else runs; the name is passwd.png
a filename with a NUL in ittruncate the name in a C API downstreamrejected: control characters are never repaired
CON.pngopen a Windows device instead of a filerejected, with any extension and with trailing dots or spaces
a name with a right-to-left override in itrender so the extension reads .jpgrejected: bidi and other invisible format controls are fatal
shell.php.jpgbe served by a mis-configured AddHandlerthe inner extension marker is replaced: shell_php.jpg
an ordinary Vietnamese filename with diacriticsnothingaccepted, NFC-normalized, otherwise unchanged

That last row is why this exists at all. StoragePath::new validates and rejects; it has no opinion about how to repair a name, so a filename with a space or a diacritic in it fails a check that was never aimed at it, and a sanitizer that rejects real names teaches applications to bypass the sanitizer.

The reject-versus-repair split is deliberate. A character is repaired (replaced with _) when a human plausibly typed it and it is only dangerous to a downstream parser: . (every remaining one, after the extension is split off), /, \, :, *, ?, ", <, >, |. A character is rejected when its presence is itself the attack: the C0 and C1 controls, DEL, the bidi overrides and isolates, the zero-width joiners, the word joiner, the byte-order mark, the blank-rendering separators and the Unicode tag block. Nobody names a holiday photo with a right-to-left override in it. The variation selectors are deliberately not fatal — they occur beside emoji in names people really have.

The whole name is fitted inside MAX_FILENAME_BYTES (255, the per-component limit on ext4, XFS, APFS and NTFS alike) by truncating the stem on a character boundary. The extension is never truncated: half an extension is a different file type.

FilenameError is the coarse reason the parse failed — Empty, TooLong, ControlChar, Traversal, MissingExtension, InvalidExtension, ExtensionNotAllowed, EmptyStem, ReservedName. It is coarse on purpose: the caller reports a fixed string to the client, never the offending input.

The name that comes out is still metadata. StoragePath::from_filename exists for the case where a sanitized name really is the key, and it produces exactly one path segment — but two users still collide on it, and one overwrites the other. Where the key does not have to be human-readable, use the content address and keep the filename for the download header.

MultipartLimits

A multipart body is a stream the client writes and the server reads until the client stops. Four separate things there are the client’s choice, and each is a separate way to exhaust a process.

SettingDefaultConstWhat it stops
fields32DEFAULT_FIELDSfifty thousand two-byte parts inside a 1 MiB body — the cost of a part is a header parse and an allocation, not its length
field_bytes8 MiBDEFAULT_FIELD_BYTESone part that is the entire budget. Deliberately below the total
total_bytes16 MiBDEFAULT_TOTAL_BYTESone request that fills the disk or the heap
read_timeout30 sDEFAULT_READ_TIMEOUTa byte a minute, holding a task and a socket open

These apply with no layer installed. MultipartLimits::from_extensions never returns None; it falls back to MultipartLimits::new(), the values above. There is no way to end up with no bound, and nothing here reads any value as unlimited — with_fields(0) means the body may contain no parts at all, and that is what it does.

Override them per route:

use std::time::Duration;
use arcature::http::multipart::MultipartLimits;

let router = axum::Router::new().route(
    "/avatar",
    axum::routing::post(store).layer(
        MultipartLimits::new()
            .with_total_bytes(2 * 1024 * 1024)
            .with_field_bytes(2 * 1024 * 1024)
            .with_fields(4)
            .with_read_timeout(Duration::from_secs(10)),
    ),
);

Readers: total_bytes(), field_bytes(), fields(), read_timeout().

The part count is checked before the read, so the part past the cap is refused rather than parsed and then refused. Byte counts are checked as soon as the chunk that crosses a cap arrives, so nothing past a cap is ever handed to the caller.

MultipartErrorStatus
TooManyFields { limit }413
FieldTooLarge { limit }413
BodyTooLarge { limit }413
ReadTimeout { after }408
Parse { source }whatever axum maps the parser error to

MultipartError::problem() builds the RFC 9457 document. Its detail is a fixed per-category string; axum’s MultipartError::body_text is deliberately not used, because it can carry the parser’s own message, which quotes header bytes the client wrote.

The streaming path

BoundedMultipart::new(multipart, limits) wraps axum’s parser rather than replacing it. Every read goes through the timeout, every part is counted before it is handed out, and every chunk is added to both a per-part and a whole-body total.

let mut multipart = BoundedMultipart::new(multipart, limits);
while let Some(mut field) = multipart.next_field().await? {
    while let Some(chunk) = field.chunk().await? {
        upload.write(chunk).await?;
    }
}

Parts are handed out one at a time and borrow the parser. That is multer’s requirement, not a choice made here: part n+1 cannot be read before part n has been consumed.

BoundedField offers name(), file_name() (raw and unsanitized, exactly as the client wrote it), declared_content_type(), byte_len(), chunk(), bytes() (consuming, bounded by field_bytes, and meant for the small text inputs beside the file) and text() (which returns Ok(None) rather than an error when the part is not UTF-8, because that is the caller’s schema failing, not the transfer). BoundedMultipart itself reports limits(), fields_read() and bytes_read().

Content-addressed storage

The object key is SHA-256(bytes) plus a whitelisted extension, split into two levels of fan-out:

ab/cd/abcd...9824.png

Path traversal is not one bug, it is a family of them, and the family is large because the input is a string whose structure the attacker chose: ../, ..\, %2e%2e%2f, ....//, a NUL that truncates the name in the C library underneath, an NTFS alternate data stream, a symlink the previous upload planted. A sanitizer answers those one at a time and is only ever as complete as the last person who thought about it. Content addressing answers all of them at once, structurally: no byte of the request reaches the path. There is nothing left for a traversal payload to be in.

Three things fall out of it. The same bytes uploaded twice land on the same key and occupy the storage once. Re-uploading is idempotent rather than a race between two writers for one name. And a filesystem disk gets 65,536 leaf directories instead of one directory with a million entries in it.

ContentAddress carries digest() (64 lowercase hex characters, DIGEST_HEX_LEN), byte_len(), extension(), path() and path_under(prefix). ContentAddress::of(bytes, extension) addresses a buffer already in memory; ContentHasher (new, update, byte_len, finish) does it a chunk at a time.

The original filename is not part of any of this. It is a label carried alongside the object, rendered in a Content-Disposition header and shown in a UI, and resolved by nothing. Keep both layers: the sanitizer makes the metadata safe to display, and content addressing makes the path safe to resolve. Neither substitutes for the other.

Staging, because the key is not known until the end

The digest exists only after the last byte has gone past, so bytes go to a unique transient key under STAGING_PREFIX (_staging) first and are moved onto the content-addressed key afterwards. The staging key is process id, wall-clock nanoseconds and a process-local counter — never anything the client sent.

let mut upload = disk.begin_upload_under("avatars").await?;
upload.write(chunk).await?;
let address = upload.finish_verified(Extension::parse("png")?).await?;

begin_upload() puts the object at the root; begin_upload_under(prefix) validates the prefix before a byte is written, rather than after the whole body has been streamed to a key that turns out to have nowhere to go.

UploadWriter::write sends the chunk to the backend and to the hasher and then drops it. Nothing accumulates: an upload of any size costs one chunk of memory, which is what makes the size caps a policy rather than the only thing standing between a request and the heap. The one exception is the sniff buffer, at most 512 bytes, readable as head().

finish(extension) closes the object — the close is what completes it on an S3-compatible backend, and it happens before the move — then moves it onto its content-addressed key. If something is already at the destination the staging copy is deleted instead. That is not an optimization: the key is the digest, so an object already there has exactly these bytes, and overwriting it would be a no-op that can still fail (on Windows a rename over a file another reader has open does).

finish_verified(extension) is the call an upload handler wants. It re-checks the leading bytes against the extension and, on disagreement, removes the staging object before returning the error, so a rejected upload costs no disk.

abort() drops the staging object. Neither finish nor abort is optional. Dropping an UploadWriter without calling one of them leaves the staging object behind, and on an S3-compatible backend leaves the multipart upload un-closed. There is no Drop that can fix that, because both fixes are async.

It is not access control

A digest is unguessable in practice, but “unguessable URL” is not authorization — it leaks through referrers, logs and browser history like any other URL. Authorize the download.

The declared Content-Type is carried and never believed

A multipart part arrives with two claims about its type, and the client wrote both of them. Content-Type: image/png is a string in a header the uploader chose. filename="avatar.png" is a string in the same header, chosen by the same uploader. Neither is evidence.

BoundedField::declared_content_type() hands the header value back, because an application may want to log it or compare it. Nothing in Arcature decides what a file is from it. The only statement about an upload that the client did not author is the one made by its first few hundred bytes.

sniff(bytes) compares the leading SNIFF_BYTES (512) against a table of magic numbers and returns Option<SniffedType>, which carries both the canonical extension() and the mime(). verify(bytes, &extension) holds the two to a symmetric agreement:

  • An extension with a known signature — png, jpg, pdf, docx, mp4, and the rest of the table — must sniff to that signature. Bytes that sniff to nothing are refused, not waved through: “unrecognized” is exactly what a PHP script renamed .jpg looks like.
  • An extension with no signature — txt, csv, an application’s own — must sniff to nothing. If a .txt upload’s first bytes are a PE header or a zip local-file header, the extension and the content disagree just as loudly, in the other direction.

Both directions are refusals, so there is no “unknown” state an upload can land in and be accepted by default. expected_signatures(&extension) exposes the table entry, or None for a format with no magic number. The OOXML entries accept plain zip beside their own type, because a .docx is a zip archive and whether the inner content-type part is close enough to the front of the stream to be seen inside 512 bytes depends on how the producing application ordered it.

SniffError has two variants: Unrecognized { declared } and Mismatch { declared, sniffed }.

A .txt or .csv upload therefore succeeds with content_type() of None. That is correct, not a gap: there was no signature to find.

Serving a stored file back

Accepting an upload safely and serving it safely are two different jobs, and doing the first perfectly buys nothing if the second hands the file to a browser as a document. A stored file served inline is content the attacker wrote, on the application’s own origin, with the application’s cookies attached — stored XSS with extra steps.

use arcature::http::download::Attachment;

let label = SafeFilename::parse(&stored_name, &AllowedExtensions::documents())
    .map_err(|_| arcature::bad_request("that filename is not storable"))?;

Ok(Attachment::from_disk(&disk, &key)
    .await
    .map_err(|_| arcature::not_found("no such file"))?
    .with_filename(&label)
    .into_response())

Every Attachment response carries all four of these, by construction:

HeaderValueWhy
Content-Dispositionattachment, plus the filename if one was setthe browser saves the file instead of rendering it. Nothing is a document, so nothing has an origin, so nothing has script
X-Content-Type-Optionsnosniffwithout it a browser is free to disagree with the declared type and render what it thinks it found
Content-Security-Policydefault-src 'none'; sandbox (DOWNLOAD_CSP)the belt to the disposition header’s braces, for the browser or plugin that renders it anyway
Content-Typethe sniffed media type, or application/octet-stream (OCTET_STREAM)never taken from the request

Content-Length is set as well when the length is known.

A recognized-but-scriptable media type is downgraded to application/octet-stream rather than forwarded. The list is text/html, application/xhtml+xml, image/svg+xml, text/xml, application/xml. The disposition header should already have made this moot, and nosniff and the sandbox policy should have made it moot twice; this is the third lock, and it is there because the cost of being wrong is stored XSS on the application’s own origin.

Attachment::from_disk(&disk, &path) reads only the leading 512 bytes up front, to decide the media type, and streams the remainder — so the response costs one buffer rather than one object. It returns StorageError when the object cannot be stat’d or read, including when it does not exist, which a handler should translate into a 404 rather than passing through. Attachment::from_bytes(bytes) serves something already in memory. with_content_type(sniffed) overrides the type when it was sniffed at upload time and stored beside the object; it takes a SniffedType and there is deliberately no way to set an arbitrary string, so every media type this response can carry is one some bytes were recognized as.

with_filename takes a SafeFilename rather than a &str on purpose — a string parameter would be a header-injection hole waiting for the one caller who forgot. An ASCII name goes out once, as filename="report.pdf". A name that is not ASCII goes out twice per RFC 6266 — the plain form plus an RFC 5987 filename*=UTF-8'' — because a name with diacritics is not representable in the first form and silently mangling it is worse than sending both. The extended form is only worth sending when the plain one lost something.

Limits

axum’s own 2 MiB body cap bites first

axum wraps a request body in http_body_util::Limited at 2 MiB unless the application raises DefaultBodyLimit. Arcature never touches DefaultBodyLimit — grep the crate and the only mentions are in prose. So on a default build the effective ceiling on an upload is 2 MiB, not the 16 MiB DEFAULT_TOTAL_BYTES above, and a 5 MiB photograph is refused by axum before MultipartLimits has seen a byte of it.

That failure arrives as MultipartError::Parse wrapping axum’s StreamReadFailed, and takes the status axum gives it — which is also a 413. axum downcasts the inner error to http_body_util::LengthLimitError and answers PAYLOAD_TOO_LARGE, so a client cannot tell which of the two caps refused it from the status alone. Only the detail string differs. An application that accepts files larger than 2 MiB has to raise the axum limit itself.

Stage 12 of the request pipeline is a separate, third cap: tower-http’s RequestBodyLimitLayer, applied only when the application calls Application::body_limit(bytes). It defaults to absent in the framework, and the generated application’s bootstrap/app.rs sets it to 2 MiB. Nothing in MultipartLimits can raise either outer wall, and nothing needs to: a body over one of them is refused without being buffered.

MultipartLimits is the inner bound that knows the body has parts. Carrying a total there as well is not redundant — an application that never configured stage 12 still gets one, and one that did gets to make a single upload route stricter than the rest of the application without loosening anything.

read_timeout is per read, not per request

The timeout wraps each individual read: each next_field() and each chunk(). A large upload over a slow link is many reads that each return promptly, and it is not what this refuses. What it refuses is the connection that goes quiet with the request half-sent.

The consequence runs the other way too: a client that sends one byte just inside the timeout, forever, is not stopped by the clock. It is stopped by total_bytes, which is why both bounds exist. There is no whole-request deadline here. Application::timeout(duration) at stage 13 of the pipeline is the place for one.

No image decoding, deliberately

Nothing in the upload path decodes anything. No image is parsed, no dimensions are read, no pixel is produced, and there is no image crate in the dependency graph. The type check is a comparison of at most 512 leading bytes against a table of magic numbers, and that is all it is.

A decoder is an interpreter for attacker-controlled input, and decoders are the densest source of memory-safety CVEs in any web stack. Running one in the request path trades a type-confusion bug for a heap-corruption bug. A prefix comparison has no state for hostile bytes to drive.

So if an application needs an image’s dimensions, a thumbnail, a re-encode or a strip of EXIF, that work belongs in a queue worker with its own memory bound and its own timeout — see Jobs — and not in the handler. Put the bytes on a disk, put the key in the payload, and let something that is allowed to crash do the decoding.

What else it does not do

  • It is not a virus scanner, and not a claim of safety. A file can be genuinely, verifiably a PNG and still be malicious. What sniffing answers is narrower and worth having on its own: the bytes and the extension agree.
  • It does not clean up after a process that died mid-upload. Staging objects live under one prefix (_staging) precisely so an operator can see at a glance whether anything was left behind, but nothing sweeps it.
  • It does not delete stored objects, ever. Content addressing means two references can share one object, so nothing here can know when the last one went away. Reference counting and garbage collection are the application’s.
  • It does not record the upload anywhere. No table, no row, no metadata store. The handler gets an address and a filename and decides what to persist.
  • It does not rate-limit. Stage 15 of the pipeline does, and an upload route usually wants a tighter limit than the rest of the application.