Концепт: подсчет контрольной суммы форм при запросах.

Выкладываем свои наработки
Ответить
Аватара пользователя
carono
Сообщения: 52
Зарегистрирован: 2018.04.28, 11:05

Концепт: подсчет контрольной суммы форм при запросах.

Сообщение carono » 2019.09.17, 17:52

Всем привет. Долгое время меня мучает одна задача, с которой я столкнулся несколько лет назад, уже после того как я её решил, и уже закончил с тем проектом, у меня родилась идея, которая не давала мне покоя, и вот я решил выложить свои мысли на этот счет.

Разрабатывал я команде для банка одну крупную систему, тогда мы использовали yii1, т.к. второй был только в планах, но на суть это не влияет. Были сотни таблиц, десятки ролей и очень много бизнес логики. А по безопасности, нужно учитывать, чтобы пользователь не отправил какой-то параметр и не изменил состояние модели, на которое не имеет право.

Обновление одной формы, могло содержать десятки полей, которые нужно было отрисовывать в зависимости от роли и параметров смежных моделей. Все условия нужно выполнять при рендере и при сохранении, чтобы ничего лишнего не показать и не сохранить в базу.
Но что в итоге получается, нужно на каждую роль описывать сценарий, в валидаторе учитывать все особенности бизнес логики, а также на форме нужно еще раз все выводить со всеми условиями. А если с этой же моделью, пользователь с этой же ролью, но на другом контроллере имеет другой набор прав, опять сценарии, валидаторы, формы, заполонённые if’ами.

Свою задачу я решил описанием доступа к полям в самой модели, и уже от этого можно модифицировать валидацию, сохранение и отрисовку формы, что сильно экономит время.

Код: Выделить всё

public function accesses()
{
    $accesses = [
        [
            'roles' => ['admin', 'user'],
            'columns' => [
                'office_id',
                'comment',
                'is_training',
            ],
            'access' => [ACR_CREATE]
        ]
    ];
}
Но, почему я как сервер, должен в принципе принимать от пользователя данные, которые у него не запрашивал.

Например, если на небольшой форме мне нужно скрыть одно поле, а значит я пишу if, добавляю сценарий, чтобы это поле не попало в safe, пишу тест, где я буду всё же это поле слать и проверять, что записалось только то, что нужно. И так раз за разом. Но почему? Я не рендерил это поле на форме, значит я не должен его принимать от пользователя, разве нет?

Меня пригласили на один проект, одной из задач, было разобраться, как пользователи накручивают себе рейтинг. Настроив логирование запросов и изменений данных нужной модели, я обнаружил, что пользовали посылали POST запрос на сервер, в котором было указано значение рейтинга, которое нужно прибавить, но на форме этих пользователей не было этого поля, оно было доступно только админам, а значит рядовые пользователи просто подделывали форму, и это не проверялось на сервере. В коде не было ни одной проверки на такие данные, если какое-то поле в каком-то месте могло меняться, оно в правилах добавлялась в safe и благополучно забывалось, а значит потенциальных мест с уязвимостями были десятки, если не сотни.

Yii из коробки даёт нам возможность защищать запрос с помощью csrf ключа. Что позволяет добропорядочному пользователю, не послать поддельную форму хакера-вредителя на ваш сайт.

Но что делать, если ваш пользователь и есть хакер-вредитель, он хочет получить больше прав, чем имеет, менять больше данных, чем позволено. Поэтому если он знает, что у какой-то модели есть поле, но на форме его нет, может исправить набор полей и отправить, если вы это не учли через сценарии, валидатор и тесты не писали, значит хакер поменял то, что не должен был, чем это чревато, каждый решит по своему проекту.

Это тоже самое, что, если вы пришли в банк, попросили кредит, вам дали форму чтобы заполнить и вы в самом низу ручкой дописали «процентная ставка: 0.000001%», такую форму даже в обработку не отдадут, её еще менеджер выкинет.

Поэтому я и решил сделать концепт проверки формы, чтобы пользователь нам слал только то, что мы от него хотим и не больше.
Что нам требуется?
• Нам нужно взять форму, которую посылаем пользователю.
• Собрать из неё все поля и составить контрольную сумму
• Добавить поле с этим хешем на форму
• Если пользователь посылает форму, сверяем суммы его формы и той что мы храним
• Если суммы не сходятся, отправляем ошибку
Звучит просто, давайте попробуем сделать.

Кто у нас будет следить за валидностью формы? Тот же, кто и сделит за csfr ключем - yii\web\Request. Значит создадим свой класс, и модифицируем метод validateCsrfToken. Мы только для POST запросов будем проверять 2 стека, данные что нам прислали, и тот, и те, что мы должны были сохранить, найдем его по контрольной сумме, что пользователь нам прислал.

Код: Выделить всё

public function validateCsrfToken($clientSuppliedToken = null)
{
    if ($this->isPost && $this->checksumIsEnabled()) {
        $post = $this->post();
        $checksum = ArrayHelper::getValue($post, $this->checksumParam);
        $stack = $this->getStackByChecksum($checksum);
        if (!Checksum::compareStacks($post, $stack, $this->checksumKey)) {
            return false;
        }
    }
    return parent::validateCsrfToken($clientSuppliedToken);
}
В какой момент мы будем сохранять отрендеренные данные формы. Можно начать с ActiveField. Создадим класс, в котором переопределим __toString, если в рендере будут нужные нам теги, мы их сохраним.

Код: Выделить всё

public function __toString()
{
    $string = parent::__toString();
    if (preg_match('#<input|<select|<textarea#', $string)) {
        $attribute = Html::getAttributeName($this->attribute);
        \Yii::$app->request->stackField($this->form->id, $this->model->formName(), $attribute);
    }
    return $string;
}
Сразу куча проблем, во первых на форме могли отрисовать не через $form->field(), а просто через хелпер Html, или может быть там статика вообще. А еще всем придется в своих ActiveForm переопределять $fieldClass, не удобно, отказываемся, нужно двигаться выше.

Так же на не подойдет модифицировать ActiveForm, добавив свое событие EVENT_AFTER_RUN, статика к нам не попадёт.

Повесим наше поведение на View, это последний бастион, весь контент что мы выводим пользователю, мы можем распарсить и модифицировать. Соберем все формы с POST методами, разгребём все поля и сохраним их, и добавим на форму поле с контрольной суммой.

Код: Выделить всё

class ChecksumBehavior extends Behavior
{
    public function events()
    {
        return [
            View::EVENT_AFTER_RENDER => 'registerChecksumField'
        ];
    }

    /**
     * @param \yii\base\WidgetEvent $event
     */
    public function registerChecksumField(ViewEvent $event)
    {
        /**
         * @var DOMElement $form
         * @var DOMElement $element
         */
        if (!$event->output) {
            return;
        }
        $document = new DOMDocument();
        $document->loadHTML($event->output, LIBXML_NOERROR);
        $xpath = new \DOMXPath($document);
        foreach ($xpath->query("//form[@method='post']") as $form) {
            $items = [];
            foreach ($xpath->query('//input|//select|//textarea', $form) as $element) {
                $items[] = $element->getAttribute('name');
            }
            $items = array_unique($items);
            parse_str(implode('&', $items), $stack);
            $checksum = \Yii::$app->request->setStack($stack);
            $input = $document->createElement('input');
            $input->setAttribute('name', \Yii::$app->request->checksumParam);
            $input->setAttribute('value', $checksum);
            $input->setAttribute('type', 'hidden');
            $form->appendChild($input);
        }
        $event->output = $document->saveHTML();
    }
}
Как мы будем хранить наши контрольные суммы? Придется использовать сессию, каждый раз, когда рендерится форма, данные в сессии будут обновляться.

Какие данные мы будем хранить. После некоторых тестов, пришел к тому, что мы будем хранить только те данные, которые пришли к нам в виде массива, т.е. в виде Model[field], те что скорее всего означают модель. А одиночные данные, такие как _csrf ключ, или кнопку отправки с именем, мы будем игнорировать, а думаю это лишние данные, но вы можете со мной и не согласиться, кто я такой, чтобы решать о важности данных формы.

Очистим входящие данные от не массивов

Код: Выделить всё

protected static function prepareData($array)
{
    return array_filter($array ?: [], 'is_array');
}
Из них составим одномерный массив, очистим от дубликатов и отсортируем.

Код: Выделить всё

public static function formKeyPartials($array)
{
    $array = static::prepareData($array);
    $result = [];
    foreach ((array)$array as $model => $values) {
        foreach (array_keys((array)$values) as $key) {
            $result[] = implode('=', [$model, $key]);
        }
    }
    $result = array_unique($result);
    sort($result);
    return $result;
}
Результат мы можем сформировать в строку и хешировать.

Код: Выделить всё

public static function formKey($array)
{
    return implode('|', static::formKeyPartials($array));
}

Код: Выделить всё

public static function calculate($array, $salt = null)
{
    if ($key = static::formKey($array)) {
        return hash('sha256', $key . $salt);
    }

    return null;
}
Но что, если нам прислали не все данные, допустим какое-то поле было отрендерено, но закрыто disabled, а на фронте оно по необходимости открывается. Например, есть галка «получать рекламу» и при клике появляется поле email, если галку не поставят, то и данные не придут, это нужно учесть. Когда придет запрос, мы добавим в него недостающие данные, которые храним.

Код: Выделить всё

protected static function mergeStacks($post, $stack)
{
    $postPartials = static::formKeyPartials($post);
    $stackPartials = static::formKeyPartials($stack);
    foreach (array_diff($stackPartials, $postPartials) as $lostPartial) {
        list($formName, $attribute) = explode('=', $lostPartial);
        $post[$formName][$attribute] = '';
    }
    return $post;
}
Теперь мы может сравнивать 2 массива, тот что храним в сессии, и данные, которые прислали

Код: Выделить всё

public static function compareStacks($post, $stack, $salt = null)
{
    $checksum = static::calculate($stack, $salt);
    $post = static::mergeStacks($post, $stack);
    return static::validate($post, $checksum, $salt);
}
Дело за малым, настроить это и проверить. В конфиге web.php заменяем класс запроса

Код: Выделить всё

'request' => [
    'class' => \carono\checksum\Request::class,
    'cookieValidationKey' => '123456789'
]
И добавляем поведение на View

Код: Выделить всё

view => [
    'class' => yii\web\View::class,
    'as caronoChecksumBehavior' => \carono\checksum\ChecksumBehavior::class
]
Ну и что в итоге мы смастерили? Теперь если на любой форме добавить поле через консоль, то запрос завершится ошибкой 400.

Минусы данной реализации, вполне очевидны, поэтому это концепт, а не готовый проект. Во-первых, мы не проверяем все данные. Во-вторых, для приложений работающие через API и js фреймворком на фронте это точно не поможет. В-третьих, могут быть ложные срабатывания, если вы добавляете какие-то поля через скрипты сами. А также на каждый запрос мы парсим наш ответ, нагрузка возрастает сразу, а еще сессии не резиновые.

Поэтому это не призыв пользоваться этим пакетом, а возможность подискутировать о теме безопасности.

Код: Выделить всё

composer require carono/yii2-request-checksum
https://github.com/carono/yii2-request-checksum

демо https://yii2-request-checksum.carono.ru (https://github.com/carono/yii2-request-checksum-demo)

Аватара пользователя
samdark
Администратор
Сообщения: 9175
Зарегистрирован: 2009.04.02, 13:46
Откуда: Воронеж
Контактная информация:

Re: Концепт: подсчет контрольной суммы форм при запросах.

Сообщение samdark » 2019.09.19, 11:58

Очень похоже на CSRF-токен.

Аватара пользователя
carono
Сообщения: 52
Зарегистрирован: 2018.04.28, 11:05

Re: Концепт: подсчет контрольной суммы форм при запросах.

Сообщение carono » 2019.09.19, 12:06

samdark писал(а):
2019.09.19, 11:58
Очень похоже на CSRF-токен.
Похоже, но не он, тут защита конкретной формы от подделки не от стороннего юзера, а от тебя лично, если через консоль добавить поле, которые ты знаешь, что оно существует, но его нет на форме и отправить, и если нет на беке проверки, оно будет записано

Аватара пользователя
samdark
Администратор
Сообщения: 9175
Зарегистрирован: 2009.04.02, 13:46
Откуда: Воронеж
Контактная информация:

Re: Концепт: подсчет контрольной суммы форм при запросах.

Сообщение samdark » 2019.09.19, 14:42

если нет на беке проверки
А почему там её нет? Для этого сделан концепт safe-полей.

Аватара пользователя
carono
Сообщения: 52
Зарегистрирован: 2018.04.28, 11:05

Re: Концепт: подсчет контрольной суммы форм при запросах.

Сообщение carono » 2019.09.19, 16:01

samdark писал(а):
2019.09.19, 14:42
если нет на беке проверки
А почему там её нет? Для этого сделан концепт safe-полей.
Разумеется, сценарии, нужные сейф поля и тесты, и ни какие дыры не страшны, но это если все делать правильно, а если что-то ускользнуло от кодревью и все же попало на прод, возможность изменить одно поле может много лет оставаться не замеченной, и не нести никакой угрозы, а может скомпрометировать весь сервис и потерять доверие за один день.

Тут даже не вопрос в том, как разрабатывать правильно, а в том, что если все же накосячили, должен ли сервер такое перехватывать.

Ответить