diff --git a/application/utils.inc.php b/application/utils.inc.php index 43786e687e..fbe77ded15 100644 --- a/application/utils.inc.php +++ b/application/utils.inc.php @@ -1886,6 +1886,9 @@ SQL; $aHTTPHeaders[] = trim($sHeaderString); } // Default options, can be overloaded/extended with the 4th parameter of this method, see above $aCurlOptions + $iCurlAllowedProtocols = self::ConvertProtocolsToCurlOption( + self::GetConfig()->Get('security.post_requests.allowed_protocols') + ); $aOptions = [ CURLOPT_RETURNTRANSFER => true, // return the content of the request CURLOPT_HEADER => false, // don't return the headers in the output @@ -1908,6 +1911,9 @@ SQL; $aAllOptions = $aCurlOptions + $aOptions; $ch = curl_init($sUrl); curl_setopt_array($ch, $aAllOptions); + if (!curl_setopt($ch, CURLOPT_PROTOCOLS, $iCurlAllowedProtocols)) { + throw new Exception('Unable to enforce allowed protocols for cURL request'); + } $response = curl_exec($ch); $iErr = curl_errno($ch); $sErrMsg = curl_error($ch); @@ -1929,6 +1935,40 @@ SQL; return $response; } + private static function ConvertProtocolsToCurlOption(string $sProtocols): int + { + $aProtocols = explode(',', $sProtocols); + + $iCurlProtocols = 0; + foreach ($aProtocols as $sProtocol) { + $iCurlProtocols |= match (strtolower(trim($sProtocol))) { + 'dict' => defined('CURLPROTO_DICT') ? CURLPROTO_DICT : 0, + 'file' => defined('CURLPROTO_FILE') ? CURLPROTO_FILE : 0, + 'ftp' => defined('CURLPROTO_FTP') ? CURLPROTO_FTP : 0, + 'ftps' => defined('CURLPROTO_FTPS') ? CURLPROTO_FTPS : 0, + 'gopher' => defined('CURLPROTO_GOPHER') ? CURLPROTO_GOPHER : 0, + 'http' => defined('CURLPROTO_HTTP') ? CURLPROTO_HTTP : 0, + 'https' => defined('CURLPROTO_HTTPS') ? CURLPROTO_HTTPS : 0, + 'imap' => defined('CURLPROTO_IMAP') ? CURLPROTO_IMAP : 0, + 'imaps' => defined('CURLPROTO_IMAPS') ? CURLPROTO_IMAPS : 0, + 'ldap' => defined('CURLPROTO_LDAP') ? CURLPROTO_LDAP : 0, + 'ldaps' => defined('CURLPROTO_LDAPS') ? CURLPROTO_LDAPS : 0, + 'mqtt' => defined('CURLPROTO_MQTT') ? CURLPROTO_MQTT : 0, + 'pop3' => defined('CURLPROTO_POP3') ? CURLPROTO_POP3 : 0, + 'pop3s' => defined('CURLPROTO_POP3S') ? CURLPROTO_POP3S : 0, + 'rtsp' => defined('CURLPROTO_RTSP') ? CURLPROTO_RTSP : 0, + 'scp' => defined('CURLPROTO_SCP') ? CURLPROTO_SCP : 0, + 'sftp' => defined('CURLPROTO_SFTP') ? CURLPROTO_SFTP : 0, + 'smtp' => defined('CURLPROTO_SMTP') ? CURLPROTO_SMTP : 0, + 'smtps' => defined('CURLPROTO_SMTPS') ? CURLPROTO_SMTPS : 0, + 'telnet' => defined('CURLPROTO_TELNET') ? CURLPROTO_TELNET : 0, + 'tftp' => defined('CURLPROTO_TFTP') ? CURLPROTO_TFTP : 0, + default => throw new Exception("Unsupported protocol: $sProtocol"), + }; + } + return $iCurlProtocols; + } + public static function QuoteForPHP(string $sValue): string { $sEscaped = str_replace(['\\', "'"], ['\\\\', "\\'"], $sValue); diff --git a/core/config.class.inc.php b/core/config.class.inc.php index b8be9b0932..e0a9f25e38 100644 --- a/core/config.class.inc.php +++ b/core/config.class.inc.php @@ -1786,6 +1786,14 @@ class Config 'source_of_value' => '', 'show_in_conf_sample' => false, ], + 'security.post_requests.allowed_protocols' => [ + 'type' => 'string', + 'description' => 'List of allowed protocols that will be used for post requests. Allowed values: dict, file, ftp, ftps, gopher, http, https, imap, imaps, ldap, ldaps, mqtt, pop3, pop3s, rtsp, scp, sftp, smtp, smtps, telnet, tftp', + 'default' => 'http,https,ftp,ftps,sftp', + 'value' => 'http,https,ftp,ftps,sftp', + 'source_of_value' => '', + 'show_in_conf_sample' => false, + ], 'behind_reverse_proxy' => [ 'type' => 'bool', 'description' => 'If true, then proxies custom header (X-Forwarded-*) are taken into account. Use only if the webserver is not publicly accessible (reachable only by the reverse proxy)', diff --git a/tests/php-unit-tests/unitary-tests/application/utilsTest.php b/tests/php-unit-tests/unitary-tests/application/utilsTest.php index b0cf9a6f21..881fc5a6b5 100644 --- a/tests/php-unit-tests/unitary-tests/application/utilsTest.php +++ b/tests/php-unit-tests/unitary-tests/application/utilsTest.php @@ -72,6 +72,30 @@ class utilsTest extends ItopTestCase ]; } + /** + * @dataProvider DoPostRequestAllowedProtocolsProvider + */ + public function testDoPostRequestRespectsAllowedProtocolsConfig(string $sAllowedProtocols, bool $bFileProtocolAllowed): void + { + // Config::Set changes the in-memory setting only; the test framework reloads config after this test class. + utils::GetConfig()->Set('security.post_requests.allowed_protocols', $sAllowedProtocols); + if (!$bFileProtocolAllowed) { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Problem opening URL'); + } else { + $this->expectNotToPerformAssertions(); + } + utils::DoPostRequest('file:///tmp', ['payload' => 'test']); + } + + public function DoPostRequestAllowedProtocolsProvider(): array + { + return [ + 'file protocol is allowed' => ['file,http,https', true], + 'file protocol is not allowed' => ['http,https', false], + ]; + } + /** * @dataProvider realPathDataProvider * @covers utils::RealPath()