CVE-2021-4026 (PHP, published 2021-11-30T20:15Z)
Description: value: bookstack is vulnerable to Improper Access Control
Severity: MEDIUM (CVSS v3 base score: 4.3)
Weakness: CWE-863 \u2014 Incorrect Authorization
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.
Repository: https://github.com/bookstackapp/bookstack
Files changed:
app/Actions/ActivityService.php
app/Auth/Permissions/PermissionService.php
app/Exceptions/Handler.php
tests/Api/AttachmentsApiTest.php
Commit message: 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.


Vulnerable code:
     * Get latest activity for a user, Filtering out similar items.

        $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);
                    });
            });
        $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);
                        });
            })->orWhere($tableDetails['entityIdColumn'], '=', 0);
        $query->where('has_permission', '=', true)->orWhere(function ($query) use ($userIdToCheck) {
            $query->where('has_permission_own', '=', true)
                ->where('owned_by', '=', $userIdToCheck);
    protected function renderApiException(Exception $e): JsonResponse
        $code = $e->getCode() === 0 ? 500 : $e->getCode();

Fixed code:
     * Get the latest activity for a user, Filtering out similar items.
     * For simplicity, this will not return results attached to draft pages.
     * Draft pages should never really have related items though.
        $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);
                });
        $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);
        };

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

        $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);
use Illuminate\Database\Eloquent\ModelNotFoundException;
    protected function renderApiException(Throwable $e): JsonResponse
        $code = 500;

        if ($e instanceof ModelNotFoundException) {
            $code = 404;
        }

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