Segurium Research Import and export users and customers
Import and export users and customers 2.4.14 blocks SSRF in CSV URL imports
- Plugin
- Import and export users and customers
import-users-from-csv-with-meta - Affected versions
- 2.4.13 and earlier
- Fixed in
2.4.14- Class
- Server-side request forgery
- Severity
- medium Segurium Research assessment. No CVSS score published yet.
- CVE
- None assigned at the time of writing
- Installs
- 70,000 active
- Patch released
- Sources
Codection shipped version 2.4.14 of Import and export users and customers on 2026-09-02. The vendor labels the change a hardening fix. The release notes say the plugin now blocks link-local and carrier-grade NAT hosts, and re-checks every redirect hop.
The diff agrees. Version 2.4.13 passed the import URL straight to WordPress core’s download_url(). Version 2.4.14 replaces that call with fetch_remote_csv_safely(). The new function checks the host on the first URL, then checks it again on every redirect the remote server sends.
About 70,000 sites run this plugin. The code only runs when somebody imports a CSV from a URL. Sites that always upload the file from a computer never reach it.
What the release fixes
Version 2.4.13 let a remote server steer the import. The plugin fetched the URL an admin typed, and core’s download helper followed redirects for it. A server that answered with a redirect could send the fetch to an address inside the network, such as a cloud metadata endpoint. Version 2.4.14 does the fetch itself, one hop at a time, and validates the host before each request.
- Affected versions: 2.4.13 and earlier
- Fixed in: 2.4.14
- Class: ssrf
- Installs: ~70,000 sites run Import and export users and customers
How the bug works
The URL enters through two screens. Both use a field named path_to_file.
The manual import screen renders the field in classes/homepage.php, inside the introduce_path block. The same screen offers a file upload as the other option, so the URL branch is a choice the operator makes.
<input placeholder="..." type="text" name="path_to_file" id="path_to_file"
value="<?php echo $settings->get( 'path_to_file' ); ?>" style="width:70%;" />
The Cron tab stores the same field as an option. classes/cron.php writes it on save:
- update_option( "acui_cron_path_to_file", $this->clean_path_url_csv( sanitize_text_field( $form_data["path_to_file"] ) ) );
+ $path_to_file = $this->clean_path_url_csv( sanitize_text_field( $form_data["path_to_file"] ) );
+ update_option( "acui_cron_path_to_file", $path_to_file );
sanitize_text_field() strips tags and control characters. The sanitiser does not care which host a URL points at. The option acui_cron_path_to_file then holds that URL, and the scheduled import reads it later with nobody watching the screen.
The value reaches classes/import.php. This is the whole change at the call site:
- $path_to_file = download_url( $path_to_file );
+ $path_to_file = $this->fetch_remote_csv_safely( $path_to_file );
if( is_wp_error( $path_to_file ) ){
echo "<p>" . sprintf( __( 'Error, problems downloading the file from the URL: %s', 'import-users-from-csv-with-meta' ), $path_to_file->get_error_message() ) . "</p>";
The return value is a path to a temporary file on the server. The import continues with that path and parses the file as CSV. So the body of the HTTP response becomes import data.
The diff does not name the function that holds this call. The hunk header carries no function context, and the lines above the call show only a return false branch without its condition. The route from the form submit to this line is therefore not established by the diff alone.
The vendor states the redirect problem in a code comment added by the same patch:
// Same SSRF hardening pattern used for the bp_avatar remote fetch (addons/buddypress.php):
// reject private/loopback/link-local/CGNAT hosts, and re-validate on every redirect hop
// instead of trusting WP core's download_url(), which follows redirects unchecked.
The new host check is is_safe_remote_csv_url():
function is_safe_remote_csv_url( $url ){
if( wp_http_validate_url( $url ) === false )
return false;
$host = wp_parse_url( $url, PHP_URL_HOST );
if( empty( $host ) )
return false;
$ip = filter_var( $host, FILTER_VALIDATE_IP ) ? $host : gethostbyname( $host );
if( !filter_var( $ip, FILTER_VALIDATE_IP ) )
return false;
$blocked_ranges = array(
// Ten CIDR literals, elided here: loopback, the three private
// IPv4 blocks, link-local, and two carrier-grade NAT and
// benchmarking blocks, plus the IPv6 loopback, unique-local
// and link-local equivalents.
);
foreach( $blocked_ranges as $range ){
if( $this->ip_in_range( $ip, $range ) )
return false;
}
return true;
}
Note what the first line does. The function calls core’s wp_http_validate_url(), and then still resolves the host and checks it against ten ranges. The vendor treats core’s own validation as not enough for this fetch.
Three of those ranges are the new ones. The changelog says which:
Security fix (hardening): importing a CSV from a remote URL (manual import and Cron tab) now blocks link-local ( 169.254.0.0/16 , including the cloud metadata endpoint 169.254.169.254 ) and carrier-grade NAT ( 100.64.0.0/10 , 198.18.0.0/15 ) hosts
ip_in_range() does the CIDR match by bytes:
function ip_in_range( $ip, $range ){
list( $subnet, $bits ) = explode( '/', $range );
$ip_bin = inet_pton( $ip );
$subnet_bin = inet_pton( $subnet );
if( $ip_bin === false || $subnet_bin === false || strlen( $ip_bin ) !== strlen( $subnet_bin ) )
return false;
$bits = (int) $bits;
$bytes = intdiv( $bits, 8 );
$remainder_bits = $bits % 8;
if( $bytes > 0 && substr( $ip_bin, 0, $bytes ) !== substr( $subnet_bin, 0, $bytes ) )
return false;
if( $remainder_bits === 0 )
return true;
$mask = chr( ( 0xFF << ( 8 - $remainder_bits ) ) & 0xFF );
return ( $ip_bin[ $bytes ] & $mask ) === ( $subnet_bin[ $bytes ] & $mask );
}
The function compares whole bytes with substr(), then masks the leftover bits. Take 100.64.0.0/10. $bytes is 1 and $remainder_bits is 2, so the mask byte is 0xC0. The subnet’s second byte 64 masks to 0x40. Address 100.127.0.1 masks to 0x40 and gets blocked. Address 100.128.0.1 masks to 0x80 and passes. The length check on inet_pton() output stops an IPv4 address from being compared against an IPv6 range.
fetch_remote_csv_safely() is the replacement for download_url():
function fetch_remote_csv_safely( $url, $max_redirects = 3 ){
if( !function_exists( 'wp_tempnam' ) )
require_once ABSPATH . 'wp-admin/includes/file.php';
for( $i = 0; $i <= $max_redirects; $i++ ){
if( !$this->is_safe_remote_csv_url( $url ) )
return new WP_Error( 'acui_unsafe_url', __( 'The URL points to a host that is not allowed (private, loopback, link-local or carrier-grade NAT address).', 'import-users-from-csv-with-meta' ) );
$tmpfname = wp_tempnam( $url );
$response = wp_safe_remote_get( $url, array(
'timeout' => 300,
'redirection' => 0,
'stream' => true,
'filename' => $tmpfname,
) );
'redirection' => 0 is the important argument. WordPress does not follow anything now. The plugin reads the status code and handles the hop itself:
if( in_array( $code, array( 301, 302, 303, 307, 308 ), true ) ){
@unlink( $tmpfname );
$location = wp_remote_retrieve_header( $response, 'location' );
if( empty( $location ) )
return new WP_Error( 'acui_bad_redirect', __( 'The server returned a redirect without a valid destination.', 'import-users-from-csv-with-meta' ) );
$url = WP_Http::make_absolute_url( $location, $url );
continue;
}
continue sends the new URL back to the top of the loop, where is_safe_remote_csv_url() runs again. WP_Http::make_absolute_url() resolves a relative Location value against the current URL, so a relative redirect cannot skip the check either. The loop allows up to 4 requests, then returns acui_too_many_redirects. Any status other than 200 or a redirect returns acui_download_failed with the code.
So what did an attacker get on 2.4.13? The site owner types a URL. The owner controls the first host, and often it belongs to a partner or a data provider. That host answers with a redirect. The plugin followed it and fetched whatever the new address served. On a cloud instance, 169.254.169.254 serves instance metadata, which on several providers includes role credentials. On a normal LAN, the private ranges hold admin panels and internal APIs. The response body was written to a temp file and parsed as user import data, so the content reached the import, not just the network.
The fix has one limit worth knowing. is_safe_remote_csv_url() resolves the host with gethostbyname(), and then wp_safe_remote_get() resolves the same name again for the real request. A name that answers with a different address on the second lookup is not covered by this check. That is DNS rebinding, and the diff does not address it. The redirect hole is closed. The name resolution race is not.
Who is exposed
Version 2.4.13 and every earlier release with the URL import. The vendor says the same class was already fixed in 2.4.3 for the BuddyPress and BuddyBoss bp_avatar fetch, and that the CSV by URL path never got the same treatment.
The import screen and the Cron tab need a logged in user. The capability is create_users, filtered through acui_capability. The plugin checks it in the cron save code and in the new AJAX handler:
if( !current_user_can( apply_filters( 'acui_capability', 'create_users' ) ) )
wp_die( -1 );
create_users belongs to administrators by default. The diff shows no unauthenticated route to the fetch. A site that lowers acui_capability with a filter widens who can trigger it.
The exposure needs three things on a site. Somebody uses the remote URL option instead of the file upload. The remote host is one the site owner does not fully control, or the owner is tricked into typing another host. The server sits somewhere with an interesting internal address, such as a cloud instance with a metadata endpoint or a network with private services.
The Cron tab widens the window. acui_cron_path_to_file holds one URL and the import runs on a schedule. A host that behaves normally today can answer with a redirect next week. Nobody is looking at the screen when that happens.
A site that only ever uploads the CSV from a computer never reaches the fetch. Check the stored cron option anyway, because it may still hold an old URL.
The release also touches the local path check. is_allowed_local_csv() now walks a filtered list of base folders instead of the uploads folder alone:
+ $allowed_base_dirs = apply_filters( 'acui_allowed_local_csv_base_dirs', array( $upload_dir['basedir'] ), $path_to_file );
The default stays the uploads folder. A developer on the site can add folders with the acui_allowed_local_csv_base_dirs filter. That widens the local read allowed by the import, so treat any code using that filter as a security decision.
One more small change is the new AJAX action acui_check_local_path, registered in hooks():
+ add_action( 'wp_ajax_acui_check_local_path', array( $this, 'ajax_check_local_path' ) );
It runs check_ajax_referer( 'codection-security', 'security' ) and the create_users capability check, then returns the result of get_local_path_status(). That result reports whether a file exists at a given path on the server. The same role can already run imports, so the extra information is small.
What to do
Update to 2.4.14. That is the whole fix for the redirect problem.
Then open the Cron tab and read the value in the Path or URL field. Confirm the host is one you own or trust. If the option holds a URL you no longer recognise, remove it before the next scheduled run.
If your site runs on a cloud instance and it imported from a URL you do not control, rotate the credentials that instance can reach. Assume the metadata endpoint was readable through the old fetch until you can prove otherwise from logs.
Check your outbound request logs, or the host firewall log, around your import times. Look for connections from the web server to 169.254.169.254 or to addresses in your private ranges. Version 2.4.13 gave no on-screen sign that a redirect moved the fetch elsewhere.
Review accounts created near those import times. The plugin marks accounts it creates through mark_user_as_imported(), hooked on acui_post_import_single_user, so imported users are identifiable in your site data.
Version 2.4.14 does the download in its own loop. It validates the first URL and every redirect target against ten CIDR ranges, refuses private, loopback, link-local and CGNAT addresses, and stops after 4 requests. It also adds a Check path button and a warning notice on the Cron settings, so a bad path shows up when you save it rather than at the next scheduled run.