CVE-2023-4624 (published 2023-08-30T13:15Z)

Server-Side Request Forgery (SSRF) in GitHub repository bookstackapp/bookstack prior to v23.08.

CVSS v3 base score: 2.4, LOW (CVSS:3.1/AV:N/AC:L/PR:H/UI:R/S:U/C:L/I:N/A:N)

Weakness information:
cwe_id: CWE-918
cwe_name: Server-Side Request Forgery (SSRF)
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.
extended_description: By providing URLs to unexpected hosts or ports, attackers can make it appear that the server is sending the request, possibly bypassing access controls such as firewalls that prevent the attackers from accessing the URLs directly. The server can be used as a proxy to conduct port scanning of hosts in internal networks, use other URLs such as that can access documents on the system (using file://), or use other protocols such as gopher:// or tftp://, which may provide greater control over the contents of requests.
         

Fix information:
repo_url: https://github.com/bookstackapp/bookstack
commit_details: repo_url: https://github.com/bookstackapp/bookstack
author: Dan Brown
author_date: 2023-08-26 15:28:29+01:00
msg: 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.
repository: repo_url: https://github.com/bookstackapp/bookstack
repo_name: BookStackApp/BookStack
description: A platform to create documentation/wiki content built with PHP & Laravel
repo_language: PHP
owner: bookstackapp
file_changes: filename: DispatchWebhookJob.php
change_type: ModificationType.MODIFY
diff: @@ -8,6 +8,7 @@ use BookStack\Activity\Tools\WebhookFormatter;
 use BookStack\Facades\Theme;
 use BookStack\Theming\ThemeEvents;
 use BookStack\Users\Models\User;
+use BookStack\Util\SsrUrlValidator;
 use Illuminate\Bus\Queueable;
 use Illuminate\Contracts\Queue\ShouldQueue;
 use Illuminate\Foundation\Bus\Dispatchable;
@@ -53,6 +54,8 @@ class DispatchWebhookJob implements ShouldQueue
         $lastError = null;
 
         try {
+            (new SsrUrlValidator())->ensureAllowed($this->webhook->endpoint);
+
             $response = Http::asJson()
                 ->withOptions(['allow_redirects' => ['strict' => true]])
                 ->timeout($this->webhook->timeout)

programming_language: PHP
filename: app.php
change_type: ModificationType.MODIFY
diff: @@ -66,6 +66,15 @@ return [
     // Current host and source for the "DRAWIO" setting will be auto-appended to the sources configured.
     'iframe_sources' => env('ALLOWED_IFRAME_SOURCES', 'https://*.draw.io https://*.youtube.com https://*.youtube-nocookie.com https://*.vimeo.com'),
 
+    // 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', '*'),
+
     // Alter the precision of IP addresses stored by BookStack.
     // Integer value between 0 (IP hidden) to 4 (Full IP usage)
     'ip_address_precision' => env('IP_ADDRESS_PRECISION', 4),

programming_language: PHP
filename: SsrUrlValidator.php
change_type: ModificationType.ADD
diff: @@ -0,0 +1,64 @@
+<?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));
+    }
+}

programming_language: PHP
filename: errors.php
change_type: ModificationType.MODIFY
diff: @@ -111,4 +111,6 @@ return [
     // Settings & Maintenance
     'maintenance_test_email_failure' => 'Error thrown when sending a test email:',
 
+    // HTTP errors
+    'http_ssr_url_no_match' => 'The URL does not match the configured allowed SSR hosts',
 ];

programming_language: PHP
filename: SsrUrlValidatorTest.php
change_type: ModificationType.ADD
diff: @@ -0,0 +1,59 @@
+<?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');
+    }
+}

programming_language: PHP