From 00b97c3302b30eeb662412e5a5f3f5263f4f3aec Mon Sep 17 00:00:00 2001 From: jekkos Date: Tue, 22 Sep 2026 23:30:05 +0200 Subject: [PATCH] feat(security): add THROTTLE_KEY env-var fallback for throttle.key (#4707) checkThrottleEncryption() now consults the THROTTLE_KEY environment variable when throttle.key is empty, mirroring the ENCRYPTION_KEY fallback in Config/Encryption. This lets Docker/Compose deployments supply the throttle HMAC secret without writing a shared value into a read-only .env. An explicit throttle.key always takes precedence. Adds regression tests to the existing security_helperTest suite and documents THROTTLE_KEY in .env.example. --- .env.example | 4 ++ app/Helpers/security_helper.php | 11 +++++- tests/helpers/security_helperTest.php | 53 +++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index ad13b7c1e..d055638c1 100644 --- a/.env.example +++ b/.env.example @@ -62,6 +62,10 @@ database.tests.DBPrefix='ospos_' encryption.key='' # Persistent secret for HMAC-hashing login-throttle cache keys. Left blank and # provisioned on startup (php spark env:provision); independent of encryption.key. +# For Docker/Compose, pass it via the THROTTLE_KEY env var instead: +# docker run -e THROTTLE_KEY="$(openssl rand -hex 32)" opensourcepos +# THROTTLE_KEY is read as a fallback when throttle.key is empty, so no +# shared secret needs to be committed or baked into the shipped image. throttle.key='' #-------------------------------------------------------------------- diff --git a/app/Helpers/security_helper.php b/app/Helpers/security_helper.php index 469b94243..32635d2c8 100644 --- a/app/Helpers/security_helper.php +++ b/app/Helpers/security_helper.php @@ -327,12 +327,16 @@ function checkEncryption(?CI3SecretConverter $converter = null): bool * Returns the persistent HMAC secret used to hash login-throttle cache keys. * * Behaviour: - * - Key already present: returned immediately (no I/O). + * - Key already present (throttle.key or THROTTLE_KEY): returned immediately (no I/O). * - Key missing and .env IS writable: generates a fresh key and persists it. * - Key missing and .env NOT writable: * throws — the key was presumably already provisioned externally * (e.g. `php spark env:provision` at container startup). * + * THROTTLE_KEY is a real environment-variable fallback (mirroring ENCRYPTION_KEY): + * it is consulted only when throttle.key is empty, so it can be supplied via + * Docker/Compose without writing a shared secret into .env. + * * @return string the throttle key * @throws RuntimeException if the key cannot be provisioned */ @@ -340,6 +344,11 @@ function checkThrottleEncryption(): string { $key = (string) env('throttle.key', ''); + if ($key === '') { + $envKey = getenv('THROTTLE_KEY'); + $key = $envKey === false ? '' : $envKey; + } + if ($key !== '') { return $key; } diff --git a/tests/helpers/security_helperTest.php b/tests/helpers/security_helperTest.php index 75782e179..7d18282e7 100644 --- a/tests/helpers/security_helperTest.php +++ b/tests/helpers/security_helperTest.php @@ -467,6 +467,59 @@ class security_helperTest extends CIUnitTestCase } } + public function testCheckThrottleEncryptionFallsBackToThrottleKeyEnvVar(): void + { + $throttleKey = bin2hex(random_bytes(32)); + + // Clear the dot-notation throttle.key so the THROTTLE_KEY fallback is + // exercised, and leave a .env with no throttle.key so any accidental + // provisioning would be visible in the file assertion below. + putenv('throttle.key'); + unset($_ENV['throttle.key'], $_SERVER['throttle.key']); + file_put_contents($this->envPath, "# tmp\n"); + + $previous = getenv('THROTTLE_KEY'); + putenv("THROTTLE_KEY=$throttleKey"); + + try { + $result = checkThrottleEncryption(); + + $this->assertSame($throttleKey, $result); + $this->assertStringNotContainsString('throttle.key=', file_get_contents($this->envPath), 'the THROTTLE_KEY fallback must not trigger provisioning/write'); + } finally { + if ($previous === false) { + putenv('THROTTLE_KEY'); + } else { + putenv("THROTTLE_KEY=$previous"); + } + } + } + + public function testCheckThrottleEncryptionPrefersThrottleKeyOverEnvVar(): void + { + $explicit = bin2hex(random_bytes(32)); + $fallback = bin2hex(random_bytes(32)); + + putenv("throttle.key=$explicit"); + $_ENV['throttle.key'] = $explicit; + $_SERVER['throttle.key'] = $explicit; + + $previous = getenv('THROTTLE_KEY'); + putenv("THROTTLE_KEY=$fallback"); + + try { + $result = checkThrottleEncryption(); + + $this->assertSame($explicit, $result, 'an explicit throttle.key must take precedence over the THROTTLE_KEY fallback'); + } finally { + if ($previous === false) { + putenv('THROTTLE_KEY'); + } else { + putenv("THROTTLE_KEY=$previous"); + } + } + } + // -- rotateEncryptionKey() — provisioning path, does write -- public function testRotateEncryptionKeyPersistsNewKey(): void