Guide
WordPress upload blocked — legitimate user or attack? How to tell
1. Problem
A user clicks Add Media, drags a file in, and WordPress throws:
Sorry, this file type is not permitted for security reasons.
Or, on the REST endpoint:
{"code":"rest_upload_unknown_error","message":"Sorry, this file type is not permitted for security reasons.","data":{"status":500}}
If the search that brought you here was wordpress upload blocked sorry this file type is not permitted, wordpress media upload rejected, or wordpress upload php file blocked, you're looking at one of two completely different problems wearing the same error message:
- A real editor trying to upload a
.svg,.webp,.heic, or.zipyour MIME whitelist doesn't include. - An attacker — often inside a compromised account — trying to write a
.phpshell intowp-content/uploads/.
WordPress shows the same string for both. Your dashboard shows nothing. The site looks fine. And every minute you spend treating case 2 like case 1 is a minute the attacker keeps probing.
2. Impact
Case 1 is annoying — a marketer can't push the new product hero image and your support inbox lights up.
Case 2 is the difference between a healthy site and a backdoor. wp-admin/async-upload.php is the canonical entry point for arbitrary file write, and the post-exploitation playbook against WordPress is overwhelmingly: get a session, upload a PHP shell into /uploads/, request it directly, escalate. WordPress's MIME filter is the last line of defence — and that defence runs silently. A blocked PHP upload looks identical, in every default log and dashboard, to a graphic designer fumbling a .tiff.
The cost of getting it wrong:
- A stolen editor session uploading shells goes undetected for days.
- You whitelist a MIME type to "fix" the marketer's complaint and accidentally allow
.phtmlor polyglot files. - Cleanup turns into a full forensic exercise because nobody knows when the first successful write happened.
3. Why It’s Hard to Spot
WordPress was built for content. Its admin UX is designed to make the marketer's path easy, not to make the SOC's path obvious. Three structural reasons this stays silent:
- There is no admin notice for a blocked upload. The error is shown to the uploader, in their browser, then disappears. No email, no dashboard widget, no entry in Tools → Site Health.
- Blocked uploads return HTTP 200. The HTML/JSON response carries the error, but the request itself is "successful" from nginx/Apache's perspective. Standard uptime checks and 4xx/5xx alerting see nothing wrong.
- The actual write attempt never lands on disk. File-integrity monitors that watch
wp-content/uploads/see no new file, so they have nothing to flag — even though an attacker just probed the boundary.
The result: an attacker can hammer async-upload.php with hundreds of malformed extensions, mapping your MIME whitelist, and the only artefact is a slightly elevated POST count. The ops team sees green. The security team sees nothing. The attacker sees everything they need.
4. Cause
Every time WordPress rejects a file, the upload_blocked signal fires. It represents a single, well-defined kernel event: wp_check_filetype_and_ext() (or wp_handle_upload() filters) returned a non-permitted type, and the upload was aborted before the file touched disk in its final destination.
Internally the chain is:
- Browser POSTs multipart form data to
/wp-admin/async-upload.php(classic editor / Add Media) or/wp-json/wp/v2/media(block editor, REST clients). - WordPress calls
wp_handle_upload()→wp_check_filetype_and_ext(). - The function compares the file's claimed extension, the sniffed MIME, and the user role's allowed list (
get_allowed_mime_types($user)). - On mismatch, WordPress aborts and returns the "not permitted" string.
upload_blocked carries the user ID, the IP, the User-Agent, the claimed filename, the detected MIME, and the endpoint hit. That payload is what separates "Sarah from marketing dragged a HEIC" from "compromised editor session POSTing shell.php.jpg from a Tor exit".
The supporting signals make the picture complete:
auth.attempt— was this user's session preceded by failed logins or unusual geography?http.requestonPOST /wp-admin/async-upload.php— frequency, bursts, response codes.wp.state_change— did anyone recently changeupload_filetypes, install a plugin like WP Add Mime Types, or grantunfiltered_upload?
5. Solution
5.1 Diagnose (logs first)
Every diagnosis here pivots on upload_blocked and the requests that produced it.
Web server access log — find the upload POSTs themselves:
grep -E 'POST /(wp-admin/async-upload\.php|wp-json/wp/v2/media)' /var/log/nginx/access.log \
| awk '{print $1, $4, $7, $9}' \
| sort | uniq -c | sort -rn | head -50
This shows IP, time, endpoint, status. Each line corresponds to one http.request signal. A single IP making 200+ POSTs to async-upload.php in five minutes is not a marketer.
PHP error log — catch the WordPress-side rejection text:
grep -i 'file type is not permitted\|rest_upload_unknown_error\|Sorry, this file' \
/var/log/php-fpm/error.log /var/log/wordpress/debug.log
Each match here is a candidate upload_blocked event. If you have WP_DEBUG_LOG enabled, debug.log contains them; in stock production, you usually don't, which is exactly why WordPress's native logging fails this case.
Filename pattern — separate clumsy users from probes:
grep 'POST /wp-admin/async-upload\.php' /var/log/nginx/access.log \
| grep -Eoi '[a-z0-9_.-]+\.(php|phtml|phar|phps|pht|inc|cgi|asp|aspx|jsp|js|svg|html|htm)([. ]|$)' \
| sort | uniq -c | sort -rn
Filenames containing .php, .phtml, .phar, .phps, polyglot patterns like shell.php.jpg, or double extensions like image.jpg.svg are not user error. Each one produces an upload_blocked signal with a high-risk filename payload.
Correlate with auth — was the session itself suspicious?
grep -E 'wp-login\.php|xmlrpc\.php' /var/log/nginx/access.log \
| awk -v ip="$SUSPECT_IP" '$1 == ip {print}' | tail -100
Any IP that produced an upload_blocked event should be cross-checked against auth.attempt signals from the previous 24 hours. A successful login preceded by 50 failures, or a login from a country your editor has never visited, reframes the upload from "user error" to "session takeover".
Correlate with config drift:
wp option get upload_filetypes 2>/dev/null
wp user list --role=administrator,editor,author --fields=user_login,user_registered,last_login
wp plugin list --status=active --fields=name,version,update
If upload_filetypes was edited recently, or a plugin that exposes unfiltered_upload was activated, that's a wp.state_change event that should have arrived shortly before the burst of upload_blocked events. The order matters: state change first, then attempted abuse.
The diagnostic shape is now explicit:
grep "POST /wp-admin/async-upload"→ produceshttp.requestsignalsgrep "file type is not permitted"→ producesupload_blockedsignalsgrep "wp-login.php"against the same IP → producesauth.attemptsignalswp option get upload_filetypesbaseline → produceswp.state_changesignals when it drifts
If those four streams converge on the same user, IP, or session, you are not looking at a marketer.
5.2 Root Causes
(see root causes inline in 5.3 Fix)
5.3 Fix
There are three real root causes. Treat them in order — getting the order wrong is how you ship a backdoor while trying to be helpful.
1. Active upload abuse (highest priority)
Indicators: bursts of upload_blocked with .php*, .phar, or polyglot filenames; uploads from an IP with prior auth.attempt failures; uploads from a session that is geographically inconsistent with the user's history.
Fix:
- Force a logout for the affected user:
wp user session destroy.--all - Rotate the user's password and revoke any application passwords:
wp user application-password delete.--all - If the account belongs to someone you can't reach immediately, demote:
wp user set-role.subscriber - Block the source IP at the WAF/edge for at least 24 hours.
- Audit
wp-content/uploads/for files written in the same window. Even one successful write is a backdoor:find wp-content/uploads -type f -newermt '2 hours ago' \( -name '.php' -o -name '.phar' -o -name '.phtml' \).
This cause maps to: upload_blocked (high-risk filename) + correlated auth.attempt failures + sometimes a preceding wp.state_change granting elevated privileges.
2. Legitimate file type genuinely missing from the whitelist
Indicators: a single user, normal session, normal IP, low frequency, filename is something media-shaped — .heic, .webp, .svg, .avif, .zip, .epub, .json.
Fix:
- Decide whether the type is actually safe.
.svgis not safe by default — it can carry script..zipis fine if the site doesn't extract it..heicis benign. - Add the MIME via filter, not via Tools → File Editor and not via "WP Add Mime Types"-style plugins (those tend to allow far more than asked):
add_filter('upload_mimes', function ($mimes) {
$mimes['heic'] = 'image/heic';
$mimes['webp'] = 'image/webp';
return $mimes;
});
- For SVG specifically, route uploads through a sanitiser (e.g.
enshrined/svg-sanitize) before allowing the type.
This cause maps to: a small, low-cardinality cluster of upload_blocked events on the same MIME, no correlated auth.attempt anomalies, no wp.state_change precursor.
3. Misconfigured or recently-changed upload_filetypes / role permissions
Indicators: a sudden change in upload_blocked rate (up or down), a recent wp.state_change event modifying upload_filetypes, a new plugin install, or a user role gaining unfiltered_upload.
Fix:
- Diff current
upload_filetypesagainst a known-good baseline. - Audit which roles have
unfiltered_upload— by default no role on multisite has it, and only Super Admin / Admin on single site. If editors or authors have it, revoke immediately:
$role = get_role('editor');
$role->remove_cap('unfiltered_upload');
- Review the plugin that introduced the change. If it was a third-party MIME plugin, replace it with the explicit
upload_mimesfilter above so the change is in code review, not in the database.
This cause maps to: wp.state_change (config drift) → followed by either a drop in upload_blocked (whitelist widened) or a spike (new attack surface tested).
5.4 Verify
Verification is not "it works on my machine" — it's the absence of the signal under realistic load.
After you've applied the fix, monitor for a window proportional to your traffic (30 minutes for high-traffic sites, 24 hours for low-traffic):
- For case 1 (active abuse): no new
upload_blockedevents from the suspect IP, user, or session. No new files inwp-content/uploads/matching the executable patterns. No newauth.attemptfailures from the same IP. - For case 2 (legit type added): the specific user retries, the upload succeeds, and no new
upload_blockedfor that MIME appears for that role. - For case 3 (config drift):
upload_filetypesand per-role capabilities match the baseline;wp.state_changeevents on those keys stop appearing.
Concrete checks:
# No new blocks from the suspect IP in last 30 min
grep "$SUSPECT_IP" /var/log/nginx/access.log \
| grep 'POST /wp-admin/async-upload' \
| awk -v cutoff="$(date -d '30 min ago' '+%d/%b/%Y:%H:%M')" '$4 > "["cutoff'
# No executable artefacts written
find wp-content/uploads -type f -newermt '1 hour ago' \
\( -name '*.php*' -o -name '*.phar' -o -name '*.phtml' -o -name '*.htaccess' \)
# Baseline restored
wp option get upload_filetypes
Healthy looks like: upload_blocked rate returns to background noise (a handful per day on a typical content site), no high-risk filenames in the payload, and no clustering by IP, user, or 1-minute window.
6. How to Catch This Early
Fixing it is straightforward once you know the cause. The hard part is knowing it happened at all.
This issue surfaces as upload_blocked.
The real failure mode here isn't the upload being blocked. WordPress did its job — it blocked the file. The failure is that nothing told you it happened.
By default, WordPress does not alert on blocked uploads. There is no email, no admin notice, no log line in any place an operator routinely looks. The information is in PHP error logs only if WP_DEBUG_LOG is on, in nginx access logs only as a 200 response, and in the user's browser only for as long as they have the tab open. A live attacker probing your MIME filter is, by default, perfectly invisible.
This is why upload_blocked exists as a first-class signal in Logystera. Every blocked upload — across async-upload.php, the REST media endpoint, and any plugin that calls wp_handle_upload() — is captured with full context: user, IP, MIME, filename, endpoint. Logystera correlates upload_blocked with auth.attempt, http.request, and wp.state_change so the four-stream picture from section 5 isn't a manual grep exercise — it's a single timeline. A burst of blocked .php uploads from a session that just survived 40 failed logins doesn't need a human to triage; it's the literal pattern the rule fires on.
You can keep grepping. The commands in section 5 are real and they work. But they only work if you know to look, and the structural problem with this failure mode is that nothing tells you to look.
7. Related Silent Failures
upload_blocked lives in a tight cluster of WordPress security signals that share the same shape — silent rejection, no admin surface, high consequence:
auth.attemptbursts onwp-login.php/xmlrpc.php— the precursor to most upload abuse. Compromised session is the precondition for case 1.wp.state_changeonupload_filetypesor role capabilities — when the MIME whitelist orunfiltered_uploadcapability changes silently, often via a plugin update.- REST API enumeration on
/wp-json/wp/v2/users— attacker maps editor accounts before targeting their sessions. - **
http.request404 storms on/wp-content/uploads/*.php** — an attacker probing for a previously-uploaded shell. If you see these, treat it as confirmation that an earlier upload succeeded. - Plugin silent activation (
wp.state_change type=plugin_activated) — a plugin that exposesunfiltered_uploador weakens MIME filtering is often the precursor to a sustained upload campaign.
Treat this guide as one node in that graph. The same diagnostic shape — primary signal, supporting signals, correlation window, baseline drift — applies to every item on the list.
See what's actually happening in your WordPress system
Connect your site. Logystera starts monitoring within minutes.