CVE-2022-36112 (PHP, published 2022-09-14T18:15Z)
Description: 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.
Severity: nan (CVSS v3 base score: 5.8)
Weakness: CWE-918 \u2014 Server-Side Request Forgery (SSRF)
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.
Repository: https://github.com/glpi-project/glpi
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 message: Mitigate SSRF exploits


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);
    }