From 47aade5024b1f3f96700a39049ebe84c37c6d540 Mon Sep 17 00:00:00 2001 From: jekkos Date: Mon, 21 Sep 2026 17:36:50 +0200 Subject: [PATCH] fix(locale): validate language_code against known locales to block path traversal (#4704) * fix(locale): validate language_code against known locales to block path traversal postSaveLocale() stored language_code from user input with no allow-list validation, and it later flows into Language::setLocale()/load() where the locale segment is require()'d. An authenticated config-grant account could store a relative path (e.g. ../../public/uploads) and, combined with a planted file in public/uploads/, achieve unauthenticated RCE on the next request. Validate the submitted language against array_keys(get_languages()) before storing, and harden languageExists() to reject path separators and dot-dot sequences. Adds regression tests. * test(locale): give locale fixture valid reference-code min/max defaults * fix(locale): reject null bytes in languageExists guard A stored language_code containing a NUL byte passes the existing path-separator and parent-dir checks, then reaches file_exists(). On PHP 8.5+ file_exists() throws a ValueError for NUL-byte paths, which breaks configuration loading. Reject NUL bytes in the guard and add regression tests. --- app/Controllers/Config.php | 7 +++- app/Events/Load_config.php | 4 +++ tests/Controllers/ConfigTest.php | 59 +++++++++++++++++++++++++++++++- tests/Events/Load_configTest.php | 49 ++++++++++++++++++++++++++ 4 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 tests/Events/Load_configTest.php diff --git a/app/Controllers/Config.php b/app/Controllers/Config.php index b448a87fb..420c92b98 100644 --- a/app/Controllers/Config.php +++ b/app/Controllers/Config.php @@ -506,7 +506,12 @@ class Config extends Secure_Controller return $this->response->setJSON(['success' => false, 'message' => reset($errors)]); } - $exploded = explode(":", $this->request->getPost('language')); + $language = $this->request->getPost('language'); + if (!in_array($language, array_keys(get_languages()), true)) { + return $this->response->setJSON(['success' => false, 'message' => 'Invalid language']); + } + + $exploded = explode(":", $language); $currency_symbol = $this->request->getPost('currency_symbol'); $batch_save_data = [ 'currency_symbol' => htmlspecialchars($currency_symbol ?? ''), diff --git a/app/Events/Load_config.php b/app/Events/Load_config.php index 89e4ece27..d98d68e66 100644 --- a/app/Events/Load_config.php +++ b/app/Events/Load_config.php @@ -62,6 +62,10 @@ class Load_config private function languageExists(string $languageCode): bool { + if (strpbrk($languageCode, '/\\') !== false || str_contains($languageCode, '..') || str_contains($languageCode, "\0")) { + return false; + } + return file_exists(APPPATH . 'Language/' . $languageCode); } } diff --git a/tests/Controllers/ConfigTest.php b/tests/Controllers/ConfigTest.php index 486a64b76..a9c913096 100644 --- a/tests/Controllers/ConfigTest.php +++ b/tests/Controllers/ConfigTest.php @@ -136,7 +136,7 @@ class ConfigTest extends CIUnitTestCase private function baseLocalePayload(array $overrides = []): array { return array_merge([ - 'language' => 'en:English', + 'language' => 'en:english', 'currency_symbol' => '$', 'currency_code' => 'USD', 'timezone' => 'UTC', @@ -151,6 +151,8 @@ class ConfigTest extends CIUnitTestCase 'payment_options_order' => '', 'cash_rounding_code' => '', 'financial_year' => '1', + 'payment_reference_code_min' => '3', + 'payment_reference_code_max' => '20', ], $overrides); } @@ -239,6 +241,61 @@ class ConfigTest extends CIUnitTestCase $this->assertFalse($result['success']); } + // ========== postSaveLocale: language_code allow-list (GHSA) ========== + + public function testSaveLocale_RejectsPathTraversalLanguageCode(): void + { + $this->resetSession(); + + $response = $this->post('/config/saveLocale', $this->baseLocalePayload([ + 'language' => '../../public/uploads:evil', + ])); + + $response->assertStatus(200); + $result = json_decode($response->getJSON(), true); + $this->assertFalse($result['success']); + $this->assertStringContainsString('language', strtolower($result['message'])); + } + + public function testSaveLocale_RejectsLanguageCodeWithBackslash(): void + { + $this->resetSession(); + + $response = $this->post('/config/saveLocale', $this->baseLocalePayload([ + 'language' => '..\\..\\public\\uploads:evil', + ])); + + $response->assertStatus(200); + $result = json_decode($response->getJSON(), true); + $this->assertFalse($result['success']); + } + + public function testSaveLocale_RejectsUnknownLanguage(): void + { + $this->resetSession(); + + $response = $this->post('/config/saveLocale', $this->baseLocalePayload([ + 'language' => 'xx:nonexistent', + ])); + + $response->assertStatus(200); + $result = json_decode($response->getJSON(), true); + $this->assertFalse($result['success']); + } + + public function testSaveLocale_RejectsCaseMismatchedLanguage(): void + { + $this->resetSession(); + + $response = $this->post('/config/saveLocale', $this->baseLocalePayload([ + 'language' => 'en:English', + ])); + + $response->assertStatus(200); + $result = json_decode($response->getJSON(), true); + $this->assertFalse($result['success']); + } + // ========== postSaveGeneral: theme validation ========== private function baseGeneralPayload(array $overrides = []): array diff --git a/tests/Events/Load_configTest.php b/tests/Events/Load_configTest.php new file mode 100644 index 000000000..29a7c6190 --- /dev/null +++ b/tests/Events/Load_configTest.php @@ -0,0 +1,49 @@ +setAccessible(true); + + return (bool) $method->invoke($instance, $languageCode); + } + + public function testLanguageExistsAcceptsValidCode(): void + { + $this->assertTrue($this->languageExists('en')); + } + + public function testLanguageExistsRejectsNullByte(): void + { + $this->assertFalse($this->languageExists("en\0")); + } + + public function testLanguageExistsRejectsNullByteOnly(): void + { + $this->assertFalse($this->languageExists("\0")); + } + + public function testLanguageExistsRejectsForwardSlashPath(): void + { + $this->assertFalse($this->languageExists('en/../../etc/passwd')); + } + + public function testLanguageExistsRejectsBackslashPath(): void + { + $this->assertFalse($this->languageExists('..\..\etc\passwd')); + } + + public function testLanguageExistsRejectsParentDir(): void + { + $this->assertFalse($this->languageExists('..')); + } +}