]> BookStack Code Mirror - bookstack/commitdiff
Security: Added new SSR allow list and validator
authorDan Brown <redacted>
Sat, 26 Aug 2023 14:28:29 +0000 (15:28 +0100)
committerDan Brown <redacted>
Sat, 26 Aug 2023 14:28:29 +0000 (15:28 +0100)
Included unit tests to cover validator functionality.
Added to webhooks.
Still need to do testing specifically for webhooks.

app/Activity/DispatchWebhookJob.php
app/Config/app.php
app/Util/SsrUrlValidator.php [new file with mode: 0644]
lang/en/errors.php
tests/Unit/SsrUrlValidatorTest.php [new file with mode: 0644]

index f2330c4faf967f257fd94534b98d2adf9b448439..405bca49cbee925b574ff7ed7c574d702df9e62c 100644 (file)
@@ -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)
index 29ab9c6dc9399e46b5337dc678174a921b4806d4..3a843c512b2f069dc21d881fc4aea6cfa5c1c12c 100644 (file)
@@ -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),
diff --git a/app/Util/SsrUrlValidator.php b/app/Util/SsrUrlValidator.php
new file mode 100644 (file)
index 0000000..722a45f
--- /dev/null
@@ -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));
+    }
+}
index 23c326f9e016f3222e81cbb9aab399a5780f1bea..4cde4cea36fb27f8bfd1207b6732c45f317a3886 100644 (file)
@@ -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',
 ];
diff --git a/tests/Unit/SsrUrlValidatorTest.php b/tests/Unit/SsrUrlValidatorTest.php
new file mode 100644 (file)
index 0000000..1443eed
--- /dev/null
@@ -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');
+    }
+}