CVE-2021-4026 (published 2021-11-30T20:15Z)

bookstack is vulnerable to Improper Access Control

Severity: MEDIUM
CVSS v2 base score: 4.0 (AV:N/AC:L/Au:S/C:P/I:N/A:N)
CVSS v3 base score: 4.3, MEDIUM (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N)

Weakness information:
cwe_id: CWE-863
cwe_name: Incorrect Authorization
description: The product performs an authorization check when an actor attempts to access a resource or perform an action, but it does not correctly perform the check. This allows attackers to bypass intended access restrictions.
extended_description: 
            Assuming a user with a given identity, authorization is the process of determining whether that user can access a given resource, based on the user's privileges and any permissions or other access-control specifications that apply to the resource.
            When access control checks are incorrectly applied, users are able to access data or perform actions that they should not be allowed to perform. This can lead to a wide range of problems, including information exposures, denial of service, and arbitrary code execution.
         
         

Fix information:
repo_url: https://github.com/bookstackapp/bookstack
commit_details: repo_url: https://github.com/bookstackapp/bookstack
author: Dan Brown
author_date: 2021-11-30 00:06:17+00:00
msg: Fixed related permissions query not considering drafts

Page-related items added on drafts could be visible in certain scenarios
since the applied permissions query filters would not consider
page draft visibility.
This commit alters queries on related items to apply such filtering.

Included test to cover API scenario.
Thanks to @haxatron for reporting.
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: ActivityService.php
change_type: ModificationType.MODIFY
diff: @@ -133,7 +133,7 @@ class ActivityService
     }
 
     /**
-     * Get latest activity for a user, Filtering out similar items.
+     * Get the latest activity for a user, Filtering out similar items.
      */
     public function userActivity(User $user, int $count = 20, int $page = 0): array
     {

programming_language: PHP
filename: PermissionService.php
change_type: ModificationType.MODIFY
diff: @@ -602,25 +602,35 @@ class PermissionService
 
     /**
      * Filter items that have entities set as a polymorphic relation.
+     * For simplicity, this will not return results attached to draft pages.
+     * Draft pages should never really have related items though.
      *
      * @param Builder|QueryBuilder $query
      */
     public function filterRestrictedEntityRelations($query, string $tableName, string $entityIdColumn, string $entityTypeColumn, string $action = 'view')
     {
         $tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn, 'entityTypeColumn' => $entityTypeColumn];
-
-        $q = $query->where(function ($query) use ($tableDetails, $action) {
-            $query->whereExists(function ($permissionQuery) use (&$tableDetails, $action) {
-                /** @var Builder $permissionQuery */
-                $permissionQuery->select(['role_id'])->from('joint_permissions')
-                    ->whereColumn('joint_permissions.entity_id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
-                    ->whereColumn('joint_permissions.entity_type', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'])
-                    ->where('action', '=', $action)
-                    ->whereIn('role_id', $this->getCurrentUserRoles())
-                    ->where(function (QueryBuilder $query) {
-                        $this->addJointHasPermissionCheck($query, $this->currentUser()->id);
-                    });
-            });
+        $pageMorphClass = (new Page())->getMorphClass();
+
+        $q = $query->whereExists(function ($permissionQuery) use (&$tableDetails, $action) {
+            /** @var Builder $permissionQuery */
+            $permissionQuery->select(['role_id'])->from('joint_permissions')
+                ->whereColumn('joint_permissions.entity_id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
+                ->whereColumn('joint_permissions.entity_type', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'])
+                ->where('joint_permissions.action', '=', $action)
+                ->whereIn('joint_permissions.role_id', $this->getCurrentUserRoles())
+                ->where(function (QueryBuilder $query) {
+                    $this->addJointHasPermissionCheck($query, $this->currentUser()->id);
+                });
+        })->where(function ($query) use ($tableDetails, $pageMorphClass) {
+            /** @var Builder $query */
+            $query->where($tableDetails['entityTypeColumn'], '!=', $pageMorphClass)
+                ->orWhereExists(function(QueryBuilder $query) use ($tableDetails, $pageMorphClass) {
+                    $query->select('id')->from('pages')
+                        ->whereColumn('pages.id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
+                        ->where($tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'], '=', $pageMorphClass)
+                        ->where('pages.draft', '=', false);
+                });
         });
 
         $this->clean();
@@ -634,25 +644,39 @@ class PermissionService
      */
     public function filterRelatedEntity(string $entityClass, Builder $query, string $tableName, string $entityIdColumn): Builder
     {
-        $tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn];
-        $morphClass = app($entityClass)->getMorphClass();
-
-        $q = $query->where(function ($query) use ($tableDetails, $morphClass) {
-            $query->where(function ($query) use (&$tableDetails, $morphClass) {
-                $query->whereExists(function ($permissionQuery) use (&$tableDetails, $morphClass) {
-                    /** @var Builder $permissionQuery */
-                    $permissionQuery->select('id')->from('joint_permissions')
-                        ->whereColumn('joint_permissions.entity_id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
-                        ->where('entity_type', '=', $morphClass)
-                        ->where('action', '=', 'view')
-                        ->whereIn('role_id', $this->getCurrentUserRoles())
-                        ->where(function (QueryBuilder $query) {
-                            $this->addJointHasPermissionCheck($query, $this->currentUser()->id);
-                        });
+        $fullEntityIdColumn = $tableName . '.' . $entityIdColumn;
+        $instance = new $entityClass;
+        $morphClass = $instance->getMorphClass();
+
+        $existsQuery = function($permissionQuery) use ($fullEntityIdColumn, $morphClass) {
+            /** @var Builder $permissionQuery */
+            $permissionQuery->select('joint_permissions.role_id')->from('joint_permissions')
+                ->whereColumn('joint_permissions.entity_id', '=', $fullEntityIdColumn)
+                ->where('joint_permissions.entity_type', '=', $morphClass)
+                ->where('joint_permissions.action', '=', 'view')
+                ->whereIn('joint_permissions.role_id', $this->getCurrentUserRoles())
+                ->where(function (QueryBuilder $query) {
+                    $this->addJointHasPermissionCheck($query, $this->currentUser()->id);
                 });
-            })->orWhere($tableDetails['entityIdColumn'], '=', 0);
+        };
+
+        $q = $query->where(function ($query) use ($existsQuery, $fullEntityIdColumn) {
+            $query->whereExists($existsQuery)
+                ->orWhere($fullEntityIdColumn, '=', 0);
         });
 
+        if ($instance instanceof Page) {
+            // Prevent visibility of non-owned draft pages
+            $q->whereExists(function(QueryBuilder $query) use ($fullEntityIdColumn) {
+                $query->select('id')->from('pages')
+                    ->whereColumn('pages.id', '=', $fullEntityIdColumn)
+                    ->where(function (QueryBuilder $query) {
+                        $query->where('pages.draft', '=', false)
+                            ->orWhere('pages.owned_by', '=', $this->currentUser()->id);
+                    });
+            });
+        }
+
         $this->clean();
 
         return $q;
@@ -666,9 +690,9 @@ class PermissionService
      */
     protected function addJointHasPermissionCheck($query, int $userIdToCheck)
     {
-        $query->where('has_permission', '=', true)->orWhere(function ($query) use ($userIdToCheck) {
-            $query->where('has_permission_own', '=', true)
-                ->where('owned_by', '=', $userIdToCheck);
+        $query->where('joint_permissions.has_permission', '=', true)->orWhere(function ($query) use ($userIdToCheck) {
+            $query->where('joint_permissions.has_permission_own', '=', true)
+                ->where('joint_permissions.owned_by', '=', $userIdToCheck);
         });
     }
 

programming_language: PHP
filename: Handler.php
change_type: ModificationType.MODIFY
diff: @@ -4,6 +4,7 @@ namespace BookStack\Exceptions;
 
 use Exception;
 use Illuminate\Auth\AuthenticationException;
+use Illuminate\Database\Eloquent\ModelNotFoundException;
 use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
 use Illuminate\Http\JsonResponse;
 use Illuminate\Http\Request;
@@ -75,15 +76,20 @@ class Handler extends ExceptionHandler
     /**
      * Render an exception when the API is in use.
      */
-    protected function renderApiException(Exception $e): JsonResponse
+    protected function renderApiException(Throwable $e): JsonResponse
     {
-        $code = $e->getCode() === 0 ? 500 : $e->getCode();
+        $code = 500;
         $headers = [];
+
         if ($e instanceof HttpException) {
             $code = $e->getStatusCode();
             $headers = $e->getHeaders();
         }
 
+        if ($e instanceof ModelNotFoundException) {
+            $code = 404;
+        }
+
         $responseData = [
             'error' => [
                 'message' => $e->getMessage(),

programming_language: PHP
filename: AttachmentsApiTest.php
change_type: ModificationType.MODIFY
diff: @@ -224,6 +224,29 @@ class AttachmentsApiTest extends TestCase
         unlink(storage_path($attachment->path));
     }
 
+    public function test_attachment_not_visible_on_other_users_draft()
+    {
+        $this->actingAsApiAdmin();
+        $editor = $this->getEditor();
+
+        /** @var Page $page */
+        $page = Page::query()->first();
+        $page->draft = true;
+        $page->owned_by = $editor;
+        $page->save();
+        $this->regenEntityPermissions($page);
+
+        $attachment = $this->createAttachmentForPage($page, [
+            'name'  => 'my attachment',
+            'path'  => 'https://example.com',
+            'order' => 1,
+        ]);
+
+        $resp = $this->getJson("{$this->baseEndpoint}/{$attachment->id}");
+
+        $resp->assertStatus(404);
+    }
+
     public function test_update_endpoint()
     {
         $this->actingAsApiAdmin();

programming_language: PHP