Files
opensourcepos/app/Config/Validation/OSPOSRules.php
Nozomu Sasaki (Paul) 85c7ce2da4 Fix negative price/quantity/discount validation (GHSA-wv3j-pp8r-7q43) (#4450)
* Fix business logic vulnerability allowing negative sale totals (GHSA-wv3j-pp8r-7q43)

Add server-side validation in postEditItem() to reject negative prices,
quantities, and discounts, as well as percentage discounts exceeding 100%
and fixed discounts exceeding the item total. Also block sale completion
with negative totals in non-return mode to prevent fraud/theft.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix: exempt return mode from negative quantity validation

Return mode legitimately stores items with negative quantities.
The quantity validation now skips the non-negative check in return mode,
consistent with the existing return mode exemption in postComplete().
Also use abs() for fixed discount comparison to handle return quantities.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Refactor: use $rules + validate() pattern per review feedback

Address review comments from jekkos on PR #4450:

1. Use CI4 $rules variable with custom non_negative_decimal validation
   rule instead of manual if-checks for price/discount validation.

2. Add validation error strings to all 44 non-English language files
   (English fallback values used until translations are contributed).

3. Use validate() method with $messages array for localized error
   display, maintaining the existing controller pattern.

Additional improvements:
- Add non_negative_decimal rule to OSPOSRules.php (leverages
  parse_decimals() for locale-aware decimal parsing)
- Preserve manual checks for business logic (return mode quantity
  exemption, discount bounds via bccomp)
- Fix PHP 8.1+ compatibility: avoid passing method return to reset()
- Explicit empty discount handling for bc-math safety

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix: rename to nonNegativeDecimal (PSR), clear non-English translation strings

- Rename validation rule method non_negative_decimal → nonNegativeDecimal in
  OSPOSRules.php and all $rules/$messages references in Sales.php (PSR naming
  per @objecttothis review)
- Replace English fallback text with "" in 43 non-English language files so
  CI4 falls back to the base language string; weblate will handle translations
  (per @jekkos and @objecttothis agreement)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Paul <morimori-dev@github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: objecttothis <17935339+objecttothis@users.noreply.github.com>
2026-04-03 14:49:42 +04:00

154 lines
4.6 KiB
PHP

<?php
namespace App\Config\Validation;
use App\Models\Employee;
use CodeIgniter\HTTP\IncomingRequest;
use Config\OSPOS;
use Config\Services;
/**
* @property Employee employee
* @property IncomingRequest request
*/
class OSPOSRules
{
private IncomingRequest $request;
private array $config;
/**
* Validates the username and password sent to the login view. User is logged in on successful validation.
*
* @param string $username Username to check against.
* @param string $fields Comma separated string of the fields for validation.
* @param array $data Data sent to the view.
* @param string|null $error The error sent back to the validation handler on failure.
* @return bool True if validation passes or false if there are errors.
* @noinspection PhpUnused
*/
public function login_check(string $username, string $fields, array $data, ?string &$error = null): bool
{
$employee = model(Employee::class);
$this->request = Services::request();
$this->config = config(OSPOS::class)->settings;
// Installation Check
if (!$this->installation_check()) {
$error = lang('Login.invalid_installation');
return false;
}
$password = $data['password'];
if (!$employee->login($username, $password)) {
$error = lang('Login.invalid_username_and_password');
return false;
}
$gcaptcha_enabled = array_key_exists('gcaptcha_enable', $this->config) && $this->config['gcaptcha_enable'];
if ($gcaptcha_enabled) {
$g_recaptcha_response = $this->request->getPost('g-recaptcha-response');
if (!$this->gcaptcha_check($g_recaptcha_response)) {
$error = lang('Login.invalid_gcaptcha');
return false;
}
}
return true;
}
/**
* Checks to see if GCaptcha verification was successful.
*
* @param $response
* @return bool true on successful GCaptcha verification or false if GCaptcha failed.
*/
private function gcaptcha_check($response): bool
{
if (!empty($response)) {
$check = [
'secret' => $this->config['gcaptcha_secret_key'],
'response' => $response,
'remoteip' => $this->request->getIPAddress()
];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, "https://www.google.com/recaptcha/api/siteverify");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($check));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$result = curl_exec($ch);
curl_close($ch);
$status = json_decode($result, true);
if (!empty($status['success'])) {
return true;
}
}
return false;
}
/**
* Checks to make sure dependency PHP extensions are installed
*
* @return bool
*/
private function installation_check(): bool
{
$installed_extensions = implode(', ', get_loaded_extensions());
$required_extensions = ['bcmath', 'intl', 'gd', 'openssl', 'mbstring', 'curl', 'xml', 'json'];
$pattern = '/';
foreach ($required_extensions as $extension) {
$pattern .= '(?=.*\b' . preg_quote($extension, '/') . '\b)';
}
$pattern .= '/i';
$is_installed = preg_match($pattern, $installed_extensions);
if (!$is_installed) {
log_message('error', '[ERROR] Check your php.ini.');
log_message('error', "PHP installed extensions: $installed_extensions");
log_message('error', 'PHP required extensions: ' . implode(', ', $required_extensions));
}
return $is_installed;
}
/**
* Validates the candidate as a decimal number. Takes the locale into account. Used in validation rule calls.
*
* @param string $candidate
* @param string|null $error
* @return bool
* @noinspection PhpUnused
*/
public function decimal_locale(string $candidate, ?string &$error = null): bool
{
return parse_decimals($candidate) !== false;
}
/**
* Validates that a locale-aware decimal value is non-negative (>= 0).
*
* @param string $candidate
* @param string|null $error
* @return bool
* @noinspection PhpUnused
*/
public function nonNegativeDecimal(string $candidate, ?string &$error = null): bool
{
$value = parse_decimals($candidate);
return $value !== false && $value >= 0;
}
}