CVE-2022-36112: Server-Side Request Forgery (SSRF) (CWE-918)
value: GLPI stands for Gestionnaire Libre de Parc Informatique and is a Free Asset and IT Management Software package, that provides ITIL Service Desk features, licenses tracking and software auditing. Usage of RSS feeds or extenal calendar in planning is subject to SSRF exploit. Server-side requests can be used to scan server port or services opened on GLPI server or its private network. Queries responses are not exposed to end-user (blind SSRF). Users are advised to upgrade to version 10.0.3 to resolve this issue. There are no known workarounds.
CWE description: The web server receives a URL or similar request from an upstream component and retrieves the contents of this URL, but it does not sufficiently ensure that the request is being sent to the expected destination.
CVSS v3 base score: 5.8
Published: 2022-09-14T18:15Z
Repository: https://github.com/glpi-project/glpi
Language: PHP
Files changed: .composer-require-checker.config.json, css/legacy/includes/_planning.scss, inc/based_config.php, src/Planning.php, src/RSSFeed.php, src/System/Status/StatusChecker.php, src/Toolbox.php, templates/components/rss_feed.html.twig, tests/bootstrap.php, tests/functionnal/Toolbox.php
Commit date: 2022-09-14 13:55:17 +0200
Commit message: Mitigate SSRF exploits

Diff: diff --git a/.composer-require-checker.config.json b/.composer-require-checker.config.json
index 6761b781c10c..1acf299d78f1 100644
--- a/.composer-require-checker.config.json
+++ b/.composer-require-checker.config.json
@@ -32,11 +32,11 @@
     "GLPI_GRAPH_DIR", "GLPI_IDOR_EXPIRES", "GLPI_INSTALL_MODE", "GLPI_INVENTORY_DIR", "GLPI_LOCAL_I18N_DIR",
     "GLPI_LOCK_DIR", "GLPI_LOG_DIR", "GLPI_MARKETPLACE_DIR", "GLPI_MARKETPLACE_PLUGINS_API_URI",
     "GLPI_MARKETPLACE_PRERELEASES", "GLPI_NETWORK_REGISTRATION_API_URL", "GLPI_NETWORK_MAIL",
     "GLPI_NETWORK_SERVICES", "GLPI_PICTURE_DIR", "GLPI_PLUGIN_DOC_DIR", "GLPI_RSS_DIR", "GLPI_SESSION_DIR",
     "GLPI_TELEMETRY_URI", "GLPI_TMP_DIR", "GLPI_UPLOAD_DIR", "GLPI_USE_CSRF_CHECK", "GLPI_USE_IDOR_CHECK",
-    "GLPI_USER_AGENT_EXTRA_COMMENTS", "GLPI_VAR_DIR", "GLPI_CENTRAL_WARNINGS",
+    "GLPI_USER_AGENT_EXTRA_COMMENTS", "GLPI_VAR_DIR", "GLPI_CENTRAL_WARNINGS", "GLPI_SERVERSIDE_URL_ALLOWLIST",
 
     "// GLPI optionnal constants",
     "GLPI_FORCE_MAIL", "GLPI_LOG_LVL",
 
     "// GLPI test suite specific functions",
diff --git a/css/legacy/includes/_planning.scss b/css/legacy/includes/_planning.scss
index c92659f059a8..18c8a5f73eec 100644
--- a/css/legacy/includes/_planning.scss
+++ b/css/legacy/includes/_planning.scss
@@ -106,10 +106,15 @@
                width: 185px;
                overflow: hidden;
                text-overflow: ellipsis;
                display: inline-block;
                white-space: nowrap;
+
+               > i {
+                  color: $warning;
+                  float: right;
+               }
             }
 
             .filter-icon {
                float: right;
                padding: 0;
diff --git a/inc/based_config.php b/inc/based_config.php
index ab52f7ec79a8..0664d086c139 100644
--- a/inc/based_config.php
+++ b/inc/based_config.php
@@ -80,10 +80,15 @@ include_once(GLPI_ROOT . "/inc/autoload.function.php");
         'GLPI_CSRF_EXPIRES'              => '7200',
         'GLPI_CSRF_MAX_TOKENS'           => '100',
         'GLPI_USE_IDOR_CHECK'            => '1',
         'GLPI_IDOR_EXPIRES'              => '7200',
         'GLPI_ALLOW_IFRAME_IN_RICH_TEXT' => false,
+        'GLPI_SERVERSIDE_URL_ALLOWLIST'  => [
+            // allowlist (regex format) of URL that can be fetched from server side (used for RSS feeds and external calendars, among others)
+            // URL will be considered as safe as long as it matches at least one entry of the allowlist
+            '/^(https?|feed):\/\/[^@:]+(\/.*)?$/', // only accept http/https/feed protocols, and reject presence of @ (username) and : (protocol) in host part of URL
+        ],
 
       // Constants related to GLPI Project / GLPI Network external services
         'GLPI_TELEMETRY_URI'                => 'https://telemetry.glpi-project.org', // Telemetry project URL
         'GLPI_INSTALL_MODE'                 => is_dir(GLPI_ROOT . '/.git') ? 'GIT' : 'TARBALL', // Install mode for telemetry
         'GLPI_NETWORK_MAIL'                 => 'glpi@teclib.com',
@@ -125,11 +130,11 @@ include_once(GLPI_ROOT . "/inc/autoload.function.php");
    // 1. First, define constants that does not inherit from another one.
    // 2. Second, define constants that inherits from another one.
    // This logic is quiet simple and is not made to handle chain inheritance.
     $inherit_pattern = '/\{(?<name>GLPI_[\w]+)\}/';
     foreach ($constants as $key => $value) {
-        if (!defined($key) && !preg_match($inherit_pattern, $value)) {
+        if (!defined($key) && (!is_string($value) || !preg_match($inherit_pattern, $value))) {
             define($key, $value);
         }
     }
     foreach ($constants as $key => $value) {
         if (!defined($key)) {
diff --git a/src/Planning.php b/src/Planning.php
index 8962207d7b9c..fde20e9ccbe8 100644
--- a/src/Planning.php
+++ b/src/Planning.php
@@ -1001,11 +1001,17 @@ class Planning extends CommonGLPI
                 $icon = "users";
             }
             echo "<i class='actor_icon fa fa-fw fa-$icon'></i>";
         }
 
-        echo "<label for='$filter_key'>$title</label>";
+        echo "<label for='$filter_key'>";
+        echo $title;
+        if ($filter_data['type'] == 'external' && !Toolbox::isUrlSafe($filter_data['url'])) {
+            $warning = sprintf(__s('URL "%s" is not allowed by your administrator.'), $filter_data['url']);
+            echo "<i class='fas fa-exclamation-triangle' title='{$warning}'></i>";
+        }
+        echo "</label>";
 
         $color = self::$palette_bg[$params['filter_color_index']];
         if (isset($filter_data['color']) && !empty($filter_data['color'])) {
             $color = $filter_data['color'];
         } else {
@@ -1394,10 +1400,19 @@ class Planning extends CommonGLPI
      *
      * @return void
      */
     public static function sendAddExternalForm($params = [])
     {
+        if (!Toolbox::isUrlSafe($params['url'])) {
+            Session::addMessageAfterRedirect(
+                sprintf(__('URL "%s" is not allowed by your administrator.'), $params['url']),
+                false,
+                ERROR
+            );
+            return;
+        }
+
         $_SESSION['glpi_plannings']['plannings']['external_' . md5($params['url'])] = [
             'color'   => self::getPaletteColor('bg', $_SESSION['glpi_plannings_color_index']),
             'display' => true,
             'type'    => 'external',
             'name'    => $params['name'],
diff --git a/src/RSSFeed.php b/src/RSSFeed.php
index 2c597d598e6b..06ee7d7eda0f 100644
--- a/src/RSSFeed.php
+++ b/src/RSSFeed.php
@@ -601,16 +601,15 @@ class RSSFeed extends CommonDBVisible implements ExtraVisibilityCriteria
                 }
         }
         return false;
     }
 
-
-    /**
-     * @see CommonDBTM::prepareInputForAdd()
-     **/
     public function prepareInputForAdd($input)
     {
+        if (!$this->checkUrlInput($input['url'])) {
+            return false;
+        }
 
         if ($feed = self::getRSSFeed($input['url'])) {
             $input['have_error'] = 0;
             $input['name']       = addslashes($feed->get_title());
             if (empty($input['comment'])) {
@@ -626,16 +625,15 @@ class RSSFeed extends CommonDBVisible implements ExtraVisibilityCriteria
             $input["name"] = __('Without title');
         }
         return $input;
     }
 
-
-    /**
-     * @see CommonDBTM::prepareInputForAdd()
-     **/
     public function prepareInputForUpdate($input)
     {
+        if (array_key_exists('url', $input) && !$this->checkUrlInput($input['url'])) {
+            return false;
+        }
 
         if (
             empty($input['name'])
             && isset($input['url'])
             && ($feed = self::getRSSFeed($input['url']))
@@ -646,10 +644,28 @@ class RSSFeed extends CommonDBVisible implements ExtraVisibilityCriteria
             }
         }
         return $input;
     }
 
+    /**
+     * Check URL given in input.
+     * @param string $url
+     * @return bool
+     */
+    private function checkUrlInput(string $url): bool
+    {
+        if (parse_url($url) === false) {
+            Session::addMessageAfterRedirect(__('Feed URL is invalid.'), false, ERROR);
+            return false;
+        } elseif (!Toolbox::isUrlSafe($url)) {
+            Session::addMessageAfterRedirect(sprintf(__('URL "%s" is not allowed by your administrator.'), $url), false, ERROR);
+            return false;
+        }
+
+        return true;
+    }
+
 
     public function pre_updateInDB()
     {
 
        // Set new user if initial user have been deleted
@@ -767,11 +783,15 @@ class RSSFeed extends CommonDBVisible implements ExtraVisibilityCriteria
         echo "</td></tr>\n";
 
         echo "<tr class='tab_bg_2'>";
         echo "<td>" . __('Error retrieving RSS feed') . "</td>";
         echo "<td>";
-        echo Dropdown::getYesNo($this->fields['have_error']);
+        if ($this->fields['have_error'] && !Toolbox::isUrlSafe($this->fields['url'])) {
+            echo sprintf(__('URL "%s" is not allowed by your administrator.'), $this->fields['url']);
+        } else {
+            echo Dropdown::getYesNo($this->fields['have_error']);
+        }
         echo "</td>";
         if ($this->fields['have_error']) {
             echo "<td>" . __('RSS feeds found');
             echo "</td><td>";
             $this->showDiscoveredFeeds();
@@ -821,29 +841,30 @@ class RSSFeed extends CommonDBVisible implements ExtraVisibilityCriteria
     {
 
         if (!$this->canViewItem()) {
             return false;
         }
-        $feed = self::getRSSFeed($this->fields['url'], $this->fields['refresh_rate']);
         $rss_feed = [
             'items'  => []
         ];
         echo "<div class='firstbloc'>";
-        if (!$feed || $feed->error()) {
-            $rss_feed['error'] = !$feed ? __('Error retrieving RSS feed') : $feed->error();
-            $this->setError(true);
-        } else {
+        if ($feed = self::getRSSFeed($this->fields['url'], $this->fields['refresh_rate'])) {
             $this->setError(false);
             $rss_feed['title'] = $feed->get_title();
             foreach ($feed->get_items(0, $this->fields['max_items']) as $item) {
                 $rss_feed['items'][] = [
                     'title'     => $item->get_title(),
                     'link'      => $item->get_permalink(),
                     'timestamp' => Html::convDateTime($item->get_date('Y-m-d H:i:s')),
                     'content'   => $item->get_content()
                 ];
             }
+        } else {
+            $rss_feed['error'] = !Toolbox::isUrlSafe($this->fields['url'])
+                ? sprintf(__('URL "%s" is not allowed by your administrator.'), $this->fields['url'])
+                : __('Error retrieving RSS feed');
+            $this->setError(true);
         }
 
         TemplateRenderer::getInstance()->display('components/rss_feed.html.twig', [
             'rss_feed'  => $rss_feed
         ]);
@@ -855,10 +876,13 @@ class RSSFeed extends CommonDBVisible implements ExtraVisibilityCriteria
      *
      * @return void
      **/
     public function showDiscoveredFeeds()
     {
+        if (!Toolbox::isUrlSafe($this->fields['url'])) {
+            return;
+        }
 
         $feed = new SimplePie();
         $feed->set_cache_location(GLPI_RSS_DIR);
         $feed->enable_cache(false);
         $feed->set_feed_url($this->fields['url']);
@@ -901,10 +925,14 @@ class RSSFeed extends CommonDBVisible implements ExtraVisibilityCriteria
      **/
     public static function getRSSFeed($url, $cache_duration = DAY_TIMESTAMP)
     {
         global $CFG_GLPI;
 
+        if (!Toolbox::isUrlSafe($url)) {
+            return false;
+        }
+
         if (Sanitizer::isHtmlEncoded($url)) {
             $url = Sanitizer::decodeHtmlSpecialChars($url);
         }
         $feed = new SimplePie();
         $feed->set_cache_location(GLPI_RSS_DIR);
diff --git a/src/System/Status/StatusChecker.php b/src/System/Status/StatusChecker.php
index b0341f75e0ea..ef152df73c36 100644
--- a/src/System/Status/StatusChecker.php
+++ b/src/System/Status/StatusChecker.php
@@ -390,15 +390,25 @@ final class StatusChecker
                 $url = $CFG_GLPI['cas_host'];
                 if (!empty($CFG_GLPI['cas_port'])) {
                     $url .= ':' . (int)$CFG_GLPI['cas_port'];
                 }
                 $url .= '/' . $CFG_GLPI['cas_uri'];
-                $data = Toolbox::getURLContent($url);
-                if (!empty($data)) {
-                    $status['status'] = self::STATUS_OK;
+                if (Toolbox::isUrlSafe($url)) {
+                    $data = Toolbox::getURLContent($url);
+                    if (!empty($data)) {
+                        $status['status'] = self::STATUS_OK;
+                    } else {
+                        $status['status'] = self::STATUS_PROBLEM;
+                    }
                 } else {
-                    $status['status'] = self::STATUS_PROBLEM;
+                    $status['status'] = self::STATUS_NO_DATA;
+                    if (!$public_only) {
+                        $status['status_msg'] = sprintf(
+                            __('URL "%s" is not considered safe and cannot be fetched from GLPI server.'),
+                            $url
+                        );
+                    }
                 }
             }
         }
 
         return $status;
diff --git a/src/Toolbox.php b/src/Toolbox.php
index f3e8778044c5..cac9734e106b 100644
--- a/src/Toolbox.php
+++ b/src/Toolbox.php
@@ -1092,10 +1092,14 @@ class Toolbox
     public static function checkNewVersionAvailable()
     {
        //parse github releases (get last version number)
         $error = "";
         $json_gh_releases = self::getURLContent("https://api.github.com/repos/glpi-project/glpi/releases", $error);
+        if (empty($json_gh_releases)) {
+            return $error;
+        }
+
         $all_gh_releases = json_decode($json_gh_releases, true);
         $released_tags = [];
         foreach ($all_gh_releases as $release) {
             if ($release['prerelease'] == false) {
                 $released_tags[] =  $release['tag_name'];
@@ -1327,10 +1331,39 @@ class Toolbox
         }
         return $out;
     }
 
 
+    /**
+     * Check an url is safe.
+     * Used to mitigate SSRF exploits.
+     *
+     * @since 10.0.3
+     *
+     * @param string    $url        URL to check
+     * @param array     $allowlist  Allowlist (regex array)
+     *
+     * @return bool
+     */
+    public static function isUrlSafe(string $url, array $allowlist = GLPI_SERVERSIDE_URL_ALLOWLIST): bool
+    {
+        foreach ($allowlist as $allow_regex) {
+            $result = preg_match($allow_regex, $url);
+            if ($result === false) {
+                trigger_error(
+                    sprintf('Unable to validate URL safeness. Following regex is probably invalid: "%s".', $allow_regex),
+                    E_USER_WARNING
+                );
+            } elseif ($result === 1) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+
     /**
      * Get a web page. Use proxy if configured
      *
      * @param string  $url    URL to retrieve
      * @param string  $msgerr set if problem encountered (default NULL)
@@ -1338,11 +1371,12 @@ class Toolbox
      *
      * @return string content of the page (or empty)
      **/
     public static function getURLContent($url, &$msgerr = null, $rec = 0)
     {
-        $content = self::callCurl($url);
+        $curl_error = null;
+        $content = self::callCurl($url, [], $msgerr, $curl_error, true);
         return $content;
     }
 
     /**
      * Executes a curl call
@@ -1352,14 +1386,28 @@ class Toolbox
      * @param string $msgerr      will contains a human readable error string if an error occurs of url returns empty contents
      * @param string $curl_error  will contains original curl error string if an error occurs
      *
      * @return string
      */
-    public static function callCurl($url, array $eopts = [], &$msgerr = null, &$curl_error = null)
-    {
+    public static function callCurl(
+        $url,
+        array $eopts = [],
+        &$msgerr = null,
+        &$curl_error = null,
+        bool $check_url_safeness = false
+    ) {
         global $CFG_GLPI;
 
+        if ($check_url_safeness && !Toolbox::isUrlSafe($url)) {
+            $msgerr = sprintf(
+                __('URL "%s" is not considered safe and cannot be fetched from GLPI server.'),
+                $url
+            );
+            trigger_error(sprintf('Unsafe URL "%s" fetching has been blocked.', $url), E_USER_NOTICE);
+            return '';
+        }
+
         $content = "";
         $taburl  = parse_url($url);
 
         $defaultport = 80;
 
diff --git a/templates/components/rss_feed.html.twig b/templates/components/rss_feed.html.twig
index 297c5ff64618..6d4ecbf6139a 100644
--- a/templates/components/rss_feed.html.twig
+++ b/templates/components/rss_feed.html.twig
@@ -31,11 +31,11 @@
  # ---------------------------------------------------------------------
  #}
 
 <div class="firstbloc">
    {% if rss_feed.error is defined and rss_feed.error is not null %}
-      {{ __('Error retrieving RSS feed') }}
+      {{ rss_feed.error }}
    {% endif %}
    <table class="table table-striped table-hover">
       <thead>
          <tr>
             <th colspan="3">{{ rss_feed.title }}</th>
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
index 8a64b8781b12..2eba1739d053 100644
--- a/tests/bootstrap.php
+++ b/tests/bootstrap.php
@@ -53,10 +53,18 @@ define(
         GLPI_ROOT . '/plugins',
         GLPI_ROOT . '/tests/fixtures/plugins',
     ]
 );
 
+define(
+    'GLPI_SERVERSIDE_URL_ALLOWLIST',
+    [
+        '/^(https?|feed):\/\/[^@:]+(\/.*)?$/', // default allowlist entry
+        '/^file:\/\/.*\.ics$/', // calendar mockups
+    ]
+);
+
 define('TU_USER', '_test_user');
 define('TU_PASS', 'PhpUnit_4');
 
 global $CFG_GLPI, $GLPI_CACHE;
 
diff --git a/tests/functionnal/Toolbox.php b/tests/functionnal/Toolbox.php
index ecbda6622a69..001739dac970 100644
--- a/tests/functionnal/Toolbox.php
+++ b/tests/functionnal/Toolbox.php
@@ -1429,6 +1429,91 @@ class Toolbox extends DbTestCase
     public function testGetMioSizeFromString(string $size, $expected): void
     {
         $result = \Toolbox::getMioSizeFromString($size);
         $this->variable($result)->isEqualTo($expected);
     }
+
+    protected function safeUrlProvider(): iterable
+    {
+        // Invalid URLs are refused
+        yield [
+            'url'      => '',
+            'expected' => false,
+        ];
+        yield [
+            'url'      => ' ',
+            'expected' => false,
+        ];
+
+        // Invalid schemes are refused
+        yield [
+            'url'      => 'file://tmp/test',
+            'expected' => false,
+        ];
+        yield [
+            'url'      => 'test://localhost/',
+            'expected' => false,
+        ];
+
+        // Local file are refused
+        yield [
+            'url'      => '//tmp/test',
+            'expected' => false,
+        ];
+
+        // http, https and feed URLs are accepted, unless they contains a user or port information
+        foreach (['http', 'https', 'feed'] as $scheme) {
+            foreach (['', '/', '/path/to/feed.php'] as $path) {
+                yield [
+                    'url'      => sprintf('%s://localhost%s', $scheme, $path),
+                    'expected' => true,
+                ];
+                yield [
+                    'url'      => sprintf('%s://localhost:8080%s', $scheme, $path),
+                    'expected' => false,
+                ];
+                yield [
+                    'url'      => sprintf('%s://test@localhost%s', $scheme, $path),
+                    'expected' => false,
+                ];
+                yield [
+                    'url'      => sprintf('%s://test:pass@localhost%s', $scheme, $path),
+                    'expected' => false,
+                ];
+            }
+        }
+
+        // Custom allowlist with multiple entries
+        $custom_allowlist = [
+            '|^https://\w+:[^/]+@calendar.mydomain.tld/|',
+            '|//intra.mydomain.tld/|',
+        ];
+        yield [
+            'url'       => 'https://calendar.external.tld/',
+            'expected'  => false,
+            'allowlist' => $custom_allowlist,
+        ];
+        yield [
+            'url'       => 'https://user:pass@calendar.mydomain.tld/',
+            'expected'  => true, // validates first item of allowlist
+            'allowlist' => $custom_allowlist,
+        ];
+        yield [
+            'url'       => 'http://intra.mydomain.tld/news.feed.php',
+            'expected'  => true, // validates second item of allowlist
+            'allowlist' => $custom_allowlist,
+        ];
+    }
+
+
+    /**
+     * @dataProvider safeUrlProvider
+     */
+    public function testIsUrlSafe(string $url, bool $expected, ?array $allowlist = null): void
+    {
+        $params = [$url];
+        if ($allowlist !== null) {
+            $params[] = $allowlist;
+        }
+        $this->boolean(call_user_func_array('Toolbox::isUrlSafe', $params))->isEqualTo($expected);
+    }
 }

Vulnerable code:     "GLPI_USER_AGENT_EXTRA_COMMENTS", "GLPI_VAR_DIR", "GLPI_CENTRAL_WARNINGS",
        if (!defined($key) && !preg_match($inherit_pattern, $value)) {
        echo "<label for='$filter_key'>$title</label>";

    /**
     * @see CommonDBTM::prepareInputForAdd()
     **/

    /**
     * @see CommonDBTM::prepareInputForAdd()
     **/
        echo Dropdown::getYesNo($this->fields['have_error']);
        $feed = self::getRSSFeed($this->fields['url'], $this->fields['refresh_rate']);
        if (!$feed || $feed->error()) {
            $rss_feed['error'] = !$feed ? __('Error retrieving RSS feed') : $feed->error();
            $this->setError(true);
        } else {
                $data = Toolbox::getURLContent($url);
                if (!empty($data)) {
                    $status['status'] = self::STATUS_OK;
                    $status['status'] = self::STATUS_PROBLEM;
        $content = self::callCurl($url);
    public static function callCurl($url, array $eopts = [], &$msgerr = null, &$curl_error = null)
    {
      {{ __('Error retrieving RSS feed') }}
Fixed code:     "GLPI_USER_AGENT_EXTRA_COMMENTS", "GLPI_VAR_DIR", "GLPI_CENTRAL_WARNINGS", "GLPI_SERVERSIDE_URL_ALLOWLIST",

               > i {
                  color: $warning;
                  float: right;
               }
        'GLPI_SERVERSIDE_URL_ALLOWLIST'  => [
            // allowlist (regex format) of URL that can be fetched from server side (used for RSS feeds and external calendars, among others)
            // URL will be considered as safe as long as it matches at least one entry of the allowlist
            '/^(https?|feed):\/\/[^@:]+(\/.*)?$/', // only accept http/https/feed protocols, and reject presence of @ (username) and : (protocol) in host part of URL
        ],
        if (!defined($key) && (!is_string($value) || !preg_match($inherit_pattern, $value))) {
        echo "<label for='$filter_key'>";
        echo $title;
        if ($filter_data['type'] == 'external' && !Toolbox::isUrlSafe($filter_data['url'])) {
            $warning = sprintf(__s('URL "%s" is not allowed by your administrator.'), $filter_data['url']);
            echo "<i class='fas fa-exclamation-triangle' title='{$warning}'></i>";
        }
        echo "</label>";
        if (!Toolbox::isUrlSafe($params['url'])) {
            Session::addMessageAfterRedirect(
                sprintf(__('URL "%s" is not allowed by your administrator.'), $params['url']),
                false,
                ERROR
            );
            return;
        }

        if (!$this->checkUrlInput($input['url'])) {
            return false;
        }
        if (array_key_exists('url', $input) && !$this->checkUrlInput($input['url'])) {
            return false;
        }
    /**
     * Check URL given in input.
     * @param string $url
     * @return bool
     */
    private function checkUrlInput(string $url): bool
    {
        if (parse_url($url) === false) {
            Session::addMessageAfterRedirect(__('Feed URL is invalid.'), false, ERROR);
            return false;
        } elseif (!Toolbox::isUrlSafe($url)) {
            Session::addMessageAfterRedirect(sprintf(__('URL "%s" is not allowed by your administrator.'), $url), false, ERROR);
            return false;
        }

        return true;
    }

        if ($this->fields['have_error'] && !Toolbox::isUrlSafe($this->fields['url'])) {
            echo sprintf(__('URL "%s" is not allowed by your administrator.'), $this->fields['url']);
        } else {
            echo Dropdown::getYesNo($this->fields['have_error']);
        }
        if ($feed = self::getRSSFeed($this->fields['url'], $this->fields['refresh_rate'])) {
        } else {
            $rss_feed['error'] = !Toolbox::isUrlSafe($this->fields['url'])
                ? sprintf(__('URL "%s" is not allowed by your administrator.'), $this->fields['url'])
                : __('Error retrieving RSS feed');
            $this->setError(true);
        if (!Toolbox::isUrlSafe($this->fields['url'])) {
            return;
        }
        if (!Toolbox::isUrlSafe($url)) {
            return false;
        }

                if (Toolbox::isUrlSafe($url)) {
                    $data = Toolbox::getURLContent($url);
                    if (!empty($data)) {
                        $status['status'] = self::STATUS_OK;
                    } else {
                        $status['status'] = self::STATUS_PROBLEM;
                    }
                    $status['status'] = self::STATUS_NO_DATA;
                    if (!$public_only) {
                        $status['status_msg'] = sprintf(
                            __('URL "%s" is not considered safe and cannot be fetched from GLPI server.'),
                            $url
                        );
                    }
        if (empty($json_gh_releases)) {
            return $error;
        }

    /**
     * Check an url is safe.
     * Used to mitigate SSRF exploits.
     *
     * @since 10.0.3
     *
     * @param string    $url        URL to check
     * @param array     $allowlist  Allowlist (regex array)
     *
     * @return bool
     */
    public static function isUrlSafe(string $url, array $allowlist = GLPI_SERVERSIDE_URL_ALLOWLIST): bool
    {
        foreach ($allowlist as $allow_regex) {
            $result = preg_match($allow_regex, $url);
            if ($result === false) {
                trigger_error(
                    sprintf('Unable to validate URL safeness. Following regex is probably invalid: "%s".', $allow_regex),
                    E_USER_WARNING
                );
            } elseif ($result === 1) {
                return true;
            }
        }

        return false;
    }


        $curl_error = null;
        $content = self::callCurl($url, [], $msgerr, $curl_error, true);
    public static function callCurl(
        $url,
        array $eopts = [],
        &$msgerr = null,
        &$curl_error = null,
        bool $check_url_safeness = false
    ) {
        if ($check_url_safeness && !Toolbox::isUrlSafe($url)) {
            $msgerr = sprintf(
                __('URL "%s" is not considered safe and cannot be fetched from GLPI server.'),
                $url
            );
            trigger_error(sprintf('Unsafe URL "%s" fetching has been blocked.', $url), E_USER_NOTICE);
            return '';
        }

      {{ rss_feed.error }}
define(
    'GLPI_SERVERSIDE_URL_ALLOWLIST',
    [
        '/^(https?|feed):\/\/[^@:]+(\/.*)?$/', // default allowlist entry
        '/^file:\/\/.*\.ics$/', // calendar mockups
    ]
);


    protected function safeUrlProvider(): iterable
    {
        // Invalid URLs are refused
        yield [
            'url'      => '',
            'expected' => false,
        ];
        yield [
            'url'      => ' ',
            'expected' => false,
        ];

        // Invalid schemes are refused
        yield [
            'url'      => 'file://tmp/test',
            'expected' => false,
        ];
        yield [
            'url'      => 'test://localhost/',
            'expected' => false,
        ];

        // Local file are refused
        yield [
            'url'      => '//tmp/test',
            'expected' => false,
        ];

        // http, https and feed URLs are accepted, unless they contains a user or port information
        foreach (['http', 'https', 'feed'] as $scheme) {
            foreach (['', '/', '/path/to/feed.php'] as $path) {
                yield [
                    'url'      => sprintf('%s://localhost%s', $scheme, $path),
                    'expected' => true,
                ];
                yield [
                    'url'      => sprintf('%s://localhost:8080%s', $scheme, $path),
                    'expected' => false,
                ];
                yield [
                    'url'      => sprintf('%s://test@localhost%s', $scheme, $path),
                    'expected' => false,
                ];
                yield [
                    'url'      => sprintf('%s://test:pass@localhost%s', $scheme, $path),
                    'expected' => false,
                ];
            }
        }

        // Custom allowlist with multiple entries
        $custom_allowlist = [
            '|^https://\w+:[^/]+@calendar.mydomain.tld/|',
            '|//intra.mydomain.tld/|',
        ];
        yield [
            'url'       => 'https://calendar.external.tld/',
            'expected'  => false,
            'allowlist' => $custom_allowlist,
        ];
        yield [
            'url'       => 'https://user:pass@calendar.mydomain.tld/',
            'expected'  => true, // validates first item of allowlist
            'allowlist' => $custom_allowlist,
        ];
        yield [
            'url'       => 'http://intra.mydomain.tld/news.feed.php',
            'expected'  => true, // validates second item of allowlist
            'allowlist' => $custom_allowlist,
        ];
    }


    /**
     * @dataProvider safeUrlProvider
     */
    public function testIsUrlSafe(string $url, bool $expected, ?array $allowlist = null): void
    {
        $params = [$url];
        if ($allowlist !== null) {
            $params[] = $allowlist;
        }
        $this->boolean(call_user_func_array('Toolbox::isUrlSafe', $params))->isEqualTo($expected);
    }