Формы, проверка и безопасность
OpenCart мало что делает за программиста: нет ни запросов с параметрами, ни автоматического экранирования в шаблонах, ни проверки форм. Каждое значение из формы нужно проверить, защитить перед запросом к базе и экранировать при выводе — вручную. В этом уроке в модуль добавляется форма заявки на звонок, и каждый шаг разобран по очереди.
Форма
Добавьте форму в шаблон модуля, а её адрес — в index() контроллера витрины:
// extension/acme_hello/catalog/controller/module/acme_hello.php, в index()
$data['send'] = $this->url->link('extension/acme_hello/module/acme_hello.send', 'language=' . $this->config->get('config_language'));{# extension/acme_hello/catalog/view/template/module/acme_hello.twig #}
<form id="form-callback" action="{{ send }}" method="post" data-oc-toggle="ajax">
<input type="text" name="name" id="input-name" placeholder="{{ entry_name }}" class="form-control mb-2">
<div id="error-name" class="invalid-feedback"></div>
<input type="tel" name="phone" id="input-phone" placeholder="{{ entry_phone }}" class="form-control mb-2">
<div id="error-phone" class="invalid-feedback"></div>
<button type="submit" class="btn btn-primary">{{ button_submit }}</button>
</form>Теперь на главной странице в карточке модуля два поля и кнопка.
Проверка
public function send(): void
{
$this->load->language('extension/acme_hello/module/acme_hello');
$json = [];
$name = trim($this->request->post['name'] ?? '');
$phone = preg_replace('/\D/', '', $this->request->post['phone'] ?? '');
if (mb_strlen($name) < 2 || mb_strlen($name) > 64) {
$json['error']['name'] = $this->language->get('error_name');
}
if (strlen($phone) < 10) {
$json['error']['phone'] = $this->language->get('error_phone');
}
if (!$json) {
$this->load->model('extension/acme_hello/module/acme_hello');
$this->model_extension_acme_hello_module_acme_hello->addCallback($name, $phone);
$json['success'] = $this->language->get('text_success');
}
$this->response->addHeader('Content-Type: application/json');
$this->response->setOutput(json_encode($json));
}Отправьте форму пустой — под обоими полями появятся ошибки. С правильными значениями появится сообщение об успехе.
SQL-инъекция
SQL-инъекция — это когда значение из формы становится частью запроса и меняет его смысл. Модель защищает каждое значение:
// extension/acme_hello/catalog/model/module/acme_hello.php
namespace Opencart\Catalog\Model\Extension\AcmeHello\Module;
class AcmeHello extends \Opencart\System\Engine\Model
{
public function addCallback(string $name, string $phone): int
{
$this->db->query(
"INSERT INTO `" . DB_PREFIX . "acme_callback` SET
`name` = '" . $this->db->escape($name) . "',
`phone` = '" . $this->db->escape($phone) . "',
`customer_id` = '" . (int) $this->customer->getId() . "',
`date_added` = NOW()"
);
return $this->db->getLastId();
}
}Строка появится в таблице oc_acme_callback и в списке на странице настроек модуля. Имя столбца для сортировки нельзя защитить через escape() — его сверяют с заранее заданным списком разрешённых имён.
XSS
XSS — это когда текст, введённый одним посетителем, выполняется как скрипт в браузере другого. Строки из $this->request движок уже пропустил через htmlspecialchars(), и они хранятся экранированными. Данные из других источников — файл импорта, JSON от внешней службы — не экранированы, поэтому их экранируют при выводе: {{ name|escape }}.
Подделка запроса
В панели каждый запрос защищён user_token. На витрине такой защиты нет: чужой сайт может отправить эту форму от имени посетителя. Для важной формы добавьте собственный токен:
// в index()
$this->session->data['acme_hello_token'] ??= bin2hex(random_bytes(16));
$data['token'] = $this->session->data['acme_hello_token'];// в send(), перед остальными проверками
if (!hash_equals($this->session->data['acme_hello_token'] ?? '', (string) ($this->request->post['token'] ?? ''))) {
$json['error'] = $this->language->get('error_token');
}Шаблон отправляет его в скрытом поле с именем token.
Частая ошибка
Значение экранируют дважды: запрос уже заменил & на &, затем шаблон ещё раз применяет |escape — и на странице стоит Tom & Anna. Экранируйте значение один раз: строки из запроса уже экранированы, данные из других источников экранируются при выводе.