FileUpload
Parsing multipart/form-data and storing what arrives, on the assumption that
whoever sent it is not on your side.
Feed it a hostile filename → — that page runs the real sanitiser on whatever you type, and stores nothing.
Two halves
Receiving is a streaming parser. File parts are written to temp files as
they arrive and never held whole in memory, so a large upload does not become a
large process. Ordinary form fields are captured up to a cap. It guards against
malformed input, oversize parts, too many parts or files, RFC 5987 extended
filenames, obsolete header folding, and base64 / quoted-printable transfer
encodings.
Storing is validate-then-persist. Size, extension and MIME policy are all checked before anything lands, and the write itself is atomic.
The edge cases
- Path traversal — client filenames are reduced to a single component, so
../../etc/passwd becomes passwd.
- Hostile filenames — NUL bytes and control characters stripped,
filesystem-hostile characters removed, leading dots and trailing dots/spaces
trimmed, Windows reserved names (CON, NUL, COM1…) neutralised, and the
length capped at 255 bytes while preserving the extension.
- Double extensions —
shell.php.jpgis rejected understrict_ext; any
dotted component matching the dangerous set fails, not just the last one.
- MIME spoofing — content is sniffed by magic number, and the effective
type drives the allow/deny decision rather than the client's claim. Set
mime_must_match to require the two agree.
- Symlink attacks — destinations are opened
O_NOFOLLOW. - Collisions — atomic
O_EXCLcreation with auto-unique naming, or opt-in
overwrite.
- Cleanup — temp files are reaped on error, on
discard, and on object
destruction if the file was never saved.
Usage
use FileUpload;
my $up = FileUpload->new(
dir => '/var/www/uploads',
max_size => 8 * 1024 * 1024,
allow => [qw(png jpg jpeg gif pdf)],
allow_mime => [ 'image/*', 'application/pdf' ],
rename => 'random', # sanitize | random | timestamp | sha256 | CODE
);
my $req = $up->parse; # reads STDIN + $ENV{CONTENT_TYPE}
for my $file ( $req->files ) {
my $path = eval { $file->save };
if ($@) { warn 'rejected ' . $file->client_name . ": $@"; next }
print "stored as $path\n";
}
my $comment = $req->field('comment');
Where it already runs
Loom's media manager is this parser. Loom::Upload is a vendored copy — same
code, renamed package, wired to Loom's error handling — so uploading an image in
the admin exercises every guard above. Sign in as demo / demodemo and
try to smuggle something past it.