Skip to content

std/multipart ​

Parsing multipart/form-data request bodies (RFC 7578) — the format a browser posts a file upload with.

milo
from "std/multipart" import { Multipart, Part, Limits, MultipartError }

Every byte of a multipart body is attacker-controlled: the boundary, the number of parts, each part's headers, the declared content type, and the filename. The parser is written on that assumption.

Parsing ​

milo
match Multipart.parse(ctx.req.body, contentType) {
    Ok(parts) => {
        for i in 0..parts.len {
            print(parts[i].name)
        }
    }
    Err(e) => {
        print("bad upload: " + e.message())
    }
}

Parts come back in order and duplicate field names are kept — a form with several checkboxes of one name is normal, so field answers the first. The preamble before the first delimiter and the epilogue after the closing one are discarded, as RFC 2046 requires.

Limits ​

Limits caps the number of parts (default 256), the bytes in one part's body (8 MiB) and the bytes in one part's header block (16 KiB). Multipart.parse applies Limits.new() rather than making you remember to. Crossing a limit is an error naming it, never a truncated-but-plausible parse.

milo
var limits = Limits.new()
limits.maxPartBytes = 65536
let parts = Multipart.parseWithLimits(body, contentType, limits)?

The body must already be in memory: this is not a streaming parser, so bounding how much you read off the socket before calling in is still yours to do.

The filename is the client's, not yours ​

Part.filename is exactly what the client sent. It can be ../../etc/passwd, ., a name with a NUL or a newline in it, or 4 KB long. safeFilename is the only thing here you may join onto a path:

milo
let name = match parts[i].safeFilename() {
    Some(n) => n,
    None => generateOwnName(),
}

It rejects rather than repairs. Everything up to the last / or \ is dropped, and the remainder must then be non-empty, at most 255 bytes, not . or .., free of C0 controls, DEL and the bytes Windows treats specially (: * ? " < > |), not end in a space or a dot (Windows strips those, which is how evil.php. defeats an extension check), and not be a reserved device name such as CON or LPT1 — those resolve in every directory on Windows.

It does not make the content safe, does not stop you overwriting an existing file of the same name, and does not constrain the extension. Part.contentType is a claim by the client: do not decide how to handle a file from it, and do not echo it back as the Content-Type of a download.

Errors ​

MultipartError names what failed, with a byte offset into the body for per-part failures; the variants are listed under the API reference below. e.message() renders any of them.

API reference ​

Limits ​

milo
pub struct Limits

Limits — bounds the parser enforces against a hostile body.

Construct with Limits.new() for the defaults and adjust the fields you care about. All three are hard caps: crossing one aborts the parse with the matching error rather than dropping data.

Fields:

  • maxParts: i64: Maximum number of parts. Default 256.
  • maxPartBytes: i64: Maximum bytes in one part's body. Default 8 MiB.
  • maxHeaderBytes: i64: Maximum bytes in one part's header block. Default 16 KiB. A part is allowed very few headers; this exists so a body made entirely of header lines cannot make the parser walk megabytes per part.

Limits.new ​

milo
fn Limits.new(): Limits

The defaults: 256 parts, 8 MiB per part body, 16 KiB per part header block. Sized for an ordinary form post; raise maxPartBytes deliberately if you accept large uploads, and lower it if you do not.

Multipart ​

milo
pub struct Multipart

Multipart — parsing a multipart/form-data body.

Multipart.boundary ​

milo
fn Multipart.boundary(contentType: &string): Option<string>

The boundary parameter of contentType, or None when it is absent or not a legal boundary.

A probe: it deliberately does not distinguish "no boundary" from "illegal boundary", because a caller that only wants to know whether a request is parseable cannot act on the difference. parse reports them separately.

Multipart.field ​

milo
fn Multipart.field(parts: &Vec<Part>, name: &string): Option<string>

The body of the first part named name, or None if there is none.

Returns an owned copy. File parts are included: this asks about the field name, not about whether the value came from a file input.

Multipart.parse ​

milo
fn Multipart.parse(body: &string, contentType: &string): Result<Vec<Part>, MultipartError>

Parse body as the multipart entity described by the Content-Type header value contentType, under Limits.new().

Parts come back in the order they appeared; duplicate field names are kept, because a form with several checkboxes of one name is normal. The preamble before the first delimiter and the epilogue after the closing one are discarded, as RFC 2046 requires.

Multipart.parseWithLimits ​

milo
fn Multipart.parseWithLimits(body: &string, contentType: &string, limits: &Limits): Result<Vec<Part>, MultipartError>

Parse as parse does, under caller-chosen limits.

MultipartError ​

milo
pub enum MultipartError

MultipartError — why a body did not parse. Every offset is a byte offset into the body that was passed in, pointing at the construct that failed.

Variants:

  • NotMultipart: The Content-Type was not multipart/*.
  • MissingBoundary: The Content-Type had no boundary parameter.
  • InvalidBoundary: The boundary parameter was empty, longer than 70 bytes, or contained a byte RFC 2046 does not allow in one.
  • NoOpeningDelimiter: The body never contains the opening --boundary delimiter at all.
  • Unterminated(i64): Input ran out mid-part: no closing delimiter, or a truncated header block. Payload is where the unterminated part began.
  • MalformedHeader(i64): A header line with no ':'. Payload is the line's offset.
  • NotFormData(i64): A part whose Content-Disposition is absent or is not form-data.
  • MissingName(i64): A form-data part with no name parameter.
  • TooManyParts(i64): More parts than Limits.maxParts. Payload is that limit.
  • PartTooLarge(i64): A part body over Limits.maxPartBytes. Payload is that limit.
  • HeadersTooLarge(i64): A part header block over Limits.maxHeaderBytes. Payload is that limit.

MultipartError.message ​

milo
fn MultipartError.message(self: &MultipartError): string

Part ​

milo
pub struct Part

Part — one form field or uploaded file from a multipart body.

Fields:

  • name: string: The form field name, from the name parameter of Content-Disposition. Required: a part without one is a parse error, not a nameless part.
  • filename: Option<string>: The client-supplied filename, present exactly when the client sent a filename parameter. Untrusted and unvalidated — see safeFilename.
  • contentType: string: The part's declared Content-Type, or "" when it sent none. A claim, not a measurement.
  • body: string: The part's bytes, with the terminating CRLF removed. Binary-safe.

Part.isFile ​

milo
fn Part.isFile(self: &Part): bool

Whether the client sent this part as a file upload (it carried a filename parameter). An empty filename still counts as a file — that is what browsers send for an empty <input type="file">.

Part.safeFilename ​

milo
fn Part.safeFilename(self: &Part): Option<string>

The client's filename reduced to something safe to use as a single path component, or None when nothing safe is left.

Rejects, rather than repairs: everything up to the last '/' or '' is dropped (so "../../etc/passwd" and "C:\evil\x.txt" become "passwd" and "x.txt"), and the remainder must then be non-empty, at most 255 bytes, not "." or "..", free of C0 controls, DEL and the bytes Windows treats specially (: * ? " < > |), not end in a space or a dot (Windows strips those, which is how "evil.php." defeats an extension check), and not be a Windows reserved device name such as CON or LPT1 — those are devices on every drive and in every directory.

What this is not: it does not make the content safe, does not stop a caller from overwriting an existing file of the same name, and does not constrain the extension. Join it onto a directory you chose, and prefer a name you generated over one an uploader picked.