CVE-2023-4624 (PHP, published 2023-08-30T13:15Z)
Description: value: Server-Side Request Forgery (SSRF) in GitHub repository bookstackapp/bookstack prior to v23.08.
Severity: nan (CVSS v3 base score: 2.4)
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/bookstackapp/bookstack
Files changed:
app/Activity/DispatchWebhookJob.php
app/Config/app.php
app/Util/SsrUrlValidator.php
lang/en/errors.php
tests/Unit/SsrUrlValidatorTest.php
Commit message: Security: Added new SSR allow list and validator

Included unit tests to cover validator functionality.
Added to webhooks.
Still need to do testing specifically for webhooks.


Vulnerable code:

Fixed code:
use BookStack\Util\SsrUrlValidator;
            (new SsrUrlValidator())->ensureAllowed($this->webhook->endpoint);

    // A list of the sources/hostnames that can be reached by application SSR calls.
    // This is used wherever users can provide URLs/hosts in-platform, like for webhooks.
    // Host-specific functionality (usually controlled via other options) like auth
    // or user avatars for example, won't use this list.
    // Space seperated if multiple. Can use '*' as a wildcard.
    // Values will be compared prefix-matched, case-insensitive, against called SSR urls.
    // Defaults to allow all hosts.
    'ssr_hosts' => env('ALLOWED_SSR_HOSTS', '*'),

<?php

namespace BookStack\Util;

use BookStack\Exceptions\HttpFetchException;

class SsrUrlValidator
{
    protected string $config;

    public function __construct(string $config = null)
    {
        $this->config = $config ?? config('app.ssr_hosts') ?? '';
    }

    /**
     * @throws HttpFetchException
     */
    public function ensureAllowed(string $url): void
    {
        if (!$this->allowed($url)) {
            throw new HttpFetchException(trans('errors.http_ssr_url_no_match'));
        }
    }

    /**
     * Check if the given URL is allowed by the configured SSR host values.
     */
    public function allowed(string $url): bool
    {
        $allowed = $this->getHostPatterns();

        foreach ($allowed as $pattern) {
            if ($this->urlMatchesPattern($url, $pattern)) {
                return true;
            }
        }

        return false;
    }

    protected function urlMatchesPattern($url, $pattern): bool
    {
        $pattern = trim($pattern);
        $url = trim($url);

        if (empty($pattern) || empty($url)) {
            return false;
        }

        $quoted = preg_quote($pattern, '/');
        $regexPattern = str_replace('\*', '.*', $quoted);

        return preg_match('/^' . $regexPattern . '.*$/i', $url);
    }

    /**
     * @return string[]
     */
    protected function getHostPatterns(): array
    {
        return explode(' ', strtolower($this->config));
    }
}
    // HTTP errors
    'http_ssr_url_no_match' => 'The URL does not match the configured allowed SSR hosts',
<?php

namespace Tests\Unit;

use BookStack\Exceptions\HttpFetchException;
use BookStack\Util\SsrUrlValidator;
use Tests\TestCase;

class SsrUrlValidatorTest extends TestCase
{
    public function test_allowed()
    {
        $testMap = [
            // Single values
            ['config' => '', 'url' => '', 'result' => false],
            ['config' => '', 'url' => 'https://example.com', 'result' => false],
            ['config' => '    ', 'url' => 'https://example.com', 'result' => false],
            ['config' => '*', 'url' => '', 'result' => false],
            ['config' => '*', 'url' => 'https://example.com', 'result' => true],
            ['config' => 'https://*', 'url' => 'https://example.com', 'result' => true],
            ['config' => 'http://*', 'url' => 'https://example.com', 'result' => false],
            ['config' => 'https://*example.com', 'url' => 'https://example.com', 'result' => true],
            ['config' => 'https://*ample.com', 'url' => 'https://example.com', 'result' => true],
            ['config' => 'https://*.example.com', 'url' => 'https://example.com', 'result' => false],
            ['config' => 'https://*.example.com', 'url' => 'https://test.example.com', 'result' => true],
            ['config' => '*//example.com', 'url' => 'https://example.com', 'result' => true],
            ['config' => '*//example.com', 'url' => 'http://example.com', 'result' => true],
            ['config' => 'https://example.com', 'url' => 'https://example.com/a/b/c?test=cat', 'result' => true],
            ['config' => 'https://example.com', 'url' => 'https://example.co.uk', 'result' => false],

            // Escapes
            ['config' => 'https://(.*?).com', 'url' => 'https://example.com', 'result' => false],
            ['config' => 'https://example.com', 'url' => 'https://example.co.uk#https://example.com', 'result' => false],

            // Multi values
            ['config' => '*//example.org *//example.com', 'url' => 'https://example.com', 'result' => true],
            ['config' => '*//example.org *//example.com', 'url' => 'https://example.com/a/b/c?test=cat#hello', 'result' => true],
            ['config' => '*.example.org *.example.com', 'url' => 'https://example.co.uk', 'result' => false],
            ['config' => '  *.example.org  *.example.com  ', 'url' => 'https://example.co.uk', 'result' => false],
            ['config' => '* *.example.com', 'url' => 'https://example.co.uk', 'result' => true],
            ['config' => '*//example.org *//example.com *//example.co.uk', 'url' => 'https://example.co.uk', 'result' => true],
            ['config' => '*//example.org *//example.com *//example.co.uk', 'url' => 'https://example.net', 'result' => false],
        ];

        foreach ($testMap as $test) {
            $result = (new SsrUrlValidator($test['config']))->allowed($test['url']);
            $this->assertEquals($test['result'], $result, "Failed asserting url '{$test['url']}' with config '{$test['config']}' results " . ($test['result'] ? 'true' : 'false'));
        }
    }

    public function test_enssure_allowed()
    {
        $result = (new SsrUrlValidator('https://example.com'))->ensureAllowed('https://example.com');
        $this->assertNull($result);

        $this->expectException(HttpFetchException::class);
        (new SsrUrlValidator('https://example.com'))->ensureAllowed('https://test.example.com');
    }
}