Разрабатывал я команде для банка одну крупную систему, тогда мы использовали 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);
}
Код: Выделить всё
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;
}
Так же на не подойдет модифицировать 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;
}
Код: Выделить всё
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;
}
Код: Выделить всё
public static function compareStacks($post, $stack, $salt = null)
{
$checksum = static::calculate($stack, $salt);
$post = static::mergeStacks($post, $stack);
return static::validate($post, $checksum, $salt);
}
Код: Выделить всё
'request' => [
'class' => \carono\checksum\Request::class,
'cookieValidationKey' => '123456789'
]
Код: Выделить всё
view => [
'class' => yii\web\View::class,
'as caronoChecksumBehavior' => \carono\checksum\ChecksumBehavior::class
]
Минусы данной реализации, вполне очевидны, поэтому это концепт, а не готовый проект. Во-первых, мы не проверяем все данные. Во-вторых, для приложений работающие через API и js фреймворком на фронте это точно не поможет. В-третьих, могут быть ложные срабатывания, если вы добавляете какие-то поля через скрипты сами. А также на каждый запрос мы парсим наш ответ, нагрузка возрастает сразу, а еще сессии не резиновые.
Поэтому это не призыв пользоваться этим пакетом, а возможность подискутировать о теме безопасности.
Код: Выделить всё
composer require carono/yii2-request-checksum
демо https://yii2-request-checksum.carono.ru (https://github.com/carono/yii2-request-checksum-demo)