fix/security-audit

This commit is contained in:
2026-10-26 17:44:54 +03:00
committed by Git Moneta
parent 1f29a07f63
commit d850c2ca31
8 changed files with 389 additions and 113 deletions
@@ -7,7 +7,7 @@
*/
class ControllerExtensionPaymentPayanyway extends Controller {
const MODULE_NAME = 'payanyway';
const MODULE_VERSION = '1.0.5';
const MODULE_VERSION = '1.0.6';
const CMS_NAME = 'OpenCart';
const PAYMENT_SERVER_PROD = 'prod';
@@ -20,8 +20,12 @@ class ControllerExtensionPaymentPayanyway extends Controller {
const TEXT_CONTENT_TYPE = 'text/plain; charset=UTF-8';
const TRANSACTION_ID_DELIMITER = '_';
const LEGACY_TRANSACTION_ID_DELIMITER = '|';
const TRANSACTION_ID_PATTERN = '/^([1-9][0-9]*)[%s]([0-9]{14})([A-Z0-9+-]{1,6})?$/';
const TRANSACTION_ID_ZONE = 'UTC';
const TRANSACTION_ID_PATTERN = '/^([1-9][0-9]*)_(0|[1-9][0-9]{0,15})_([0-9]{14})UTC$/D';
const LEGACY_TRANSACTION_ID_PATTERN = '/^([1-9][0-9]*)\\|([0-9]{14})$/D';
const LEGACY_ZONED_TRANSACTION_ID_PATTERN = '/^([1-9][0-9]*)_([0-9]{14})([A-Z]{1,6}|[+-][0-9]{2}|[+-][0-9]{4})?$/D';
const LEGACY_TRANSACTION_MAX_AGE_DAYS = 14;
const OPERATION_ID_PATTERN = '/^[0-9]{1,20}$/D';
const DESCRIPTION_MAX_LENGTH = 500;
const CLIENT_NAME_MAX_LENGTH = 256;
@@ -40,6 +44,8 @@ class ControllerExtensionPaymentPayanyway extends Controller {
const DEFAULT_CANCELED_ORDER_STATUS_IDS = '7,9,16';
const JSON_NUMBER_PREFIX = '__payanyway_number:';
const JSON_NUMBER_KEYS = 'amount|price';
const ORDER_LOCK_TIMEOUT_SECONDS = 10;
/** @var int */
private $mntId;
@@ -71,7 +77,7 @@ class ControllerExtensionPaymentPayanyway extends Controller {
/** @var bool */
private $fiscalization;
/** @var string «Email для чеков»: подставляется, если у покупателя нет ни email, ни телефона */
/** @var string */
private $receiptEmail;
/** @var string */
@@ -113,8 +119,8 @@ class ControllerExtensionPaymentPayanyway extends Controller {
$data['action'] = (self::PAYMENT_SERVER_PROD === $this->server) ? self::PAYMENT_URL_PROD : self::PAYMENT_URL_DEMO;
$data['MNT_ID'] = $this->mntId;
$data['MNT_TRANSACTION_ID'] = $this->createTransactionId();
$data['MNT_AMOUNT'] = $this->getOrderAmount();
$data['MNT_TRANSACTION_ID'] = $this->createTransactionId($data['MNT_AMOUNT']);
$data['MNT_CURRENCY_CODE'] = $this->currency;
$data['MNT_TEST_MODE'] = $this->testMode ? '1' : '0';
$data['MNT_DESCRIPTION'] = $this->getDescription();
@@ -159,17 +165,7 @@ class ControllerExtensionPaymentPayanyway extends Controller {
$this->load->language('extension/payment/payanyway');
$orderStatusId = isset($this->orderInfo['order_status_id']) ? (int)$this->orderInfo['order_status_id'] : null;
$paymentCode = isset($this->orderInfo['payment_code']) ? $this->orderInfo['payment_code'] : '';
if (self::MODULE_NAME !== $paymentCode) {
$json['success'] = false;
} else {
if ((null !== $orderStatusId) && ($orderStatusId !== $this->successOrderStatusId)) {
$this->addOrderHistory($this->pendingOrderStatusId, $this->language->get('text_order_confirmed'));
}
$json['success'] = true;
}
$json['success'] = $this->changeStatusUnderOrderLock(true, $this->pendingOrderStatusId, $this->language->get('text_order_confirmed'));
$this->response->addHeader('Content-Type: application/json');
$this->response->setOutput(json_encode($json));
@@ -182,10 +178,7 @@ class ControllerExtensionPaymentPayanyway extends Controller {
public function success() {
$this->load->language('extension/payment/payanyway');
$orderStatusId = isset($this->orderInfo['order_status_id']) ? (int)$this->orderInfo['order_status_id'] : null;
if ((null !== $orderStatusId) && ($orderStatusId !== $this->successOrderStatusId)) {
$this->addOrderHistory($this->pendingOrderStatusId, $this->language->get('text_payment_processing'));
}
$this->changeStatusUnderOrderLock(false, $this->pendingOrderStatusId, $this->language->get('text_payment_processing'));
$this->response->redirect($this->url->link('checkout/success', '', true));
}
@@ -197,10 +190,7 @@ class ControllerExtensionPaymentPayanyway extends Controller {
public function fail() {
$this->load->language('extension/payment/payanyway');
$orderStatusId = isset($this->orderInfo['order_status_id']) ? (int)$this->orderInfo['order_status_id'] : null;
if ((null !== $orderStatusId) && ($orderStatusId !== $this->successOrderStatusId)) {
$this->addOrderHistory($this->errorOrderStatusId, $this->language->get('text_payment_error'));
}
$this->changeStatusUnderOrderLock(false, $this->errorOrderStatusId, $this->language->get('text_payment_error'));
$this->response->redirect($this->url->link('checkout/failure', '', true));
}
@@ -218,9 +208,18 @@ class ControllerExtensionPaymentPayanyway extends Controller {
return $this->model_checkout_order->getOrder($this->orderId);
}
/** @return string */
private function createTransactionId() {
return $this->orderId . self::TRANSACTION_ID_DELIMITER . date('YmdHisT');
/**
* @param string $amount
* @return string
* @throws Exception
*/
private function createTransactionId($amount) {
$kopecks = ltrim(str_replace('.', '', $amount), '0');
$issuedAt = new \DateTime('now', new \DateTimeZone(self::TRANSACTION_ID_ZONE));
return $this->orderId
. self::TRANSACTION_ID_DELIMITER . (('' !== $kopecks) ? $kopecks : '0')
. self::TRANSACTION_ID_DELIMITER . $issuedAt->format('YmdHis') . self::TRANSACTION_ID_ZONE;
}
/**
@@ -313,6 +312,61 @@ class ControllerExtensionPaymentPayanyway extends Controller {
return (null !== $phone) ? $phone : '';
}
/** @return bool */
private function isPaidWithModule() {
return isset($this->orderInfo['payment_code']) && (self::MODULE_NAME === $this->orderInfo['payment_code']);
}
/**
* @param int $orderStatusId
* @return bool
*/
private function isAwaitingPayment($orderStatusId) {
return in_array($orderStatusId, array(0, $this->pendingOrderStatusId, $this->errorOrderStatusId), true);
}
/**
* @param bool $allowFailed подтверждение формы допускает повторную попытку после «Неуспешного»
* @param int $orderStatusId
* @param string $comment
* @return bool
* @throws \Exception
*/
private function changeStatusUnderOrderLock($allowFailed, $orderStatusId, $comment) {
if (!$this->isStatusChangeableByCustomer($allowFailed)) {
return false;
}
$this->load->model('extension/payment/payanyway');
if (!$this->model_extension_payment_payanyway->acquireOrderLock($this->orderId, self::ORDER_LOCK_TIMEOUT_SECONDS)) {
return false;
}
$this->orderInfo = $this->getOrderInfo();
$changeable = $this->isStatusChangeableByCustomer($allowFailed)
&& !$this->model_extension_payment_payanyway->hasOrderStatusInHistory($this->orderId, $this->successOrderStatusId);
if ($changeable) {
$this->addOrderHistory($orderStatusId, $comment);
}
$this->model_extension_payment_payanyway->releaseOrderLock($this->orderId);
return $changeable;
}
/**
* @param bool $allowFailed
* @return bool
*/
private function isStatusChangeableByCustomer($allowFailed) {
if (empty($this->orderInfo) || !$this->isPaidWithModule()) {
return false;
}
$orderStatusId = (int)$this->orderInfo['order_status_id'];
return $allowFailed ? $this->isAwaitingPayment($orderStatusId) : in_array($orderStatusId, array(0, $this->pendingOrderStatusId), true);
}
/**
* @param int $orderStatusId
* @param string $comment
@@ -338,22 +392,40 @@ class ControllerExtensionPaymentPayanyway extends Controller {
$this->validateCallback($callback);
$isCheck = 'CHECK' === $callback['MNT_COMMAND'];
if ($isCheck && !$this->checkSignature($callback, $callback['MNT_SIGNATURE'])) {
if (!$this->hasRequiredOperationId($callback)) {
$this->log->write('PayAnyWay callback: MNT_OPERATION_ID is missing or not numeric');
$this->sendResponse(self::FAIL_RESPONSE);
}
$this->orderId = $this->parseOrderIdFromTransactionId($callback['MNT_TRANSACTION_ID']);
if (!$this->checkSignature($callback, $callback['MNT_SIGNATURE'])) {
$this->log->write('PayAnyWay callback: MNT_SIGNATURE does not match');
$this->sendResponse(self::FAIL_RESPONSE);
}
$transaction = $this->parseTransactionId($callback['MNT_TRANSACTION_ID']);
$this->orderId = (null !== $transaction) ? $transaction['order_id'] : null;
if (null === $this->orderId) {
$this->log->write('PayAnyWay callback: MNT_TRANSACTION_ID has unexpected format');
$this->sendResponse(self::FAIL_RESPONSE);
}
$isCheck = 'CHECK' === $callback['MNT_COMMAND'];
$this->load->model('extension/payment/payanyway');
if (!$isCheck && !$this->model_extension_payment_payanyway->acquireOrderLock($this->orderId, self::ORDER_LOCK_TIMEOUT_SECONDS)) {
$this->log->write('PayAnyWay callback: order is locked by another request');
$this->sendResponse(self::FAIL_RESPONSE);
}
$this->orderInfo = $this->getOrderInfo();
if (empty($this->orderInfo)) {
$this->sendResponse($isCheck ? $this->buildUnknownOrderResponse($callback) : self::FAIL_RESPONSE);
}
if (!$this->isPaidWithModule()) {
$this->log->write('PayAnyWay callback: order is not paid with PayAnyWay');
$this->sendResponse($isCheck ? $this->buildUnknownOrderResponse($callback) : self::FAIL_RESPONSE);
}
$isCheck ? $this->handleCheckCallback($callback) : $this->handlePayCallback($callback);
}
@@ -384,19 +456,31 @@ class ControllerExtensionPaymentPayanyway extends Controller {
/**
* @param mixed $transactionId
* @return int|null
* @return array{order_id: int, amount: string|null, issued_at: string}|null
*/
private function parseOrderIdFromTransactionId($transactionId) {
private function parseTransactionId($transactionId) {
if (!is_string($transactionId)) {
return null;
}
$delimiters = preg_quote(self::TRANSACTION_ID_DELIMITER . self::LEGACY_TRANSACTION_ID_DELIMITER, '/');
if (!preg_match(sprintf(self::TRANSACTION_ID_PATTERN, $delimiters), $transactionId, $matches)) {
return null;
if (preg_match(self::TRANSACTION_ID_PATTERN, $transactionId, $matches)) {
$kopecks = str_pad($matches[2], 3, '0', STR_PAD_LEFT);
return array(
'order_id' => (int)$matches[1],
'amount' => substr($kopecks, 0, -2) . '.' . substr($kopecks, -2),
'issued_at' => $matches[3],
);
}
return (int)$matches[1];
if (preg_match(self::LEGACY_TRANSACTION_ID_PATTERN, $transactionId, $matches)
|| (preg_match(self::LEGACY_ZONED_TRANSACTION_ID_PATTERN, $transactionId, $matches)
&& (!isset($matches[3]) || !preg_match('/^[+-]/', $matches[3]) || ($matches[3] === date('T'))))
) {
return array('order_id' => (int)$matches[1], 'amount' => null, 'issued_at' => $matches[2]);
}
return null;
}
/** @return int|null */
@@ -433,7 +517,7 @@ class ControllerExtensionPaymentPayanyway extends Controller {
$this->sendResponse(self::FAIL_RESPONSE);
}
if ($this->mntId !== (int)$callback['MNT_ID']) {
if (!$this->isOwnAccount($callback)) {
$this->sendResponse(self::FAIL_RESPONSE);
}
@@ -443,6 +527,23 @@ class ControllerExtensionPaymentPayanyway extends Controller {
}
}
/**
* @param array<string, mixed> $callback
* @return bool
*/
private function isOwnAccount(array $callback) {
return (string)$this->mntId === $callback['MNT_ID'];
}
/**
* @param array<string, mixed> $callback
* @return bool
*/
private function hasRequiredOperationId(array $callback) {
return ('CHECK' === $callback['MNT_COMMAND'])
|| (1 === preg_match(self::OPERATION_ID_PATTERN, $callback['MNT_OPERATION_ID']));
}
/**
* @param array<string, mixed> $data
* @return bool
@@ -475,6 +576,10 @@ class ControllerExtensionPaymentPayanyway extends Controller {
* @throws \Exception
*/
private function handleCheckCallback(array $callbackData) {
if (!$this->isOrderPaid()) {
$this->rejectPaymentMismatch($callbackData);
}
list($resultCode, $description) = $this->determineCheckResult($callbackData);
$responseData = $this->buildBaseResponseData($callbackData, $resultCode, $description);
@@ -494,26 +599,15 @@ class ControllerExtensionPaymentPayanyway extends Controller {
* @throws \Exception
*/
private function handlePayCallback(array $callbackData) {
$currencyCode = isset($this->orderInfo['currency_code']) ? $this->orderInfo['currency_code'] : 'RUB';
$shopData = array(
'MNT_ID' => $this->mntId,
'MNT_TRANSACTION_ID' => $callbackData['MNT_TRANSACTION_ID'],
'MNT_OPERATION_ID' => $callbackData['MNT_OPERATION_ID'],
'MNT_AMOUNT' => $this->getOrderAmount(),
'MNT_CURRENCY_CODE' => $currencyCode,
'MNT_SUBSCRIBER_ID' => $callbackData['MNT_SUBSCRIBER_ID'],
'MNT_TEST_MODE' => $callbackData['MNT_TEST_MODE'],
);
if (!$this->checkSignature($shopData, $callbackData['MNT_SIGNATURE'])) {
$this->sendResponse(self::FAIL_RESPONSE);
}
$isPayedOrder = $this->isOrderPaid();
if (!$isPayedOrder) {
$this->rejectPaymentMismatch($callbackData);
$this->rejectCanceledOrder($callbackData);
$this->load->language('extension/payment/payanyway');
$this->addOrderHistory($this->successOrderStatusId, $this->getPaymentCompletedComment($callbackData['MNT_OPERATION_ID']));
}
$this->model_extension_payment_payanyway->releaseOrderLock($this->orderId);
$resultCode = 200;
$description = $isPayedOrder ? 'Order already paid' : 'Order success paid';
@@ -522,6 +616,80 @@ class ControllerExtensionPaymentPayanyway extends Controller {
$this->sendResponse($this->buildXmlResponse($responseData + $this->getResponseAttributes()));
}
/**
* @param array<string, mixed> $callbackData
* @return void
* @throws \Exception
*/
private function rejectPaymentMismatch(array $callbackData) {
$mismatch = $this->getPaymentMismatch($callbackData);
if (null !== $mismatch) {
$this->log->write('PayAnyWay callback: ' . $mismatch);
$this->sendResponse(self::FAIL_RESPONSE);
}
}
/**
* Заказ отменили до оплаты — оплата его не проводит: подписанный 500 без атрибутов прекращает
* повторы, номер операции в логе нужен администратору для возврата в кабинете MONETA.RU.
* Как Check URL и остальные модули.
*
* @param array<string, mixed> $callbackData
* @return void
* @throws \Exception
*/
private function rejectCanceledOrder(array $callbackData) {
$response = $this->buildCanceledOrderPayResponse($callbackData);
if (null !== $response) {
$this->log->write('PayAnyWay callback: order ' . $this->orderId . ' is canceled, payment ' . $callbackData['MNT_OPERATION_ID'] . ' must be refunded');
$this->sendResponse($response);
}
}
/**
* @param array<string, mixed> $callbackData
* @return array{data: string, contentType: string}|null
* @throws \Exception
*/
private function buildCanceledOrderPayResponse(array $callbackData) {
$orderStatusId = isset($this->orderInfo['order_status_id']) ? (int)$this->orderInfo['order_status_id'] : 0;
if (!in_array($orderStatusId, $this->canceledOrderStatusIds, true)) {
return null;
}
return $this->buildXmlResponse($this->buildBaseResponseData($callbackData, 500, 'Order status is ' . $this->getOrderStatusById($orderStatusId)), false);
}
/**
* @param array<string, mixed> $callbackData
* @return string|null
* @throws \Exception
*/
private function getPaymentMismatch(array $callbackData) {
$transaction = $this->parseTransactionId($callbackData['MNT_TRANSACTION_ID']);
if ((null === $transaction) || ($transaction['order_id'] !== $this->orderId)) {
return 'MNT_TRANSACTION_ID has unexpected format';
}
if (null === $transaction['amount']) {
$issuedAt = \DateTime::createFromFormat('!YmdHis', $transaction['issued_at']);
if ((false === $issuedAt) || ($issuedAt->modify('+' . self::LEGACY_TRANSACTION_MAX_AGE_DAYS . ' days') < new \DateTime())) {
return 'MNT_TRANSACTION_ID of the previous format has expired';
}
} elseif (('' !== $callbackData['MNT_AMOUNT']) && ($callbackData['MNT_AMOUNT'] !== $transaction['amount'])) {
return 'MNT_AMOUNT does not match MNT_TRANSACTION_ID';
} elseif ($transaction['amount'] !== $this->getOrderAmount()) {
return 'order amount changed after the payment form was issued';
}
$isCheck = 'CHECK' === $callbackData['MNT_COMMAND'];
if ((!$isCheck || ('' !== $callbackData['MNT_AMOUNT'])) && !$this->isOrderAmountMatched($callbackData)) {
return 'MNT_AMOUNT or MNT_CURRENCY_CODE does not match the order';
}
return null;
}
/**
* @param string $operationId
* @return string
@@ -603,7 +771,7 @@ class ControllerExtensionPaymentPayanyway extends Controller {
return array(100, "Order status is {$orderStatus}");
}
if ($this->successOrderStatusId === $orderStatusId) {
if ($this->isOrderPaid()) {
return array(200, 'Order Paid');
}
@@ -614,13 +782,34 @@ class ControllerExtensionPaymentPayanyway extends Controller {
return array(402, 'Order created, but not paid');
}
/** @return bool */
/**
* @return bool
* @throws \Exception
*/
private function isOrderPaid() {
if (!isset($this->orderInfo['order_status_id'])) {
return false;
}
return $this->successOrderStatusId === (int)$this->orderInfo['order_status_id'];
if ($this->successOrderStatusId === (int)$this->orderInfo['order_status_id']) {
return true;
}
$this->load->model('extension/payment/payanyway');
return $this->model_extension_payment_payanyway->hasOrderStatusInHistory((int)$this->orderId, $this->successOrderStatusId);
}
/**
* @param array<string, mixed> $callbackData
* @return bool
* @throws \Exception
*/
private function isOrderAmountMatched(array $callbackData) {
$currencyCode = isset($this->orderInfo['currency_code']) ? (string)$this->orderInfo['currency_code'] : '';
return ($callbackData['MNT_AMOUNT'] === $this->getOrderAmount())
&& ($callbackData['MNT_CURRENCY_CODE'] === $currencyCode);
}
/**
@@ -936,7 +1125,7 @@ class ControllerExtensionPaymentPayanyway extends Controller {
* @param string $vatRate
* @return string
*/
public function getVat($vatRate) {
private function getVat($vatRate) {
$map = array(
'1104' => 'vat0',
'1108' => 'vat5',
@@ -959,7 +1148,7 @@ class ControllerExtensionPaymentPayanyway extends Controller {
* @return string
* @throws \Exception
*/
public function getOrderStatusById($orderStatusId) {
private function getOrderStatusById($orderStatusId) {
$this->load->model('localisation/order_status');
/**@var array<int, array{order_status_id: string, name: string}> $orderStatuses */
$orderStatuses = $this->model_localisation_order_status->getOrderStatuses();
@@ -988,7 +1177,7 @@ class ControllerExtensionPaymentPayanyway extends Controller {
* @return array
* @throws \JsonException
*/
public function buildJsonResponse(array $data) {
private function buildJsonResponse(array $data) {
$client = $data['client'];
if (isset($client['phone']) && (strlen($client['phone']) > self::CHECK_CLIENT_PHONE_MAX_LENGTH)) {
unset($client['phone']);
@@ -1014,7 +1203,7 @@ class ControllerExtensionPaymentPayanyway extends Controller {
$json = json_encode($jsonData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (false !== $json) {
$json = preg_replace('/"' . preg_quote(self::JSON_NUMBER_PREFIX, '/') . '([0-9]+\.[0-9]{2})"/', '$1', $json);
$json = preg_replace('/"(' . self::JSON_NUMBER_KEYS . ')":"' . preg_quote(self::JSON_NUMBER_PREFIX, '/') . '([0-9]+\.[0-9]{2})"/', '"$1":$2', $json);
}
return array(
@@ -1057,7 +1246,7 @@ class ControllerExtensionPaymentPayanyway extends Controller {
foreach ($requiredFields as $field) {
if (isset($data[$field]) && ($data[$field] !== '')) {
$root->appendChild($dom->createElement($field, (string)$data[$field]));
$root->appendChild($this->createTextElement($dom, $field, (string)$data[$field]));
}
}
@@ -1078,8 +1267,8 @@ class ControllerExtensionPaymentPayanyway extends Controller {
foreach ($attributeMap as $key => $value) {
if (!empty($value)) {
$attrElement = $dom->createElement('ATTRIBUTE');
$attrElement->appendChild($dom->createElement('KEY', $key));
$attrElement->appendChild($dom->createElement('VALUE', (string)$value));
$attrElement->appendChild($this->createTextElement($dom, 'KEY', $key));
$attrElement->appendChild($this->createTextElement($dom, 'VALUE', (string)$value));
$attributes->appendChild($attrElement);
}
}
@@ -1087,6 +1276,22 @@ class ControllerExtensionPaymentPayanyway extends Controller {
return $this->saveXmlResponse($dom);
}
/**
* createElement($name, $value) не экранирует «&» — текст только через createTextNode().
*
* @param \DOMDocument $dom
* @param string $name
* @param string $value
* @return \DOMElement
* @throws DOMException
*/
private function createTextElement(\DOMDocument $dom, $name, $value) {
$element = $dom->createElement($name);
$element->appendChild($dom->createTextNode($value));
return $element;
}
/**
* @param \DOMDocument $dom
* @return array