CoolCMS API

ImportImageDownloader
in package

FinalYes

SSRF-safe image download for the import; the only place where the import reaches out to the network.

Defense in depth:

  • only the http(s) scheme (also after a redirect);
  • the hostname is resolved manually (gethostbynamel/dns_get_record) and the resulting IP is checked against private, loopback, link-local and reserved ranges (IPv4 and IPv6) before the connection is opened;
  • cURL is then pinned to that same verified IP (CURLOPT_RESOLVE), which prevents DNS rebinding between the check and the connection (race); TLS server name and certificate verification still run against the hostname, not the IP;
  • redirects are not followed by cURL itself (CURLOPT_FOLLOWLOCATION = false): every target (max MAX_REDIRECTS) goes through the same check again, not only the first;
  • the size limit is enforced while streaming (CURLOPT_WRITEFUNCTION aborts the transfer on overflow), not after the whole file is downloaded;
  • the image type is verified by real content detection (getimagesize); the Content-Type header is informative only (it can be forged).

$allowedTestHosts is always empty in production (see config/Shared/eshopProductImport.neon); only a test overlay puts the host or IP of the local fixture server there (127.0.0.1), so the positive download can be tested without weakening the default private-range blocking for anyone else.

Always returns ImportImageDownloadResult and never throws: the caller (writer) logs the error as soft, and an unreachable image must not fail the whole item.

Table of Contents

Constants

ALLOWED_MIME_TO_EXTENSION  : array<string|int, mixed> = ['image/jpeg' => 'jpg', 'image/png' => 'png', '...
MAX_REDIRECTS  : int = 3

Properties

$allowedTestHosts  : array<int, string>
Tests only: host/IP allowlist (case-insensitive), see the class docblock. An empty array (production default) means no exception, blocking is by IP range only.
$connectTimeoutSeconds  : int
$maxBytes  : int
10 MB.
$totalTimeoutSeconds  : int

Methods

__construct()  : mixed
download()  : ImportImageDownloadResult
hasUserInfo()  : bool
The URL carries `user[:pass]@` in its authority. The import must not need HTTP Basic credentials embedded in the document, so such an address is rejected (validator and downloader) and the credentials never reach the log or metadata.
stripUserInfo()  : string
Diagnostic form of the URL (log, `importSource.url`): authority without `user[:pass]@`, the rest unchanged.
fetchOnce()  : array{success: bool, error: string, redirectTo: ?string, tempPath: ?string, contentType: string}
finalizeDownload()  : ImportImageDownloadResult
isTargetAllowed()  : bool
The allowlist (tests only) bypasses the range check only for the explicitly listed host or IP, not globally. Otherwise a public IP is allowed and private, loopback, link-local and reserved (IPv4 and IPv6) are blocked (`filter_var` with both `FILTER_FLAG_NO_*` flags).
resolveFirstIp()  : string|null
IPv4 first (`gethostbynamel`), IPv6 fallback (`dns_get_record` aaaa record).
resolveTarget()  : array{host: string, ip: string, port: int}|null

Constants

ALLOWED_MIME_TO_EXTENSION

private array<string|int, mixed> ALLOWED_MIME_TO_EXTENSION = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/webp' => 'webp']

detected MIME => extension of the stored file

Properties

$allowedTestHosts read-only

Tests only: host/IP allowlist (case-insensitive), see the class docblock. An empty array (production default) means no exception, blocking is by IP range only.

private array<int, string> $allowedTestHosts = []

Methods

__construct()

public __construct([int $maxBytes = 10 * 1024 * 1024 ][, int $connectTimeoutSeconds = 5 ][, int $totalTimeoutSeconds = 20 ][, array<string|int, mixed> $allowedTestHosts = [] ]) : mixed
Parameters
$maxBytes : int = 10 * 1024 * 1024
$connectTimeoutSeconds : int = 5
$totalTimeoutSeconds : int = 20
$allowedTestHosts : array<string|int, mixed> = []

hasUserInfo()

The URL carries `user[:pass]@` in its authority. The import must not need HTTP Basic credentials embedded in the document, so such an address is rejected (validator and downloader) and the credentials never reach the log or metadata.

public static hasUserInfo(string $url) : bool
Parameters
$url : string
Return values
bool

stripUserInfo()

Diagnostic form of the URL (log, `importSource.url`): authority without `user[:pass]@`, the rest unchanged.

public static stripUserInfo(string $url) : string
Parameters
$url : string
Return values
string

fetchOnce()

private fetchOnce(string $url, array{host: string, ip: string, port: int} $target) : array{success: bool, error: string, redirectTo: ?string, tempPath: ?string, contentType: string}
Parameters
$url : string
$target : array{host: string, ip: string, port: int}
Return values
array{success: bool, error: string, redirectTo: ?string, tempPath: ?string, contentType: string}

isTargetAllowed()

The allowlist (tests only) bypasses the range check only for the explicitly listed host or IP, not globally. Otherwise a public IP is allowed and private, loopback, link-local and reserved (IPv4 and IPv6) are blocked (`filter_var` with both `FILTER_FLAG_NO_*` flags).

private isTargetAllowed(string $host, string $ip) : bool
Parameters
$host : string
$ip : string
Return values
bool

resolveFirstIp()

IPv4 first (`gethostbynamel`), IPv6 fallback (`dns_get_record` aaaa record).

private resolveFirstIp(string $host) : string|null
Parameters
$host : string
Return values
string|null

resolveTarget()

private resolveTarget(string $url) : array{host: string, ip: string, port: int}|null
Parameters
$url : string
Return values
array{host: string, ip: string, port: int}|null
On this page

Search results