ImportImageDownloader
in package
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_WRITEFUNCTIONaborts the transfer on overflow), not after the whole file is downloaded; - the image type is verified by real content detection (
getimagesize); theContent-Typeheader 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
MAX_REDIRECTS
private
int
MAX_REDIRECTS
= 3
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
= []
$connectTimeoutSeconds read-only
private
int
$connectTimeoutSeconds
= 5
$maxBytes read-only
10 MB.
private
int
$maxBytes
= 10 * 1024 * 1024
$totalTimeoutSeconds read-only
private
int
$totalTimeoutSeconds
= 20
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> = []
download()
public
download(string $url, int $line) : ImportImageDownloadResult
Parameters
- $url : string
- $line : int
Return values
ImportImageDownloadResulthasUserInfo()
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
boolstripUserInfo()
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
stringfetchOnce()
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}finalizeDownload()
private
finalizeDownload(string $tempPath, string $contentTypeHeader) : ImportImageDownloadResult
Parameters
- $tempPath : string
- $contentTypeHeader : string
Return values
ImportImageDownloadResultisTargetAllowed()
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
boolresolveFirstIp()
IPv4 first (`gethostbynamel`), IPv6 fallback (`dns_get_record` aaaa record).
private
resolveFirstIp(string $host) : string|null
Parameters
- $host : string
Return values
string|nullresolveTarget()
private
resolveTarget(string $url) : array{host: string, ip: string, port: int}|null
Parameters
- $url : string