Allowing SSI to download images from private or loopback hosts

June 5, 2026

By default, SSI refuses to download images from URLs whose hostname resolves to a private, loopback, link-local, multicast, reserved, or cloud-metadata IP address. This is the SSRF gate — a security guard added in 2.x that blocks Server-Side Request Forgery attacks where a hostile CSV row tells your WordPress to fetch (and expose) data from your internal network or cloud-metadata endpoints.

For most production sites that’s exactly what you want. But there are legitimate cases where you need SSI to fetch images from a private host. The ssi_allow_private_image_hosts filter (added in 2.54.3) is your opt-in escape hatch.

When you need this

You need to enable the filter if your image URLs resolve to a non-public IP. The three common cases:

  • Benchmark or staging setups where the image library lives on the same box as WordPress (so URLs like https://yoursite.example/wp-content/uploads/images/… resolve to 127.0.0.1 because of an /etc/hosts entry).
  • LAN image server behind your firewall — e.g. http://images.local/products/… resolving to 192.168.x.x.
  • Docker / Kubernetes deployments where the image source runs in a sibling container reachable only at an internal IP.

If your images come from a normal public-internet URL — Cloudinary, S3, your supplier’s CDN, a real subdomain pointing at a public IP — you don’t need this filter and shouldn’t enable it. Leave the gate doing its job.

Symptom — what failure looks like

Every image fails with a “Refusing to fetch image” error in the import log, and the upload-remote-images stage reports remote_failed_images = <total> with remote_uploaded_images = 0. The stage completes very quickly (milliseconds per image) because the gate rejects each URL without actually attempting the download.

If you see thousands of “uploaded 0, failed N” in 2-3 seconds per thousand images, the SSRF gate is almost certainly the cause. Run wp eval 'echo gethostbyname("your-image-host.example");' on the server — if it returns a 127.x.x.x or 192.168.x.x or 10.x.x.x or 172.16-31.x.x address, the gate is doing its job and you need the filter.

Recommended: a must-use plugin

The right place to register this filter is a must-use plugin (mu-plugin). Mu-plugins:

  • Load on every WordPress request — admin, frontend, REST, WP-CLI, AJAX, cron.
  • Load before regular plugins, so the filter is registered by the time SSI needs it.
  • Survive theme switches, plugin upgrades, and wp plugin deactivate --all.
  • Aren’t overwritten when you update SSI itself.

Critically for benchmarks: SSI’s parallel-worker subprocesses re-bootstrap WordPress fully, so any filter registered in a mu-plugin fires in every worker without extra plumbing. Theme functions.php works too (themes do load in WP-CLI by default), but mu-plugins are the safer bet.

Create the mu-plugin

  1. SSH to your server. Make the mu-plugins directory if it doesn’t already exist: bash mkdir -p /path/to/wp-content/mu-plugins
  2. Create the file /path/to/wp-content/mu-plugins/ssi-allow-private-image-hosts.php:
<?php
/**
 * Plugin Name: SSI — Allow Private Image Hosts
 * Description: Opt-in bypass of SSI's SSRF gate for specific image hosts on the
 *              local network or loopback. Required when image URLs resolve to
 *              private IPs (benchmark / staging / LAN image-server scenarios).
 * Version:     1.0
 */

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

add_filter( 'ssi_allow_private_image_hosts', function ( $allow, $url, $ip, $host ) {

    // Whitelist of hostnames we permit SSI to fetch from even when they
    // resolve to private / loopback IPs. Add YOUR hostnames here.
    $allowed_hosts = array(
        'tests.dev.superspeedyplugins.com',  // benchmark image library
        // 'images.local',
        // 'staging.example.com',
    );

    if ( in_array( strtolower( $host ), $allowed_hosts, true ) ) {
        return true;   // bypass the gate for this host
    }

    return $allow;   // default false — keep the gate active for everything else
}, 10, 4 );
  1. No activation needed — mu-plugins activate themselves the moment the file exists. Verify it loaded: bash wp plugin list --status=must-use --fields=name,description You should see “SSI — Allow Private Image Hosts” in the list.

  2. Re-run your import. The “Refusing to fetch image” errors should be gone and remote_uploaded_images will start counting up properly.

Adapting the whitelist

The filter receives ($allow, $url, $ip, $host):

  • $allow — what previous filters / the default decided. Always pass it through when you don’t want to override.
  • $url — full URL being fetched (handy for matching by URL substring).
  • $ip — the resolved IP that triggered the gate.
  • $host — the original hostname from the URL, before DNS resolution.

So you can be as broad or as narrow as you need:

// Match on substring of the hostname
if ( strpos( $host, '.internal' ) !== false ) return true;

// Match on a specific IP range — useful for LAN setups
if ( strpos( $ip, '192.168.1.' ) === 0 ) return true;

// Match on a URL path prefix (be careful — attacker-controlled URLs could
// game this if combined with an SSRF redirect chain; prefer host matching)
if ( strpos( $url, 'http://192.168.1.50/products/' ) === 0 ) return true;

Always prefer matching by host over matching by URL path — the hostname is what the DNS resolver and the gate already see, and an attacker cannot redirect to a different hostname without the gate re-evaluating.

Alternative: theme functions.php

You can drop the same add_filter(...) call into your active theme’s functions.php. It works, but it has three downsides:

  • Survives only as long as the theme is active. Switch themes and the filter disappears, silently breaking imports.
  • Overwritten by parent-theme updates unless you’re using a child theme.
  • Doesn’t load on every request type in all hosting setups. WP-CLI normally loads themes, but wp <cmd> --skip-themes skips them, and some hardened hosts skip themes on REST requests. Mu-plugins don’t have these gaps.

If you do use functions.php, put it in a child theme, not in the parent (otherwise the next theme update wipes it).

Security disclaimer

The SSRF gate exists for a real reason. By bypassing it for a host, you accept these risks for that host:

  1. A malicious or compromised image source can probe your internal network. If 192.168.1.50 is on your whitelist and someone uploads a CSV with image URLs like http://192.168.1.50:6379/info (Redis), http://192.168.1.50:11211/stats (memcached), or http://192.168.1.50:5601/api/status (Kibana), SSI will dutifully fetch them. SSI’s MIME sniff will reject non-images, but the request still hits the target — enough to enumerate which internal services are reachable.

  2. Cloud-metadata endpoints become accessible if you whitelist 169.254.169.254 or its hostname. Don’t. AWS / GCP / Azure metadata APIs expose IAM credentials and machine identity — assume any whitelist that reaches them is equivalent to leaking your cloud root keys.

  3. The whitelist applies to ALL imports, including imports configured by other admins. If you have multiple users with import-create permission, a whitelist entry is implicit trust that none of them will configure an import pointing at a sensitive internal URL.

Mitigations to apply on top:

  • Whitelist by exact hostname, never by IP range or wildcard, unless you’re certain about what’s behind the range.
  • Whitelist the minimum needed. Comment out entries you’re not actively using — don’t leave a staging host in production.
  • Audit the whitelist before deploying to production. A tests.example.com entry that made sense on staging should be removed before the mu-plugin ships to live.
  • Restrict the CSV source. If the CSV is uploaded by untrusted users, the bypass is far riskier than if only you write the CSVs.

For a production import from a third-party CSV feed, leave the gate active unless you’ve explicitly mapped out which hosts the feed legitimately needs to reach.

Testing the filter is doing its job

A 2-line sanity check confirms the filter is registered and SSI sees it:

wp eval '
echo "Filter callbacks for ssi_allow_private_image_hosts:\n";
print_r( $GLOBALS["wp_filter"]["ssi_allow_private_image_hosts"] ?? "(none registered)" );
'

You should see one entry pointing at the closure in your mu-plugin. If it says (none registered), the mu-plugin didn’t load — check the filename has .php, lives directly in wp-content/mu-plugins/ (not in a subdirectory — those don’t auto-load), and has no PHP syntax errors (php -l <file>).

Once registered, run a single-image test import or check the next failed-image entry in wp-content/debug.log. The “Refusing to fetch image — host resolves to a private…” message should no longer appear for whitelisted hosts.

What this filter does NOT bypass

The filter only relaxes the private-IP rejection part of the SSRF gate. These checks still apply unconditionally:

  • URL scheme — only http:// and https:// are accepted; file://, gopher://, ftp://, dict:// and so on are rejected.
  • DNS resolution — the host must resolve. If DNS returns nothing, the URL is rejected regardless of the filter.
  • HTTP status check — the response must be HTTP 200 (after redirects). A 4xx or 5xx response causes a download error.
  • MIME sniff — the downloaded bytes must be a recognised image MIME type. A whitelisted host returning JSON or HTML still gets its content thrown away.
  • Redirect protocol pinning — the gate forbids redirects to non-HTTP(S) schemes even from whitelisted hosts.

So the filter widens which IPs are reachable, not what can be downloaded from them. If you legitimately need SVG support or some other custom MIME, use ssi_allowed_downloaded_image_mimes — different filter, different concern.

Reverting

Delete or rename the mu-plugin file. The next request picks up the change immediately — no cache clear, no service restart.

rm /path/to/wp-content/mu-plugins/ssi-allow-private-image-hosts.php

Confirm with wp plugin list --status=must-use — the entry should be gone. The gate is now back to default secure-by-default behaviour.

×
1/1