Files
opensourcepos/app/Helpers/security_helper.php
objecttothis 9ee530e454 Hotfix: Fix CI3 database migration caused by regression (#4649)
* refactor: standardize function and variable names to camelCase and improve naming consistency across files

* refactor(config): remove spaces around `=` in configuration files for improved consistency and formatting as is required by .env formatting rules.

* refactor(security): extract `.env` key management logic into reusable `writeEnvKey` helper, add throttle key provisioning logic, and streamline encryption key updates

* fix(migration): improve error handling in CI3 to CI4 encryption data migration
- Secure `up` and `convertCI3EncryptedData` methods with detailed exception handling for script execution and data saving.

* fix(migration): ensure empty string is correctly handled in CI3 to CI4 encryption data conversion

* refactor(security): enhance `.env` management with durable writes, better locking, and helper abstraction
- Update `writeEnvKey` to return a success flag and handle file locks robustly.
- Introduce `atomicWriteFile` for atomic writes to prevent partial file updates.
- Add `applyEnvKeyReplacement` to streamline `.env` key insertion and updates.
- Improve throttle key provisioning with validation and runtime persistence safeguards.

* refactor(security): implement dedicated `.env` file locking for robust and cross-platform safe write operations
- Add `lockEnvFile` and `unlockEnvFile` helpers to manage `.env` mutex files.
- Refactor `.env` write logic to use lock helpers, improving reliability and preventing race conditions.
- Enhance `atomicWriteFile` for better handling of file overwrites on Windows and POSIX systems.

* fix(migration): improve encryption error handling during CI3 to CI4 data conversion
- Add conditional checks for `checkEncryption` to prevent failed key persistence.
- Introduce `abortEncryptionConversion` for cleanup on failure.
- Update `writeEnvKey` to handle and return errors gracefully.

* refactor(security): improve `atomicWriteFile` for better file locking and cross-platform durability
- Replace `uniqid` with `bin2hex(random_bytes())` for more secure temp file naming.
- Add explicit file permissions and locking for safe concurrent writes.
- Enhance error handling to ensure atomicity on both Windows and POSIX systems.

* Add env temp files to gitignore so they don't get tracked.

---------

Signed-off-by: objecttothis <17935339+objecttothis@users.noreply.github.com>
2026-08-21 21:00:48 +04:00

323 lines
8.5 KiB
PHP

<?php
use CodeIgniter\Encryption\Encryption;
use Config\Services;
use Random\RandomException;
/**
* Opens (creating if needed) and exclusively locks a dedicated mutex file
* for coordinating .env writes. Windows can't rename/delete a file while
* any handle to it is open, so the mutex must be a separate file from
* .env itself — never fopen()/flock() .env directly.
*
* @return resource
*/
function lockEnvFile()
{
$lockPath = ROOTPATH . '.env.lock';
$handle = @fopen($lockPath, 'c+');
if ($handle === false) {
throw new RuntimeException("Unable to open $lockPath");
}
if (!flock($handle, LOCK_EX)) {
fclose($handle);
throw new RuntimeException("Unable to lock $lockPath");
}
return $handle;
}
/**
* @param resource $handle
* @return void
*/
function unlockEnvFile($handle): void
{
flock($handle, LOCK_UN);
fclose($handle);
}
/**
* Replaces or inserts a single `key='value'` line in .env
*
* @param string $envKey
* @param string $value
* @return bool true on success, false if the write could not be completed
*/
function writeEnvKey(string $envKey, string $value): bool
{
$configPath = ROOTPATH . '.env';
if (!file_exists($configPath)) {
$examplePath = ROOTPATH . '.env.example';
if (file_exists($examplePath)) {
@copy($examplePath, $configPath);
} else {
@file_put_contents($configPath, "# OSPOS Configuration\n\n");
}
@chmod($configPath, 0640);
}
if (!file_exists($configPath)) {
return false;
}
$lock = lockEnvFile();
try {
$configFile = file_get_contents($configPath);
if ($configFile === false) {
return false;
}
$configFile = applyEnvKeyReplacement($configFile, $envKey, $value);
return atomicWriteFile($configPath, $configFile);
} finally {
unlockEnvFile($lock);
}
}
/**
* @param string $configFile
* @param string $envKey
* @param string $value
* @return string
*/
function applyEnvKeyReplacement(string $configFile, string $envKey, string $value): string
{
$pattern = '/^\s*' . preg_quote($envKey, '/') . '\s*=.*/m';
if (preg_match($pattern, $configFile)) {
return preg_replace($pattern, "$envKey='$value'", $configFile, 1);
}
if (preg_match('/^encryption\.key\s*=.*$/m', $configFile, $matches, PREG_OFFSET_CAPTURE)) {
$insertAt = $matches[0][1] + strlen($matches[0][0]);
return substr_replace($configFile, "\n$envKey='$value'", $insertAt, 0);
}
return $configFile . "\n$envKey='$value'\n";
}
/**
* Writes $contents to a temp file in the same directory as $path, then
* renames it onto $path so readers never observe a partially-written file.
*
* @param string $path
* @param string $contents
* @return bool
*/
function atomicWriteFile(string $path, string $contents): bool
{
$tmpPath = $path . '.tmp.' . bin2hex(random_bytes(8));
$handle = @fopen($tmpPath, 'x');
if ($handle === false) {
return false;
}
if (!@chmod($tmpPath, 0640)) {
fclose($handle);
@unlink($tmpPath);
return false;
}
$written = fwrite($handle, $contents);
if ($written === false || $written !== strlen($contents) || !fflush($handle)) {
fclose($handle);
@unlink($tmpPath);
return false;
}
if (function_exists('fsync') && !fsync($handle)) {
fclose($handle);
@unlink($tmpPath);
return false;
}
fclose($handle);
// rename() overwrites an existing destination on POSIX. On Windows it
// does not, so fall back to unlink()+rename() there. Callers must not
// hold any open handle on $path — Windows can't unlink/rename a path
// that's still open, even by the same process.
if (!@rename($tmpPath, $path)) {
if (PHP_OS_FAMILY !== 'Windows' || !@unlink($path) || !@rename($tmpPath, $path)) {
@unlink($tmpPath);
return false;
}
}
@chmod($path, 0640);
return true;
}
/**
* @return bool
*/
function checkEncryption(): bool
{
$oldKey = config('Encryption')->key;
if ((empty($oldKey)) || (strlen($oldKey) < 64)) {
$encryption = new Encryption();
$key = bin2hex($encryption->createKey());
config('Encryption')->key = $key;
$configPath = ROOTPATH . '.env';
$backupPath = WRITEPATH . '/backup/.env.bak';
$backupFolder = WRITEPATH . '/backup';
if (!file_exists($backupFolder)) {
@mkdir($backupFolder, 0750, true);
}
if (!file_exists($configPath)) {
$examplePath = ROOTPATH . '.env.example';
if (file_exists($examplePath)) {
@copy($examplePath, $configPath);
} else {
@file_put_contents($configPath, "# OSPOS Configuration\n\n");
}
@chmod($configPath, 0640);
}
if (file_exists($configPath)) {
@copy($configPath, $backupPath);
@chmod($backupPath, 0640);
@chmod($configPath, 0640);
if (!writeEnvKey('encryption.key', $key)) {
return false;
}
if (!empty($oldKey)) {
$configFile = file_get_contents($configPath);
$oldLine = "# encryption.key='$oldKey' REMOVE IF UNNEEDED\r\n";
if (preg_match('/^encryption\.key\s*=/m', $configFile, $matches, PREG_OFFSET_CAPTURE)) {
$configFile = substr_replace($configFile, $oldLine, $matches[0][1], 0);
@file_put_contents($configPath, $configFile);
@chmod($configPath, 0640);
}
}
log_message('info', "Updated encryption key in $configPath");
}
}
return true;
}
/**
* Returns a persistent secret for HMAC-hashing login-throttle cache keys.
*
* Deliberately independent of checkEncryption()/encryption.key: the throttle
* filter runs before the login-triggered CI3->CI4 migration, so provisioning
* this secret must never touch or rotate the encryption key.
*
* @return string
* @throws RandomException
* @throws RuntimeException if the key cannot be durably persisted
*/
function checkThrottleEncryption(): string
{
$key = (string) env('throttle.key', '');
if (!empty($key)) {
return $key;
}
$configPath = ROOTPATH . '.env';
if (!file_exists($configPath)) {
$examplePath = ROOTPATH . '.env.example';
if (file_exists($examplePath)) {
@copy($examplePath, $configPath);
} else {
@file_put_contents($configPath, "# OSPOS Configuration\n\n");
}
@chmod($configPath, 0640);
}
if (!file_exists($configPath)) {
throw new RuntimeException("Unable to create $configPath to provision throttle.key");
}
$lock = lockEnvFile();
try {
$configFile = file_get_contents($configPath);
if ($configFile === false) {
throw new RuntimeException("Unable to read $configPath to provision throttle.key");
}
// Another process may have provisioned the key while we waited for the lock.
if (preg_match('/^\s*throttle\.key\s*=\s*[\'"]?([^\'"\r\n]*)/m', $configFile, $matches)) {
$existing = trim($matches[1]);
if ($existing !== '') {
$key = $existing;
}
}
if (empty($key)) {
$key = bin2hex(random_bytes(32));
$configFile = applyEnvKeyReplacement($configFile, 'throttle.key', $key);
if (!atomicWriteFile($configPath, $configFile)) {
throw new RuntimeException("Unable to persist throttle.key to $configPath");
}
}
} finally {
unlockEnvFile($lock);
}
putenv("throttle.key=$key");
$_ENV['throttle.key'] = $key;
$_SERVER['throttle.key'] = $key;
log_message('info', 'Provisioned throttle key in ' . ROOTPATH . '.env');
return $key;
}
/**
* @return void
*/
function abortEncryptionConversion(): void
{
$configPath = ROOTPATH . '.env';
$backupPath = WRITEPATH . '/backup/.env.bak';
if (!file_exists($backupPath)) {
return;
}
@chmod($configPath, 0640);
$configFile = file_get_contents($backupPath);
@file_put_contents($configPath, $configFile);
log_message('info', "Restored $configPath from backup");
}
/**
* @return void
*/
function removeBackup(): void
{
$backupPath = WRITEPATH . '/backup/.env.bak';
if (!file_exists($backupPath)) {
return;
}
@unlink($backupPath);
log_message('info', "Removed $backupPath");
}