Files
objecttothisandcoderabbitai[bot] b610ae28ac fix(validation): broaden sendmail path regex, expand i18n, strip advisory IDs
fix(validation): allow Windows sendmail paths, tighten shell metachar exclusions

Broaden PLAIN_FILESYSTEM_PATH_STRICT to accept real-world sendmail formats
while blocking command injection characters not needed in valid paths.

- OSPOSRules.php: allow space, colon, backslash for Windows paths
  (e.g. C:\wamp64\...) and trailing args (-t -i); still excludes
  ampersand, backtick, subshell, redirect, and cmd.exe metacharacters
- OSPOSRulesTest.php: add cases for Windows paths, trailing args, and
  injection payloads
- Remove 7 ConfigTest assertions that expected metacharacter rejection;
  add acceptance test for sendmail path with trailing args

i18n(lang): expand mailpath_invalid message across all locales

- Fill previously empty mailpath_invalid keys across all locales
- Update existing translations (de-CH, de-DE, es-ES, es-MX, fr, nl-BE,
  nl-NL) to reflect newly allowed characters; nl locales corrected from
  English loanwords to proper Dutch terms
- Add missing key to ckb/Config.php

docs: remove security advisory IDs from public-facing files

- AGENTS.md: extend no-advisory-ID rule to documentation and URLs
- INSTALL.md: drop GHSA reference and advisory link from Host Header
  Injection guidance; rationale and fix instructions remain intact

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Signed-off-by: objecttothis <17935339+objecttothis@users.noreply.github.com>
2026-09-10 18:01:12 +04:00

276 lines
9.2 KiB
PHP

<?php
namespace App\Config\Validation;
use App\Models\Employee;
use CodeIgniter\HTTP\IncomingRequest;
use Config\OSPOS;
use Config\Services;
use DirectoryIterator;
use IntlChar;
/**
* @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;
}
$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;
}
}
$password = $data['password'];
if (!$employee->login($username, $password)) {
$error = lang('Login.invalid_username_and_password');
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.
*/
protected 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;
}
/**
* Validates that the candidate value is greater than or equal to another
* field in the same request (proper cross-field comparison).
*
* CI4's built-in greater_than_equal_to[field] rule does not resolve the
* [field] token to that field's value, so this rule performs the real
* comparison and sets a human-readable error on failure.
*
* @param string $candidate The value being validated (e.g. max).
* @param string $otherField The field to compare against (e.g. min).
* @param array $data The full set of data being validated.
* @param string|null $error Error message set on failure.
* @return bool
* @noinspection PhpUnused
*/
public function gte_field(string $candidate, string $otherField, array $data, ?string &$error = null): bool
{
$other = $data[$otherField] ?? null;
if (is_numeric($candidate) && is_numeric($other) && (float) $candidate < (float) $other) {
$error = 'The value must be a number greater than or equal to the ' . $otherField . ' field.';
return false;
}
return true;
}
/**
* Validates that the candidate theme name matches an installed bootswatch theme directory.
*
* @param string $theme
* @param string|null $error
* @return bool
* @noinspection PhpUnused
*/
public function themeExists(string $theme, ?string &$error = null): bool
{
$dir = new DirectoryIterator('resources/bootswatch');
foreach ($dir as $fileInfo) {
if (
$fileInfo->isDir()
&& !$fileInfo->isDot()
&& $fileInfo->getFilename() !== 'fonts'
&& $fileInfo->getFilename() === $theme
) {
return true;
}
}
return false;
}
/**
* Unicode-aware version of CodeIgniter's built-in `alpha_numeric_punct` rule, which only
* matches ASCII (`preg_match('\A[A-Z0-9 ~!#$%\&\*\-_+=|:.]+\z/i', ...)`) and so rejects
* legitimate non-English text (e.g. accented or CJK characters). Allows unicode letters,
* combining marks (so base+diacritic sequences pass), and digits in place of `A-Z0-9`, and
* reuses the exact same punctuation set as the original rule (`~!#$%&*-_+=|:.` plus space),
* extended with `'` and `,` to accommodate real-world tax names (e.g. "O'Brien's Tax",
* "Impôt, incl."). `<` and `>` are deliberately absent from the punctuation set, same as in
* the original rule, so this also serves as a defense-in-depth backstop against HTML
* injection (the primary fix is escaping at render time).
*
* @param string $candidate
* @param string|null $error
* @return bool
* @noinspection PhpUnused
*/
public function unicode_alpha_numeric_punct(string $candidate, ?string &$error = null): bool
{
$allowedPunctuation = ['~', '!', '#', '$', '%', '&', '*', '-', '_', '+', '=', '|', ':', '.', ' ', "'", ','];
$allowedCategories = [
IntlChar::CHAR_CATEGORY_UPPERCASE_LETTER,
IntlChar::CHAR_CATEGORY_LOWERCASE_LETTER,
IntlChar::CHAR_CATEGORY_TITLECASE_LETTER,
IntlChar::CHAR_CATEGORY_MODIFIER_LETTER,
IntlChar::CHAR_CATEGORY_OTHER_LETTER,
IntlChar::CHAR_CATEGORY_NON_SPACING_MARK,
IntlChar::CHAR_CATEGORY_COMBINING_SPACING_MARK,
IntlChar::CHAR_CATEGORY_ENCLOSING_MARK,
IntlChar::CHAR_CATEGORY_DECIMAL_DIGIT_NUMBER,
IntlChar::CHAR_CATEGORY_LETTER_NUMBER,
IntlChar::CHAR_CATEGORY_OTHER_NUMBER,
];
foreach (mb_str_split($candidate) as $character) {
if (in_array($character, $allowedPunctuation, true)) {
continue;
}
if (!in_array(IntlChar::charType($character), $allowedCategories, true)) {
return false;
}
}
return true;
}
/**
* Validates a plain filesystem path, allowing space/colon/backslash for Windows paths and
* trailing sendmail-style args. Excludes shell metacharacters since this value is concatenated
* unescaped into a popen() call. Uses \A...\z, not ^...$, since $ also matches before a
* trailing newline.
*
* @param string $candidate
* @param string|null $error
* @return bool
* @noinspection PhpUnused
*/
public function valid_path_strict(string $candidate, ?string &$error = null): bool
{
if ($candidate === '') {
return false;
}
return (bool) preg_match('/\A[a-zA-Z0-9_\-\/.: \\\\]+\z/', $candidate);
}
}