PluginProbe ʕ •ᴥ•ʔ
Matomo Analytics – Powerful, Privacy-First Insights for WordPress / 5.13.0
Matomo Analytics – Powerful, Privacy-First Insights for WordPress v5.13.0
5.13.0 5.12.1 5.12.0 5.11.1 5.11.0 5.10.2 5.10.1 trunk 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.1.0 1.1.1 1.1.2 1.1.3 1.2.0 1.3.0 1.3.1 1.3.2 4.0.0 4.0.1 4.0.2 4.0.3 4.0.4 4.1.0 4.1.1 4.1.2 4.1.3 4.10.0 4.11.0 4.12.0 4.13.0 4.13.2 4.13.3 4.13.4 4.13.5 4.14.0 4.14.1 4.14.2 4.15.0 4.15.1 4.15.2 4.15.3 4.2.0 4.3.0 4.3.1 4.4.1 4.4.2 4.5.0 4.6.0 5.0.1 5.0.2 5.0.3 5.0.4 5.0.5 5.0.6 5.0.7 5.0.8 5.1.0 5.1.1 5.1.2 5.1.3 5.1.4 5.1.5 5.1.6 5.1.7 5.10.0 5.2.0 5.2.1 5.2.2 5.3.0 5.3.1 5.3.2 5.3.3 5.6.0 5.6.1 5.7.0 5.7.1 5.8.0 5.8.1 5.8.2
matomo / app / core / Http.php
matomo / app / core Last commit date
API 6 days ago Access 1 month ago Application 1 month ago Archive 1 month ago ArchiveProcessor 6 days ago Archiver 2 years ago AssetManager 1 month ago Auth 1 month ago Category 1 month ago Changes 3 months ago CliMulti 1 month ago Columns 6 days ago Concurrency 1 month ago Config 1 month ago Container 3 months ago CronArchive 1 month ago DataAccess 6 days ago DataFiles 2 years ago DataTable 1 month ago Db 6 days ago DeviceDetector 1 year ago Email 2 years ago Exception 6 months ago Http 6 days ago Intl 5 months ago Log 2 years ago Mail 1 year ago Measurable 8 months ago Menu 1 month ago Metrics 1 month ago Notification 8 months ago Period 1 month ago Plugin 6 days ago Policy 3 months ago ProfessionalServices 1 year ago Report 1 year ago ReportRenderer 6 days ago Request 1 month ago Scheduler 1 month ago Segment 1 month ago Session 6 days ago Settings 6 days ago Tracker 6 days ago Translation 3 months ago Twig 1 year ago UpdateCheck 5 months ago Updater 1 month ago Updates 2 months ago Validators 1 year ago View 3 months ago ViewDataTable 6 days ago Visualization 6 days ago Widget 1 month ago .htaccess 2 years ago Access.php 1 month ago Archive.php 3 months ago ArchiveProcessor.php 6 days ago AssetManager.php 1 month ago Auth.php 8 months ago AuthResult.php 8 months ago BaseFactory.php 2 years ago Cache.php 1 month ago CacheId.php 6 months ago CliMulti.php 1 month ago Common.php 1 month ago Config.php 1 month ago Console.php 5 months ago Context.php 2 years ago Cookie.php 1 month ago CronArchive.php 1 month ago DI.php 5 months ago DataArray.php 1 month ago DataTable.php 6 days ago Date.php 3 months ago Db.php 3 months ago DbHelper.php 1 month ago Development.php 1 month ago ErrorHandler.php 8 months ago EventDispatcher.php 3 months ago ExceptionHandler.php 6 months ago FileIntegrity.php 1 month ago Filechecks.php 1 year ago Filesystem.php 6 days ago FrontController.php 1 month ago Http.php 6 days ago IP.php 1 year ago Log.php 5 months ago LogDeleter.php 1 month ago Mail.php 1 year ago Metrics.php 3 months ago NoAccessException.php 2 years ago Nonce.php 8 months ago Notification.php 3 months ago NumberFormatter.php 1 month ago Option.php 1 month ago Period.php 1 month ago Piwik.php 1 month ago Plugin.php 3 months ago Process.php 3 months ago Profiler.php 1 month ago ProxyHeaders.php 6 months ago ProxyHttp.php 1 month ago QuickForm2.php 5 months ago RankingQuery.php 3 months ago ReportRenderer.php 6 days ago Request.php 3 months ago Segment.php 6 days ago Sequence.php 1 month ago Session.php 2 months ago SettingsPiwik.php 1 month ago SettingsServer.php 1 month ago Singleton.php 2 years ago Site.php 3 months ago SiteContentDetector.php 6 days ago SupportedBrowser.php 2 years ago TCPDF.php 1 year ago Theme.php 1 month ago Timer.php 1 month ago Tracker.php 1 month ago Twig.php 6 days ago Unzip.php 1 year ago UpdateCheck.php 3 months ago Updater.php 1 month ago UpdaterErrorException.php 2 years ago Updates.php 1 month ago Url.php 1 month ago UrlHelper.php 3 months ago Version.php 6 days ago View.php 1 month ago bootstrap.php 1 year ago dispatch.php 2 years ago testMinimumPhpVersion.php 8 months ago
Http.php
1049 lines
1 <?php
2
3 /**
4 * Matomo - free/libre analytics platform
5 *
6 * @link https://matomo.org
7 * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
8 */
9 namespace Piwik;
10
11 use Composer\CaBundle\CaBundle;
12 use Exception;
13 use Piwik\Config\GeneralConfig;
14 use Piwik\Container\StaticContainer;
15 use Piwik\Http\EgressBlockedException;
16 use Piwik\Http\EgressHostValidator;
17 /**
18 * Contains HTTP client related helper methods that can retrieve content from remote servers
19 * and optionally save to a local file.
20 *
21 * Used to check for the latest Piwik version and download updates.
22 *
23 */
24 class Http
25 {
26 /**
27 * Returns the "best" available transport method for {@link sendHttpRequest()} calls.
28 *
29 * @return string|null Either curl, fopen, socket or null if no method is supported.
30 * @api
31 */
32 public static function getTransportMethod()
33 {
34 $method = 'curl';
35 if (!self::isCurlEnabled()) {
36 $method = 'fopen';
37 if (@ini_get('allow_url_fopen') != '1') {
38 $method = 'socket';
39 if (!self::isSocketEnabled()) {
40 return null;
41 }
42 }
43 }
44 return $method;
45 }
46 /**
47 * @return bool
48 */
49 protected static function isSocketEnabled()
50 {
51 return function_exists('fsockopen');
52 }
53 /**
54 * @return bool
55 */
56 protected static function isCurlEnabled()
57 {
58 return function_exists('curl_init') && function_exists('curl_exec');
59 }
60 /**
61 * Sends an HTTP request using best available transport method.
62 *
63 * @param string $aUrl The target URL.
64 * @param int $timeout The number of seconds to wait before aborting the HTTP request.
65 * @param string|null $userAgent The user agent to use.
66 * @param string|null $destinationPath If supplied, the HTTP response will be saved to the file specified by
67 * this path.
68 * @param int|null $followDepth Internal redirect count. Should always pass `null` for this parameter.
69 * @param bool|string $acceptLanguage The value to use for the `'Accept-Language'` HTTP request header.
70 * @param array|bool $byteRange For `Range:` header. Should be two element array of bytes, eg, `array(0, 1024)`
71 * Doesn't work w/ `fopen` transport method.
72 * @param bool $getExtendedInfo If true returns the status code, headers & response, if false just the response.
73 * @param string $httpMethod The HTTP method to use. Defaults to `'GET'`.
74 * @param string $httpUsername HTTP Auth username
75 * @param string $httpPassword HTTP Auth password
76 * @param bool $checkHostIsAllowed whether we should check if the target host is allowed or not. This should only
77 * be set to false when using a hardcoded URL.
78 * @param bool $validateEgressIp when true, serves the request over the SSRF-safe path: the resolved host must be a
79 * public IP (or covered by `[General] allowed_private_egress_ranges`), every redirect
80 * hop is re-validated and the connection pinned to it. Use this whenever the URL comes
81 * from untrusted input (e.g. a site's own configured URL).
82 * Requires curl, bypasses any configured or environment proxy, retains the method and
83 * body across same-origin hops, drops credentials, caller headers and the body on an
84 * origin change, and does not follow redirects when downloading to a file.
85 * A refused target or unmet precondition throws {@see EgressBlockedException}.
86 *
87 * @return string|array|bool If `$destinationPath` is not specified the HTTP response is returned on success. `false`
88 * is returned on failure.
89 * If `$getExtendedInfo` is `true` and `$destinationPath` is not specified an array with
90 * the following information is returned on success:
91 *
92 * - **status**: the HTTP status code
93 * - **headers**: the HTTP headers
94 * - **data**: the HTTP response data
95 *
96 * `false` is still returned on failure.
97 * @throws Exception if the response cannot be saved to `$destinationPath`, if the HTTP response cannot be sent,
98 * if there are more than 5 redirects or if the request times out.
99 * @phpstan-return ($destinationPath is null ? ($getExtendedInfo is true ? array{status: ?int, headers?: ?array, data?: ?string} : string|false) : bool)
100 * @api
101 */
102 public static function sendHttpRequest($aUrl, $timeout, $userAgent = null, $destinationPath = null, $followDepth = 0, $acceptLanguage = \false, $byteRange = \false, $getExtendedInfo = \false, $httpMethod = 'GET', $httpUsername = null, $httpPassword = null, $checkHostIsAllowed = \true, $validateEgressIp = \false)
103 {
104 // create output file
105 $file = self::ensureDestinationDirectoryExists($destinationPath);
106 $transport = self::getTransportMethod();
107 if ($validateEgressIp) {
108 // The SSRF-safe path only pins and re-validates reliably over curl, so fail
109 // closed rather than silently degrade to an unprotected transport.
110 if (!self::isCurlEnabled()) {
111 throw new EgressBlockedException('SSRF-safe HTTP requests require the curl PHP extension.');
112 }
113 $transport = 'curl';
114 }
115 $acceptLanguage = $acceptLanguage ? 'Accept-Language: ' . $acceptLanguage : '';
116 return self::sendHttpRequestBy($transport, $aUrl, $timeout, $userAgent, $destinationPath, $file, $followDepth ?? 0, $acceptLanguage, $acceptInvalidSslCertificate = \false, $byteRange, $getExtendedInfo, $httpMethod, $httpUsername, $httpPassword, null, [], null, $checkHostIsAllowed, $validateEgressIp);
117 }
118 /**
119 * @param string|null $destinationPath
120 * @return resource|null
121 * @throws Exception
122 */
123 public static function ensureDestinationDirectoryExists($destinationPath)
124 {
125 if ($destinationPath) {
126 \Piwik\Filesystem::mkdir(dirname($destinationPath));
127 if (($file = @fopen($destinationPath, 'wb')) === \false || !is_resource($file)) {
128 throw new Exception('Error while creating the file: ' . $destinationPath);
129 }
130 return $file;
131 }
132 return null;
133 }
134 /**
135 * Throws when the host matches any `http.blocklist.hosts` wildcard rule.
136 */
137 private static function assertHostNotBlocked(?string $host) : void
138 {
139 if (empty($host)) {
140 return;
141 }
142 $disallowedHosts = StaticContainer::get('http.blocklist.hosts');
143 foreach ($disallowedHosts as $disallowedHost) {
144 if (preg_match(self::convertWildcardToPattern($disallowedHost), $host) === 1) {
145 throw new Exception(sprintf('Hostname %s is in list of disallowed hosts', $host));
146 }
147 }
148 }
149 private static function convertWildcardToPattern(string $wildcardHost) : string
150 {
151 $flexibleStart = $flexibleEnd = \false;
152 if (strpos($wildcardHost, '*.') === 0) {
153 $flexibleStart = \true;
154 $wildcardHost = substr($wildcardHost, 2);
155 }
156 if (\Piwik\Common::stringEndsWith($wildcardHost, '.*')) {
157 $flexibleEnd = \true;
158 $wildcardHost = substr($wildcardHost, 0, -2);
159 }
160 $pattern = preg_quote($wildcardHost);
161 if ($flexibleStart) {
162 $pattern = '.*\\.' . $pattern;
163 }
164 if ($flexibleEnd) {
165 $pattern .= '\\..*';
166 }
167 return '/^' . $pattern . '$/i';
168 }
169 /**
170 * Sends an HTTP request using the specified transport method.
171 *
172 * @param string|null $method
173 * @param string $aUrl
174 * @param int $timeout in seconds
175 * @param string|null $userAgent
176 * @param string|null $destinationPath
177 * @param resource|null $file
178 * @param int $followDepth
179 * @param string|false $acceptLanguage Accept-language header
180 * @param bool $acceptInvalidSslCertificate Only used with $method == 'curl'. If set to true (NOT recommended!) the SSL certificate will not be checked
181 * @param array|false $byteRange For Range: header. Should be two element array of bytes, eg, array(0, 1024)
182 * Doesn't work w/ fopen method.
183 * @param bool $getExtendedInfo True to return status code, headers & response, false if just response.
184 * @param string $httpMethod The HTTP method to use. Defaults to `'GET'`.
185 * @param string|null $httpUsername HTTP Auth username
186 * @param string|null $httpPassword HTTP Auth password
187 * @param array|string|null $requestBody If $httpMethod is 'POST' this may accept an array of variables or a string that needs to be posted
188 * @param array $additionalHeaders List of additional headers to set for the request
189 * @param bool|null $forcePost If true, forces POST redirects to remain POST requests (curl only). Ignored on the
190 * `$validateEgressIp` path, where the method and body are retained on same-origin
191 * redirects only and cross-origin redirects are downgraded to GET without a body.
192 * @param bool $checkHostIsAllowed whether we should check if the target host is allowed or not. This should only
193 * be set to false when using a hardcoded URL.
194 * @param bool $validateEgressIp when true, the request is served over the SSRF-safe path: public-IP validation,
195 * manual per-hop redirect re-validation and connection pinning. See
196 * {@see sendHttpRequest()} for the full contract.
197 *
198 * @return ($destinationPath is null ? ($getExtendedInfo is true ? array{status: ?int, headers?: ?array, data?: ?string} : string|false) : bool)
199 * @throws Exception
200 */
201 public static function sendHttpRequestBy($method, $aUrl, $timeout, $userAgent = null, $destinationPath = null, $file = null, $followDepth = 0, $acceptLanguage = \false, $acceptInvalidSslCertificate = \false, $byteRange = \false, $getExtendedInfo = \false, $httpMethod = 'GET', $httpUsername = null, $httpPassword = null, $requestBody = null, $additionalHeaders = array(), $forcePost = null, $checkHostIsAllowed = \true, $validateEgressIp = \false)
202 {
203 if ($followDepth > 5) {
204 throw new Exception('Too many redirects (' . $followDepth . ')');
205 }
206 $aUrl = preg_replace('/[\\x00-\\x1F\\x7F]/', '', trim($aUrl));
207 $parsedUrl = @parse_url($aUrl);
208 if (empty($parsedUrl['scheme'])) {
209 throw new Exception('Missing scheme in given url');
210 }
211 $allowedProtocols = GeneralConfig::getConfigValue('allowed_outgoing_protocols');
212 $isAllowed = \false;
213 foreach (explode(',', $allowedProtocols) as $protocol) {
214 if (strtolower($parsedUrl['scheme']) === strtolower(trim($protocol))) {
215 $isAllowed = \true;
216 break;
217 }
218 }
219 if (!$isAllowed) {
220 throw new Exception(sprintf('Protocol %s not in list of allowed protocols: %s', $parsedUrl['scheme'], $allowedProtocols));
221 }
222 if ($checkHostIsAllowed) {
223 self::assertHostNotBlocked($parsedUrl['host'] ?? null);
224 }
225 // SSRF-safe path: only curl can pin the validated address
226 // we handle redirects manually below, and refuse any other transport
227 // or a forward proxy rather than fetch unsafely.
228 $pinnedResolveEntry = null;
229 if ($validateEgressIp) {
230 if ($method !== 'curl') {
231 throw new EgressBlockedException('SSRF-safe HTTP requests require the curl transport.');
232 }
233 if (!self::isCurlEnabled()) {
234 throw new EgressBlockedException('SSRF-safe HTTP requests require the curl PHP extension.');
235 }
236 // Restrict to http(s): other schemes have different default ports
237 $scheme = strtolower((string) $parsedUrl['scheme']);
238 if ($scheme !== 'http' && $scheme !== 'https') {
239 throw new EgressBlockedException('SSRF-safe HTTP requests only support the http and https schemes.');
240 }
241 [$configuredProxyHost] = self::getProxyConfiguration($aUrl);
242 if (!empty($configuredProxyHost)) {
243 throw new EgressBlockedException('SSRF-safe HTTP requests cannot be routed through a configured proxy.');
244 }
245 $effectivePort = isset($parsedUrl['port']) ? (int) $parsedUrl['port'] : ($scheme === 'https' ? 443 : 80);
246 // Resolved via DI so tests can substitute a validator that accepts the local fixture server.
247 [$canonicalHost, $pinnedIp] = StaticContainer::get(EgressHostValidator::class)->resolveTarget((string) ($parsedUrl['host'] ?? ''));
248 // Rewrite the URL to the canonical host when it differs (IDN folding, casing, a trailing dot)
249 if ($canonicalHost !== trim((string) ($parsedUrl['host'] ?? ''), '[]')) {
250 $aUrl = self::replaceUrlHost($parsedUrl, $canonicalHost);
251 // Re-check the blocklist against the host curl will actually connect to. The check
252 // above ran on the raw host, so canonicalisation (a trailing dot, IDN folding) could
253 // otherwise slip a blocked host like "s3.amazonaws.com." past the wildcard rules.
254 if ($checkHostIsAllowed) {
255 self::assertHostNotBlocked($canonicalHost);
256 }
257 }
258 // For a DNS host, pin the name to the validated IP so curl cannot re-resolve to
259 // a different address. An IP literal (canonicalHost === pinnedIp) needs no pin.
260 // @todo PHP 8.1 min: strpos($pinnedIp, ':') !== false can become str_contains().
261 if ($canonicalHost !== $pinnedIp) {
262 $pinnedAddress = strpos($pinnedIp, ':') !== \false ? '[' . $pinnedIp . ']' : $pinnedIp;
263 $pinnedResolveEntry = $canonicalHost . ':' . $effectivePort . ':' . $pinnedAddress;
264 }
265 }
266 // When sending an insecure request, but https is forced, and we would care about valid certificates, log a warning
267 // Note: accepting invalid ssl certificates should only be used when requesting data from a configured website
268 if ($parsedUrl['scheme'] === 'http' && \Piwik\SettingsPiwik::isHttpsForced() && $acceptInvalidSslCertificate === \false) {
269 \Piwik\Log::warning('Matomo is configured to force HTTPS, but is sending an insecure request to ' . $aUrl);
270 }
271 $contentLength = 0;
272 $fileLength = 0;
273 if (!empty($requestBody) && is_array($requestBody)) {
274 $requestBodyQuery = self::buildQuery($requestBody);
275 } else {
276 $requestBodyQuery = $requestBody;
277 }
278 if (empty($userAgent)) {
279 $userAgent = self::getUserAgent();
280 }
281 $via = 'Via: ' . (isset($_SERVER['HTTP_VIA']) && !empty($_SERVER['HTTP_VIA']) ? $_SERVER['HTTP_VIA'] . ', ' : '') . \Piwik\Version::VERSION . ' ' . ($userAgent ? " ({$userAgent})" : '');
282 // range header
283 $rangeBytes = '';
284 $rangeHeader = '';
285 if (!empty($byteRange)) {
286 $rangeBytes = $byteRange[0] . '-' . $byteRange[1];
287 $rangeHeader = 'Range: bytes=' . $rangeBytes . "\r\n";
288 }
289 [$proxyHost, $proxyPort, $proxyUser, $proxyPassword] = self::getProxyConfiguration($aUrl);
290 /** @var int|null $status */
291 $status = null;
292 /** @var array<string, string> $headers */
293 $headers = array();
294 /** @var string|null $response */
295 $response = null;
296 $httpAuthIsUsed = !empty($httpUsername) || !empty($httpPassword);
297 $httpAuth = '';
298 if ($httpAuthIsUsed) {
299 $httpAuth = 'Authorization: Basic ' . base64_encode($httpUsername . ':' . $httpPassword) . "\r\n";
300 }
301 $httpEventParams = array('httpMethod' => $httpMethod, 'body' => $requestBody, 'userAgent' => $userAgent, 'timeout' => $timeout, 'headers' => array_map('trim', array_filter(array_merge([$rangeHeader, $via, $httpAuth, $acceptLanguage], $additionalHeaders))), 'verifySsl' => !$acceptInvalidSslCertificate, 'destinationPath' => $destinationPath, 'validateEgressIp' => $validateEgressIp);
302 /**
303 * Triggered to send an HTTP request. Allows plugins to resolve the HTTP request themselves or to find out
304 * when an HTTP request is triggered to log this information for example to a monitoring tool.
305 *
306 * @param string $url The URL that needs to be requested
307 * @param array $params HTTP params like
308 * - 'httpMethod' (eg GET, POST, ...),
309 * - 'body' the request body if the HTTP method needs to be posted
310 * - 'userAgent'
311 * - 'timeout' After how many seconds a request should time out
312 * - 'headers' An array of header strings like array('Accept-Language: en', '...')
313 * - 'verifySsl' A boolean whether SSL certificate should be verified
314 * - 'destinationPath' If set, the response of the HTTP request should be saved to this file
315 * - 'validateEgressIp' Whether the caller asked for SSRF-safe semantics. A listener
316 * resolving the request itself must honour them or leave it unhandled
317 * @param string &$response A plugin listening to this event should assign the HTTP response it received to this variable, for example "{value: true}"
318 * @param int &$status A plugin listening to this event should assign the HTTP status code it received to this variable, for example "200"
319 * @param array &$headers A plugin listening to this event should assign the HTTP headers it received to this variable, eg array('Content-Length' => '5')
320 */
321 \Piwik\Piwik::postEvent('Http.sendHttpRequest', array($aUrl, $httpEventParams, &$response, &$status, &$headers));
322 if ($response !== null || $status !== null || !empty($headers)) {
323 // was handled by event above...
324 /**
325 * described below
326 * @ignore
327 */
328 \Piwik\Piwik::postEvent('Http.sendHttpRequest.end', array($aUrl, $httpEventParams, &$response, &$status, &$headers));
329 if ($destinationPath && file_exists($destinationPath)) {
330 return \true;
331 }
332 if ($getExtendedInfo) {
333 return array('status' => $status, 'headers' => $headers, 'data' => $response);
334 } else {
335 return trim($response);
336 }
337 }
338 if ($method == 'socket') {
339 if (!self::isSocketEnabled()) {
340 // can be triggered in tests
341 throw new Exception("HTTP socket support is not enabled (php function fsockopen is not available) ");
342 }
343 // initialization
344 $url = @parse_url($aUrl);
345 if ($url === \false || !isset($url['scheme'])) {
346 throw new Exception('Malformed URL: ' . $aUrl);
347 }
348 if ($url['scheme'] != 'http' && $url['scheme'] != 'https') {
349 throw new Exception('Invalid protocol/scheme: ' . $url['scheme']);
350 }
351 $host = $url['host'];
352 $port = isset($url['port']) ? $url['port'] : ('https' == $url['scheme'] ? 443 : 80);
353 $path = isset($url['path']) ? $url['path'] : '/';
354 if (isset($url['query'])) {
355 $path .= '?' . $url['query'];
356 }
357 $errno = null;
358 $errstr = null;
359 if (!empty($proxyHost) && !empty($proxyPort) || !empty($byteRange)) {
360 $httpVer = '1.1';
361 } else {
362 $httpVer = '1.0';
363 }
364 $proxyAuth = null;
365 if (!empty($proxyHost) && !empty($proxyPort)) {
366 $connectHost = $proxyHost;
367 $connectPort = $proxyPort;
368 if (!empty($proxyUser) && !empty($proxyPassword)) {
369 $proxyAuth = 'Proxy-Authorization: Basic ' . base64_encode("{$proxyUser}:{$proxyPassword}") . "\r\n";
370 }
371 $requestHeader = "{$httpMethod} {$aUrl} HTTP/{$httpVer}\r\n";
372 } else {
373 $connectHost = $host;
374 $connectPort = $port;
375 $requestHeader = "{$httpMethod} {$path} HTTP/{$httpVer}\r\n";
376 if ('https' == $url['scheme']) {
377 $connectHost = 'tls://' . $connectHost;
378 }
379 }
380 // connection attempt
381 if (($fsock = @fsockopen($connectHost, $connectPort, $errno, $errstr, $timeout)) === \false || !is_resource($fsock)) {
382 if (is_resource($file)) {
383 @fclose($file);
384 }
385 throw new Exception("Error while connecting to: {$host}. Please try again later. {$errstr}");
386 }
387 // send HTTP request header
388 $requestHeader .= "Host: {$host}" . ($port != 80 && ('https' == $url['scheme'] && $port != 443) ? ':' . $port : '') . "\r\n" . ($httpAuth ? $httpAuth : '') . ($proxyAuth ? $proxyAuth : '') . 'User-Agent: ' . $userAgent . "\r\n" . ($acceptLanguage ? $acceptLanguage . "\r\n" : '') . $via . "\r\n" . $rangeHeader . (!empty($additionalHeaders) ? implode("\r\n", $additionalHeaders) . "\r\n" : '') . "Connection: close\r\n";
389 fwrite($fsock, $requestHeader);
390 if (strtolower($httpMethod) === 'post' && !empty($requestBodyQuery)) {
391 fwrite($fsock, self::buildHeadersForPost($requestBodyQuery));
392 fwrite($fsock, "\r\n");
393 fwrite($fsock, $requestBodyQuery);
394 } else {
395 fwrite($fsock, "\r\n");
396 }
397 $streamMetaData = array('timed_out' => \false);
398 @stream_set_blocking($fsock, \true);
399 if (function_exists('stream_set_timeout')) {
400 @stream_set_timeout($fsock, $timeout);
401 } elseif (function_exists('socket_set_timeout')) {
402 @socket_set_timeout($fsock, $timeout);
403 }
404 // process header
405 $status = null;
406 while (!feof($fsock)) {
407 $line = fgets($fsock, 4096);
408 $streamMetaData = @stream_get_meta_data($fsock);
409 if ($streamMetaData['timed_out']) {
410 if (is_resource($file)) {
411 @fclose($file);
412 }
413 @fclose($fsock);
414 throw new Exception('Timed out waiting for server response');
415 }
416 // a blank line marks the end of the server response header
417 if (rtrim($line, "\r\n") == '') {
418 break;
419 }
420 // parse first line of server response header
421 if (!$status) {
422 // expect first line to be HTTP response status line, e.g., HTTP/1.1 200 OK
423 if (!preg_match('~^HTTP/(\\d\\.\\d)\\s+(\\d+)(\\s*.*)?~', $line, $m)) {
424 if (is_resource($file)) {
425 @fclose($file);
426 }
427 @fclose($fsock);
428 throw new Exception('Expected server response code. Got ' . rtrim($line, "\r\n"));
429 }
430 $status = (int) $m[2];
431 // Informational 1xx or Client Error 4xx
432 if ($status < 200 || $status >= 400) {
433 if (is_resource($file)) {
434 @fclose($file);
435 }
436 @fclose($fsock);
437 if (!$getExtendedInfo) {
438 return \false;
439 } else {
440 return array('status' => $status);
441 }
442 }
443 continue;
444 }
445 // handle redirect
446 if (preg_match('/^Location:\\s*(.+)/', rtrim($line, "\r\n"), $m)) {
447 if (is_resource($file)) {
448 @fclose($file);
449 }
450 @fclose($fsock);
451 // Successful 2xx vs Redirect 3xx
452 if ($status < 300) {
453 throw new Exception('Unexpected redirect to Location: ' . rtrim($line) . ' for status code ' . $status);
454 }
455 return self::sendHttpRequestBy($method, trim($m[1]), $timeout, $userAgent, $destinationPath, $file, $followDepth + 1, $acceptLanguage, $acceptInvalidSslCertificate = \false, $byteRange, $getExtendedInfo, $httpMethod, $httpUsername, $httpPassword, $requestBodyQuery, $additionalHeaders);
456 }
457 // save expected content length for later verification
458 if (preg_match('/^Content-Length:\\s*(\\d+)/', $line, $m)) {
459 $contentLength = (int) $m[1];
460 }
461 self::parseHeaderLine($headers, $line);
462 }
463 if (feof($fsock) && $httpMethod != 'HEAD') {
464 throw new Exception('Unexpected end of transmission');
465 }
466 // process content/body
467 $response = '';
468 while (!feof($fsock)) {
469 $line = fread($fsock, 8192);
470 $streamMetaData = @stream_get_meta_data($fsock);
471 if ($streamMetaData['timed_out']) {
472 if (is_resource($file)) {
473 @fclose($file);
474 }
475 @fclose($fsock);
476 throw new Exception('Timed out waiting for server response');
477 }
478 $fileLength += strlen($line);
479 if (is_resource($file)) {
480 // save to file
481 fwrite($file, $line);
482 } else {
483 // concatenate to response string
484 $response .= $line;
485 }
486 }
487 // determine success or failure
488 @fclose(@$fsock);
489 } elseif ($method == 'fopen') {
490 $response = \false;
491 // we make sure the request takes less than a few seconds to fail
492 // we create a stream_context (works in php >= 5.2.1)
493 // we also set the socket_timeout (for php < 5.2.1)
494 $default_socket_timeout = @ini_get('default_socket_timeout');
495 @ini_set('default_socket_timeout', (string) $timeout);
496 $ctx = null;
497 if (function_exists('stream_context_create')) {
498 $stream_options = array('http' => array(
499 'header' => 'User-Agent: ' . $userAgent . "\r\n" . ($httpAuth ? $httpAuth : '') . ($acceptLanguage ? $acceptLanguage . "\r\n" : '') . $via . "\r\n" . (!empty($additionalHeaders) ? implode("\r\n", $additionalHeaders) . "\r\n" : '') . $rangeHeader,
500 'max_redirects' => 5,
501 // PHP 5.1.0
502 'timeout' => $timeout,
503 ));
504 if (!empty($proxyHost) && !empty($proxyPort)) {
505 $stream_options['http']['proxy'] = 'tcp://' . $proxyHost . ':' . $proxyPort;
506 $stream_options['http']['request_fulluri'] = \true;
507 // required by squid proxy
508 if (!empty($proxyUser) && !empty($proxyPassword)) {
509 $stream_options['http']['header'] .= 'Proxy-Authorization: Basic ' . base64_encode("{$proxyUser}:{$proxyPassword}") . "\r\n";
510 }
511 }
512 if (strtolower($httpMethod) === 'post' && !empty($requestBodyQuery)) {
513 $postHeader = self::buildHeadersForPost($requestBodyQuery);
514 $postHeader .= "\r\n";
515 $stream_options['http']['method'] = 'POST';
516 $stream_options['http']['header'] .= $postHeader;
517 $stream_options['http']['content'] = $requestBodyQuery;
518 }
519 $ctx = stream_context_create($stream_options);
520 }
521 // save to file
522 if (is_resource($file)) {
523 if (!($handle = fopen($aUrl, 'rb', \false, $ctx))) {
524 throw new Exception("Unable to open {$aUrl}");
525 }
526 while (!feof($handle)) {
527 $response = fread($handle, 8192);
528 $fileLength += strlen($response);
529 fwrite($file, $response);
530 }
531 fclose($handle);
532 if (function_exists('http_get_last_response_headers')) {
533 $http_response_header = http_get_last_response_headers();
534 }
535 } else {
536 $response = @file_get_contents($aUrl, \false, $ctx);
537 if (function_exists('http_get_last_response_headers')) {
538 $http_response_header = http_get_last_response_headers();
539 }
540 // try to get http status code from response headers
541 if (!empty($http_response_header) && preg_match('~^HTTP/(\\d\\.\\d)\\s+(\\d+)(\\s*.*)?~', implode("\n", $http_response_header), $m)) {
542 $status = (int) $m[2];
543 }
544 if (!$status && $response === \false) {
545 $error = \Piwik\ErrorHandler::getLastError();
546 throw new \Exception($error);
547 }
548 $fileLength = strlen($response);
549 }
550 foreach ($http_response_header as $line) {
551 self::parseHeaderLine($headers, $line);
552 }
553 // restore the socket_timeout value
554 if (!empty($default_socket_timeout)) {
555 @ini_set('default_socket_timeout', $default_socket_timeout);
556 }
557 } elseif ($method == 'curl') {
558 if (!self::isCurlEnabled()) {
559 // can be triggered in tests
560 throw new Exception("CURL is not enabled in php.ini, but is being used.");
561 }
562 $ch = @curl_init();
563 if (!empty($proxyHost) && !empty($proxyPort)) {
564 @curl_setopt($ch, \CURLOPT_PROXY, $proxyHost . ':' . $proxyPort);
565 if (!empty($proxyUser) && !empty($proxyPassword)) {
566 // PROXYAUTH defaults to BASIC
567 @curl_setopt($ch, \CURLOPT_PROXYUSERPWD, $proxyUser . ':' . $proxyPassword);
568 }
569 }
570 $curl_options = array(
571 // curl options (sorted oldest to newest)
572 \CURLOPT_URL => $aUrl,
573 \CURLOPT_USERAGENT => $userAgent,
574 \CURLOPT_HTTPHEADER => array_merge(array($via, $acceptLanguage), $additionalHeaders),
575 // only get header info if not saving directly to file
576 \CURLOPT_HEADER => is_resource($file) ? \false : \true,
577 \CURLOPT_CONNECTTIMEOUT => $timeout,
578 \CURLOPT_TIMEOUT => $timeout,
579 );
580 if ($rangeBytes) {
581 curl_setopt($ch, \CURLOPT_RANGE, $rangeBytes);
582 } else {
583 // see https://github.com/matomo-org/matomo/pull/17009 for more info
584 // NOTE: we only do this when CURLOPT_RANGE is not being used, because when using both the
585 // response is empty.
586 $curl_options[\CURLOPT_ENCODING] = "";
587 }
588 // Case core:archive command is triggering archiving on https:// and the certificate is not valid
589 if ($acceptInvalidSslCertificate) {
590 $curl_options += array(\CURLOPT_SSL_VERIFYHOST => \false, \CURLOPT_SSL_VERIFYPEER => \false);
591 }
592 @curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, $httpMethod);
593 if ($httpMethod == 'HEAD') {
594 @curl_setopt($ch, \CURLOPT_NOBODY, \true);
595 }
596 if (in_array(strtolower($httpMethod), ['post', 'put']) && !empty($requestBodyQuery)) {
597 curl_setopt($ch, \CURLOPT_POST, 1);
598 curl_setopt($ch, \CURLOPT_POSTFIELDS, $requestBodyQuery);
599 }
600 if (!empty($httpUsername) && !empty($httpPassword)) {
601 $curl_options += array(\CURLOPT_USERPWD => $httpUsername . ':' . $httpPassword);
602 }
603 @curl_setopt_array($ch, $curl_options);
604 self::configCurlCertificate($ch);
605 if ($validateEgressIp) {
606 // Follow redirects manually so every hop is re-validated, and pin
607 // the connection to the address we validated to close the DNS-rebinding window.
608 // CURLOPT_RESOLVE keeps the original hostname for SNI and certificate checks.
609 // @todo when switching to a new HTTP library: this transport-specific
610 // pinning/redirect wiring should probably be re-implemented against it (e.g. Guzzle's
611 // curl.options + redirect middleware). EgressHostValidator is reusable as-is.
612 @curl_setopt($ch, \CURLOPT_FOLLOWLOCATION, \false);
613 // Disable any environment proxy (http_proxy etc.) so it cannot re-resolve the host and bypass the pin.
614 @curl_setopt($ch, \CURLOPT_PROXY, '');
615 if ($pinnedResolveEntry !== null) {
616 @curl_setopt($ch, \CURLOPT_RESOLVE, array($pinnedResolveEntry));
617 }
618 }
619 /*
620 * as of php 5.2.0, CURLOPT_FOLLOWLOCATION can't be set if
621 * in safe_mode or open_basedir is set
622 */
623 if (!$validateEgressIp && (string) ini_get('safe_mode') == '' && ini_get('open_basedir') == '') {
624 $protocols = 0;
625 foreach (explode(',', $allowedProtocols) as $protocol) {
626 if (defined('CURLPROTO_' . strtoupper(trim($protocol)))) {
627 $protocols |= constant('CURLPROTO_' . strtoupper(trim($protocol)));
628 }
629 }
630 $curl_options = array(
631 // curl options (sorted oldest to newest)
632 \CURLOPT_FOLLOWLOCATION => \true,
633 \CURLOPT_REDIR_PROTOCOLS => $protocols,
634 \CURLOPT_MAXREDIRS => 5,
635 );
636 if ($forcePost) {
637 $curl_options[\CURLOPT_POSTREDIR] = \CURL_REDIR_POST_ALL;
638 }
639 @curl_setopt_array($ch, $curl_options);
640 }
641 if (is_resource($file)) {
642 // write output directly to file
643 @curl_setopt($ch, \CURLOPT_FILE, $file);
644 } else {
645 // internal to ext/curl
646 @curl_setopt($ch, \CURLOPT_RETURNTRANSFER, \true);
647 }
648 ob_start();
649 $response = @curl_exec($ch);
650 ob_end_clean();
651 if ($response === \true) {
652 $response = '';
653 } elseif ($response === \false) {
654 $errstr = curl_error($ch);
655 if ($errstr != '') {
656 throw new Exception('curl_exec: ' . $errstr . '. Hostname requested was: ' . \Piwik\UrlHelper::getHostFromUrl($aUrl));
657 }
658 $response = '';
659 } else {
660 $header = '';
661 // redirects are included in the output html, so we look for the last line that starts w/ HTTP/...
662 // to split the response
663 while (substr($response, 0, 5) == "HTTP/") {
664 $split = explode("\r\n\r\n", $response, 2);
665 if (count($split) == 2) {
666 [$header, $response] = $split;
667 } else {
668 $response = '';
669 $header = reset($split);
670 }
671 }
672 foreach (explode("\r\n", $header) as $line) {
673 self::parseHeaderLine($headers, $line);
674 }
675 }
676 $contentLength = @curl_getinfo($ch, \CURLINFO_CONTENT_LENGTH_DOWNLOAD);
677 $fileLength = is_resource($file) ? @curl_getinfo($ch, \CURLINFO_SIZE_DOWNLOAD) : strlen($response);
678 $status = @curl_getinfo($ch, \CURLINFO_HTTP_CODE);
679 $elapsed = (float) @curl_getinfo($ch, \CURLINFO_TOTAL_TIME);
680 $curlRedirectUrl = (string) @curl_getinfo($ch, \CURLINFO_REDIRECT_URL);
681 @curl_close($ch);
682 unset($ch);
683 // SSRF-safe path follows redirects manually so each hop is re-validated and re-pinned.
684 if ($validateEgressIp && $status >= 300 && $status < 400 && $status !== 304) {
685 if (is_resource($file)) {
686 // CURLOPT_HEADER is off for file downloads, so the Location cannot be re-validated: fail closed.
687 @fclose($file);
688 if ($destinationPath) {
689 @unlink($destinationPath);
690 }
691 throw new EgressBlockedException('SSRF-safe HTTP requests cannot follow redirects when downloading to a file.');
692 }
693 // Read from curl, not from $headers: the splitter above keeps the last "HTTP/" block
694 // it finds, so a response body can forge one.
695 $redirectUrl = $curlRedirectUrl;
696 if ($redirectUrl !== '') {
697 // Cross-origin: drop credentials, caller headers and the body, and downgrade to GET.
698 // $acceptInvalidSslCertificate stays, since it guards no secret past this point and
699 // dropping it would fail the common http -> https hop for self-signed sites.
700 if (!self::urlsSameOrigin($aUrl, $redirectUrl)) {
701 $httpUsername = null;
702 $httpPassword = null;
703 $additionalHeaders = array();
704 $requestBody = null;
705 $httpMethod = 'GET';
706 $forcePost = null;
707 }
708 // Shrink the timeout by what this hop already spent so the whole redirect chain
709 // stays within the caller's original budget instead of granting it to every hop.
710 $remainingTimeout = max(1, (int) floor($timeout - $elapsed));
711 return self::sendHttpRequestBy($method, $redirectUrl, $remainingTimeout, $userAgent, $destinationPath, $file, $followDepth + 1, $acceptLanguage, $acceptInvalidSslCertificate, $byteRange, $getExtendedInfo, $httpMethod, $httpUsername, $httpPassword, $requestBody, $additionalHeaders, $forcePost, $checkHostIsAllowed, $validateEgressIp);
712 }
713 }
714 } else {
715 throw new Exception('Invalid request method: ' . $method);
716 }
717 if (is_resource($file)) {
718 fflush($file);
719 @fclose($file);
720 $fileSize = filesize($destinationPath);
721 if ($contentLength > 0 && $fileSize != $contentLength) {
722 throw new Exception('File size error: ' . $destinationPath . '; expected ' . $contentLength . ' bytes; received ' . $fileLength . ' bytes; saved ' . $fileSize . ' bytes to file');
723 }
724 return \true;
725 }
726 /**
727 * Triggered when an HTTP request finished. A plugin can for example listen to this and alter the response,
728 * status code, or finish a timer in case the plugin is measuring how long it took to execute the request
729 *
730 * @param string $url The URL that needs to be requested
731 * @param array $params HTTP params like
732 * - 'httpMethod' (eg GET, POST, ...),
733 * - 'body' the request body if the HTTP method needs to be posted
734 * - 'userAgent'
735 * - 'timeout' After how many seconds a request should time out
736 * - 'headers' An array of header strings like array('Accept-Language: en', '...')
737 * - 'verifySsl' A boolean whether SSL certificate should be verified
738 * - 'destinationPath' If set, the response of the HTTP request should be saved to this file
739 * - 'validateEgressIp' Whether the caller asked for SSRF-safe semantics. A listener
740 * resolving the request itself must honour them or leave it unhandled
741 * @param string &$response The response of the HTTP request, for example "{value: true}"
742 * @param int &$status The returned HTTP status code, for example "200"
743 * @param array &$headers The returned headers, eg array('Content-Length' => '5')
744 */
745 \Piwik\Piwik::postEvent('Http.sendHttpRequest.end', array($aUrl, $httpEventParams, &$response, &$status, &$headers));
746 if (!$getExtendedInfo) {
747 return trim($response);
748 } else {
749 return array('status' => $status, 'headers' => $headers, 'data' => $response);
750 }
751 }
752 public static function buildQuery($params)
753 {
754 return http_build_query($params, '', '&');
755 }
756 private static function buildHeadersForPost(string $requestBody) : string
757 {
758 $postHeader = "Content-Type: application/x-www-form-urlencoded\r\n";
759 $postHeader .= "Content-Length: " . strlen($requestBody) . "\r\n";
760 return $postHeader;
761 }
762 /**
763 * Downloads the next chunk of a specific file. The next chunk's byte range
764 * is determined by the existing file's size and the expected file size, which
765 * is stored in the option table before starting a download. The expected
766 * file size is obtained through a `HEAD` HTTP request.
767 *
768 * _Note: this function uses the **Range** HTTP header to accomplish downloading in
769 * parts. Not every server supports this header._
770 *
771 * The proper use of this function is to call it once per request. The browser
772 * should continue to send requests to Piwik which will in turn call this method
773 * until the file has completely downloaded. In this way, the user can be informed
774 * of a download's progress.
775 *
776 * **Example Usage**
777 *
778 * ```
779 * // browser JavaScript
780 * var downloadFile = function (isStart) {
781 * var ajax = new ajaxHelper();
782 * ajax.addParams({
783 * module: 'MyPlugin',
784 * action: 'myAction',
785 * isStart: isStart ? 1 : 0
786 * }, 'post');
787 * ajax.setCallback(function (response) {
788 * var progress = response.progress
789 * // ...update progress...
790 *
791 * downloadFile(false);
792 * });
793 * ajax.send();
794 * }
795 *
796 * downloadFile(true);
797 * ```
798 *
799 * ```
800 * // PHP controller action
801 * public function myAction()
802 * {
803 * $outputPath = PIWIK_INCLUDE_PATH . '/tmp/averybigfile.zip';
804 * $isStart = Common::getRequestVar('isStart', 1, 'int');
805 * Http::downloadChunk("https://bigfiles.com/averybigfile.zip", $outputPath, $isStart == 1);
806 * }
807 * ```
808 *
809 * @param string $url The url to download from.
810 * @param string $outputPath The path to the file to save/append to.
811 * @param bool $isContinuation `true` if this is the continuation of a download,
812 * or if we're starting a fresh one.
813 * @throws Exception if the file already exists and we're starting a new download,
814 * if we're trying to continue a download that never started
815 * @return array
816 * @api
817 */
818 public static function downloadChunk($url, $outputPath, $isContinuation)
819 {
820 // make sure file doesn't already exist if we're starting a new download
821 if (!$isContinuation && file_exists($outputPath)) {
822 throw new Exception(\Piwik\Piwik::translate('General_DownloadFail_FileExists', "'" . $outputPath . "'") . ' ' . \Piwik\Piwik::translate('General_DownloadPleaseRemoveExisting'));
823 }
824 // if we're starting a download, get the expected file size & save as an option
825 $downloadOption = $outputPath . '_expectedDownloadSize';
826 if (!$isContinuation) {
827 $expectedFileSizeResult = \Piwik\Http::sendHttpRequest($url, $timeout = 300, $userAgent = null, $destinationPath = null, $followDepth = 0, $acceptLanguage = \false, $byteRange = \false, $getExtendedInfo = \true, $httpMethod = 'HEAD');
828 $expectedFileSize = 0;
829 if (isset($expectedFileSizeResult['headers']['Content-Length'])) {
830 $expectedFileSize = (int) $expectedFileSizeResult['headers']['Content-Length'];
831 }
832 if ($expectedFileSize == 0) {
833 \Piwik\Log::info("HEAD request for '%s' failed, got following: %s", $url, print_r($expectedFileSizeResult, \true));
834 throw new Exception(\Piwik\Piwik::translate('General_DownloadFail_HttpRequestFail'));
835 }
836 \Piwik\Option::set($downloadOption, (string) $expectedFileSize);
837 } else {
838 $expectedFileSize = \Piwik\Option::get($downloadOption);
839 if ($expectedFileSize === \false) {
840 // sanity check
841 throw new Exception("Trying to continue a download that never started?! That's not supposed to happen...");
842 }
843 $expectedFileSize = (int) $expectedFileSize;
844 }
845 // if existing file is already big enough, then fail so we don't accidentally overwrite
846 // existing DB
847 $existingSize = file_exists($outputPath) ? filesize($outputPath) : 0;
848 if ($existingSize >= $expectedFileSize) {
849 throw new Exception(\Piwik\Piwik::translate('General_DownloadFail_FileExistsContinue', "'" . $outputPath . "'") . ' ' . \Piwik\Piwik::translate('General_DownloadPleaseRemoveExisting'));
850 }
851 // download a chunk of the file
852 $result = \Piwik\Http::sendHttpRequest($url, $timeout = 300, $userAgent = null, $destinationPath = null, $followDepth = 0, $acceptLanguage = \false, $byteRange = array($existingSize, min($existingSize + 1024 * 1024 - 1, $expectedFileSize)), $getExtendedInfo = \true);
853 if ($result['status'] < 200 || $result['status'] > 299) {
854 $result['data'] = self::truncateStr($result['data'], 1024);
855 \Piwik\Log::info("Failed to download range '%s-%s' of file from url '%s'. Got result: %s", $byteRange[0], $byteRange[1], $url, print_r($result, \true));
856 throw new Exception(\Piwik\Piwik::translate('General_DownloadFail_HttpRequestFail'));
857 }
858 // write chunk to file
859 $f = fopen($outputPath, 'ab');
860 fwrite($f, $result['data']);
861 fclose($f);
862 clearstatcache($clear_realpath_cache = \true, $outputPath);
863 return array('current_size' => filesize($outputPath), 'expected_file_size' => $expectedFileSize);
864 }
865 /**
866 * Will configure CURL handle $ch
867 * to use local list of Certificate Authorities,
868 */
869 public static function configCurlCertificate(&$ch)
870 {
871 $cacertPath = GeneralConfig::getConfigValue('custom_cacert_pem');
872 if (empty($cacertPath)) {
873 $cacertPath = CaBundle::getBundledCaBundlePath();
874 }
875 @curl_setopt($ch, \CURLOPT_CAINFO, $cacertPath);
876 }
877 public static function getUserAgent()
878 {
879 return !empty($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : 'Matomo/' . \Piwik\Version::VERSION;
880 }
881 public static function getClientHintsFromServerVariables() : array
882 {
883 $clientHints = [];
884 foreach ($_SERVER as $key => $value) {
885 if (0 === strpos(strtolower($key), strtolower('HTTP_SEC_CH_UA')) || 'X_HTTP_REQUESTED_WITH' === strtoupper($key)) {
886 $clientHints[$key] = $value;
887 }
888 }
889 ksort($clientHints);
890 return $clientHints;
891 }
892 /**
893 * Fetches a file located at `$url` and saves it to `$destinationPath`.
894 *
895 * @param string $url The URL of the file to download.
896 * @param string $destinationPath The path to download the file to.
897 * @param int $tries (deprecated)
898 * @param int $timeout The amount of seconds to wait before aborting the HTTP request.
899 * @return string|bool
900 * @throws Exception if the response cannot be saved to `$destinationPath`, if the HTTP response cannot be sent,
901 * if there are more than 5 redirects or if the request times out.
902 * @phpstan-return ($destinationPath is null ? false|string : bool)
903 * @api
904 */
905 public static function fetchRemoteFile($url, $destinationPath = null, $tries = 0, $timeout = 10)
906 {
907 @ignore_user_abort(\true);
908 \Piwik\SettingsServer::setMaxExecutionTime(0);
909 return self::sendHttpRequest($url, $timeout, 'Update', $destinationPath);
910 }
911 /**
912 * Utility function, parses an HTTP header line into key/value & sets header
913 * array with them.
914 *
915 * @param array $headers
916 * @param string $line
917 */
918 private static function parseHeaderLine(&$headers, $line) : void
919 {
920 $parts = explode(':', $line, 2);
921 if (count($parts) == 1) {
922 return;
923 }
924 [$name, $value] = $parts;
925 $name = trim($name);
926 $headers[$name] = trim($value);
927 /**
928 * With HTTP/2 Cloudflare is passing headers in lowercase (e.g. 'content-type' instead of 'Content-Type')
929 * which breaks any code which uses the header data.
930 */
931 $camelName = ucwords($name, '-');
932 if ($camelName !== $name) {
933 $headers[$camelName] = trim($value);
934 }
935 }
936 /**
937 * Whether two URLs share the same origin (scheme, host and effective port). Fails closed:
938 * a parse failure or missing component counts as a different origin.
939 */
940 private static function urlsSameOrigin(string $urlA, string $urlB) : bool
941 {
942 $a = @parse_url($urlA);
943 $b = @parse_url($urlB);
944 if (!is_array($a) || !is_array($b) || !isset($a['host'], $b['host'], $a['scheme'], $b['scheme'])) {
945 return \false;
946 }
947 $schemeA = strtolower($a['scheme']);
948 $schemeB = strtolower($b['scheme']);
949 if ($schemeA !== $schemeB || strcasecmp($a['host'], $b['host']) !== 0) {
950 return \false;
951 }
952 $defaultPort = $schemeA === 'https' ? 443 : 80;
953 $portA = isset($a['port']) ? (int) $a['port'] : $defaultPort;
954 $portB = isset($b['port']) ? (int) $b['port'] : $defaultPort;
955 return $portA === $portB;
956 }
957 /**
958 * Rebuilds a URL from its parse_url() parts with a replacement host, preserving every other
959 * component. Used on the SSRF-safe path, so the connected URL carries the pinned canonical host.
960 *
961 * @param array<string, mixed> $parts
962 */
963 private static function replaceUrlHost(array $parts, string $newHost) : string
964 {
965 // @todo PHP 8.1 min (Matomo 6): strpos($newHost, ':') !== false can become str_contains().
966 $scheme = isset($parts['scheme']) ? $parts['scheme'] . '://' : '';
967 $user = (string) ($parts['user'] ?? '');
968 $pass = isset($parts['pass']) ? ':' . $parts['pass'] : '';
969 $auth = $user !== '' ? $user . $pass . '@' : '';
970 $hostPart = strpos($newHost, ':') !== \false ? '[' . $newHost . ']' : $newHost;
971 $port = isset($parts['port']) ? ':' . $parts['port'] : '';
972 $path = (string) ($parts['path'] ?? '');
973 $query = isset($parts['query']) ? '?' . $parts['query'] : '';
974 $fragment = isset($parts['fragment']) ? '#' . $parts['fragment'] : '';
975 return $scheme . $auth . $hostPart . $port . $path . $query . $fragment;
976 }
977 /**
978 * Utility function that truncates a string to an arbitrary limit.
979 *
980 * @param string $str The string to truncate.
981 * @param int $limit The maximum length of the truncated string.
982 * @return string
983 */
984 private static function truncateStr($str, $limit)
985 {
986 if (strlen($str) > $limit) {
987 return substr($str, 0, $limit) . '...';
988 }
989 return $str;
990 }
991 /**
992 * Returns the If-Modified-Since HTTP header if it can be found. If it cannot be
993 * found, an empty string is returned.
994 *
995 * @return string
996 */
997 public static function getModifiedSinceHeader()
998 {
999 $modifiedSince = '';
1000 if (isset($_SERVER['HTTP_IF_MODIFIED_SINCE'])) {
1001 $modifiedSince = $_SERVER['HTTP_IF_MODIFIED_SINCE'];
1002 // strip any trailing data appended to header
1003 if (\false !== ($semicolonPos = strpos($modifiedSince, ';'))) {
1004 $modifiedSince = substr($modifiedSince, 0, $semicolonPos);
1005 }
1006 }
1007 return $modifiedSince;
1008 }
1009 /**
1010 * Returns Proxy to use for connecting via HTTP to given URL
1011 *
1012 * @param string $url
1013 * @return array{0: string|null, 1: string|null, 2: string|null, 3: string|null}
1014 */
1015 private static function getProxyConfiguration($url) : array
1016 {
1017 $hostname = \Piwik\UrlHelper::getHostFromUrl($url);
1018 if (\Piwik\Url::isLocalHost($hostname)) {
1019 return [null, null, null, null];
1020 }
1021 // proxy configuration
1022 $proxyHost = \Piwik\Config::getInstance()->proxy['host'];
1023 $proxyPort = \Piwik\Config::getInstance()->proxy['port'];
1024 $proxyUser = \Piwik\Config::getInstance()->proxy['username'];
1025 $proxyPassword = \Piwik\Config::getInstance()->proxy['password'];
1026 $proxyExclude = \Piwik\Config::getInstance()->proxy['exclude'];
1027 if (!empty($proxyExclude)) {
1028 $excludes = explode(',', $proxyExclude);
1029 $excludes = array_map('trim', $excludes);
1030 $excludes = array_filter($excludes);
1031 if (in_array($hostname, $excludes)) {
1032 return [null, null, null, null];
1033 }
1034 }
1035 return array($proxyHost, $proxyPort, $proxyUser, $proxyPassword);
1036 }
1037 /**
1038 * Checks if HTTPS is available
1039 *
1040 * @return bool
1041 */
1042 public static function isUpdatingOverHttps()
1043 {
1044 $openSslEnabled = extension_loaded('openssl');
1045 $usingMethodSupportingHttps = \Piwik\Http::getTransportMethod() !== 'socket';
1046 return $openSslEnabled && $usingMethodSupportingHttps;
1047 }
1048 }
1049