Skip to main content

Upload guard

An upload says what it is three times: in its file name, in the Content-Type of its part, and in its bytes. Only the bytes are true. A web shell named avatar.php.jpg, an executable declared image/png, a GIF that is also valid PHP, a zip that expands to a terabyte or carries an entry named ../../etc/cron.d/x: none of these is caught by checking a content type.

The Upload guard (CloudApimUploadGuard) reads every file of a multipart/form-data body as it streams to the backend, and refuses what it finds disguised. Form fields are left to the WAF.

What it checks​

CheckReasonStatus
The last extension is denied (.php, .jsp, .aspx, .exe, .sh, .htaccess...)denied_extension415
A denied extension hidden before the last one: shell.php.jpgdouble_extension415
A NUL byte in the name: shell.php\0.jpgnull_byte400
The last extension is not in the allowed list, when there is oneextension_not_allowed415
The content is of a denied kind (by default executables, server scripts and HTML)denied_type415
The content is not of an allowed kind, when there is a listtype_not_allowed415
The content is of another family than its name or declared type say: a PDF named .jpgtype_mismatch415
An image, document or media file that also carries PHP, JSP or a script tagpolyglot415
An archive entry escaping its directory: ../, an absolute path, a drive letterzip_slip400
The archives expand past their size or ratio budgetarchive_bomb413
Archives nested deeper than allowedarchive_depth413
More archive entries than allowedarchive_entries413
An archive nothing here can read: encrypted, an unknown compression method, 7z, rar, xz, zstd, bzip2unreadable_archive415
Too many files, or a file too largetoo_many_files, file_too_large413
A body that is not valid multipartmalformed_multipart400
The malware scanner found somethingmalware403
The malware scanner could not scan a file, and the route refuses thatscan_failed503

Names​

A name is read the way the server storing it will read it: a path is reduced to its last segment, trailing dots and spaces are dropped as Windows drops them, a name stops at an NTFS alternate data stream (shell.php::$DATA), at a NUL byte, and an extension stops at a semicolon as IIS 6 stops it (shell.asp;.jpg). Every extension of the name counts, not only the last: Apache runs shell.php.jpg as PHP whenever a handler is mapped by extension. An RFC 8187 name (filename*=UTF-8''...) is decoded before it is judged.

Content​

The first 64 KB of each file say what it is: PNG, JPEG, GIF, WebP, BMP, TIFF, ICO, HEIC and AVIF; PDF and legacy Office documents; zip (and everything built on it: docx, xlsx, odt, jar, apk), gzip, tar, 7z, rar, xz, zstd and bzip2; MP4, WebM, Ogg, FLAC, WAV and AVI; ELF, PE, Mach-O, Java class, DEX and WebAssembly executables; PHP, JSP and ASP code and shebang scripts; HTML, and SVG, which is an image unless it carries a script, an event handler or a javascript: link, in which case it is HTML. Anything else is text or binary.

Kinds: image, document, archive, media, executable, script, html, text, binary.

A mismatch compares families, not formats. A PNG named .jpg is a mislabelled image, which every image library copes with; a PDF named .jpg is pretending. A file nothing recognises (binary) contradicts nothing.

Archives​

A zip is read from its local headers as it streams, and every entry is actually inflated: the sizes a header declares can lie, what an entry inflates to cannot. An entry that is itself a zip is read the same way, one level deeper. The budget is the whole request's: every archive of the upload counts against the same expanded size and ratio, so two hundred archives just under the limit are as much of a bomb as one over it. A gzip file is decoded and counted the same way.

Entry names are always checked for zip slip. Holding them to the denied extensions as well is a switch (check_archive_entry_extensions), off by default: a project archive full of .sh and .php files is ordinary on a code-hosting API.

Malware scanning​

The checks above find files pretending to be something else. An antivirus finds files that are exactly what they claim and still malicious, and a procurement checklist usually asks for one. A malware scanner (MalwareScanner) is an antivirus the guard hands every uploaded file to:

kindProtocolPort
clamdClamAV's INSTREAM, on its TCP socket3310
icapICAP (RFC 3507), RESPMOD or REQMOD, at service: what most enterprise antivirus products expose1344

Each file is spooled to a temporary file as it streams by, and handed to the scanner once it is whole; the file is deleted once the scanner has answered. A file larger than the scanner's max_file_size (25 MiB by default, clamd's own StreamMaxLength) is not sent.

The scanner answers once a file is whole, so the upload waits for it. Within the first body_limit bytes, the answer comes before anything is forwarded, and malware is refused with a 403. Past them, the upload streams to the backend as usual but its last chunk is held until every file has been scanned: a clean upload then completes, an infected one is cut, and the backend never receives a complete upload the scanner has not cleared.

A scan that cannot be made (the scanner down, timing out, a file too large for it) is decided by the route: scan_failure_action: "reject", the default, refuses the upload with a 503; "allow" lets it through unscanned. The caller is only told the file could not be scanned; why, and the scanner's address, are in the event.

Test with EICAR, from the scanner's form in the backoffice or its row in Threat Studio, sends the EICAR test file, which must come back infected, and a plain file, which must come back clean. A scanner that answers "clean" to EICAR is reachable and useless, which checking the connection alone would miss.

{
"name": "ClamAV",
"kind": "clamd",
"host": "clamav.internal",
"port": 3310,
"timeout_millis": 30000,
"max_file_size": 26214400
}

Before and after the head​

The guard reads the first body_limit bytes (1 MiB) before anything is forwarded. A file refused within them gets its status and a JSON body, and the backend never sees the request:

{ "error": "upload_refused", "reason": "polyglot", "detail": "gif content that also carries code", "reference": "1843627815893483520" }

Past them, the upload is already on its way. A file refused there cuts the upload before the chunk that revealed it is forwarded: the backend gets a broken body rather than the file, and the client an error. Memory stays bounded whatever is uploaded: what could still be the start of a boundary, the head of the current file, and the archive entry being inflated.

Play decodes gzip and deflate request bodies before any plugin. A br body is decoded by the guard; a body in an encoding it cannot read is refused (undecodable_body, 415).

Configuration​

{
"mode": "enforce",
"allowed_extensions": [],
"denied_extensions": ["php", "phtml", "phar", "jsp", "asp", "aspx", "exe", "sh", "htaccess"],
"allowed_types": [],
"denied_types": ["executable", "script", "html"],
"check_mismatch": true,
"max_files": 100,
"max_file_size": 0,
"archive_max_depth": 3,
"archive_max_entries": 10000,
"archive_max_expanded_size": 268435456,
"archive_max_ratio": 100,
"unreadable_archive_action": "reject",
"check_archive_entry_extensions": false,
"body_limit": 1048576,
"scanner": null,
"scan_failure_action": "reject"
}
FieldDefaultMeaning
modeenforcemonitor reports what would be refused and lets every upload through
allowed_extensions[]When not empty, the only extensions accepted, without the dot
denied_extensions46 extensionsNever accepted, last or hidden before the last. The default list covers what web servers run and desktops execute
allowed_types[]When not empty, the only kinds of content accepted
denied_typesexecutable, script, htmlNever accepted, whatever the file is called
check_mismatchtrueRefuse a file whose content contradicts its name or declared type
max_files · max_file_size100 · 0Files per request and bytes per file. 0 for no limit
archive_max_depth3Archives inside archives, the uploaded one counting as 1: a zip carrying a war carrying jars
archive_max_entries10000Entries across every archive of the request
archive_max_expanded_size256 MiBBytes every archive of the request may expand to
archive_max_ratio100How many times the archives may expand, judged past 1 MiB
unreadable_archive_actionrejectallow lets through what cannot be looked into
check_archive_entry_extensionsfalseHold archive entry names to the denied extensions too
body_limit1 MiBBytes read before the upload is forwarded
scanner(empty)The id of a malware scanner. Empty means no malware scan
scan_failure_actionrejectA scan that cannot be made refuses the upload (503), or allow lets it through unscanned
Name the files a route takes

Most routes that take uploads take one or two kinds of file. allowed_extensions: ["png", "jpg"] and allowed_types: ["image"] refuse everything else, which is a far shorter list to get right than everything dangerous.

Laying it down​

On a route, add the plugin, or switch uploads on in the preset or in a global preset rule, with uploads_mode and uploads_allowed_extensions, and uploads_scanner and uploads_scan_failure_action for the malware scan. It runs after the WAF and before the threat response. In Threat Studio, it is the Upload guard section of a workspace's protection, with its allowed extensions and its malware scanner; scanners themselves are on the Malware scanners page. It is off by default: a route that takes no upload has nothing for it to judge.

What it reports​

Each refusal is a CloudApimSecurityEvent of category upload, action deny (outcome blocked) or log in monitor mode (outcome observed). Its signal names the reason, the field, the file name, what was seen, whether the upload was cut on its way, and the files read so far with their declared type, detected kind and size; its tag is upload:<reason>. A refusal found in the head is also a signal on the threat bus, so in monitor mode the threat response can still act on it, and when enforced it charges the caller's ledger: an image carrying PHP is not an accident.