From 2030d9f7bfceaa8dc21dc4d8182cfee56897b028 Mon Sep 17 00:00:00 2001 From: TUNG-YEN Date: Fri, 6 Nov 2026 11:52:10 +0800 Subject: [PATCH 1/2] Isolate the test login route and refuse unsafe production modes The test-only login controller lived in controllers/ and relied on a single YII_ENV_TEST check. Move it under tests/_support, mount it only from the test config, and also require APP_ENV=testing/test before it answers. Production now refuses to start unless YII_ENV=prod and YII_DEBUG=false, matching what the configuration guide already promised. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 + config/ConfigValidator.php | 26 ++++++ config/test.php | 5 ++ docs/CONFIGURATION.md | 2 +- docs/RELEASING.md | 2 + tests/TEST_PLAN.md | 2 +- .../_support/TestLoginController.php | 26 +++++- tests/e2e/README.md | 2 +- .../unit/config/ProductionRuntimeModeTest.php | 82 +++++++++++++++++++ 9 files changed, 142 insertions(+), 7 deletions(-) rename controllers/TestController.php => tests/_support/TestLoginController.php (74%) create mode 100644 tests/unit/config/ProductionRuntimeModeTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 1177231..4a0ff20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,8 @@ ### Security +* 測試專用登入路由移出正式程式目錄(`tests/_support/TestLoginController.php`),只由測試設定掛載,且須 Yii 測試模式加上 `APP_ENV=testing`/`test` 才可使用,其他情況回 404 +* **升級注意**:正式環境(`APP_ENV=production`)必須為 `YII_ENV=prod`(或未設定)且 `YII_DEBUG=false`,否則系統拒絕啟動並列出原因;升級前請先檢查正式機 `.env` * 投票頁自訂欄位標題、候選名單內容與紙本選票的中英文資料統一淨化原本直接輸出的 HTML;列印分段表頭也套用淨化,保留既有換行與合法排版 * 重設密碼回應不透露帳號是否存在;token 只存 SHA-256;同帳號/IP 15 分鐘最多 5 次申請 * 升級 `phpoffice/phpspreadsheet` 至 5.10.0(CVE-2026-108357、CVE-2026-108358、CVE-2026-108359、CVE-2026-108360) diff --git a/config/ConfigValidator.php b/config/ConfigValidator.php index b24f753..30d1439 100644 --- a/config/ConfigValidator.php +++ b/config/ConfigValidator.php @@ -46,6 +46,7 @@ public static function validate(array $config, array $options = []): array $errors = array_merge($errors, self::validateProductionAllowedHosts()); $errors = array_merge($errors, self::validateProductionSaDbTools()); $errors = array_merge($errors, self::validateProductionLegacyAdminPassword()); + $errors = array_merge($errors, self::validateProductionRuntimeMode()); } // 3. 檢查資料庫配置 @@ -156,6 +157,31 @@ protected static function validateProductionSaDbTools(): array return []; } + /** + * 正式環境必須以 YII_ENV=prod、YII_DEBUG=false 執行 + * + * 測試模式會啟用測試專用行為,除錯模式會顯示堆疊與內部資訊;正式環境設定錯誤時拒絕啟動。 + * + * @param string|null $yiiEnv 預設取 YII_ENV 常數 + * @param bool|null $yiiDebug 預設取 YII_DEBUG 常數 + * @return array + */ + public static function validateProductionRuntimeMode(?string $yiiEnv = null, ?bool $yiiDebug = null): array + { + $yiiEnv = $yiiEnv ?? (defined('YII_ENV') ? (string) YII_ENV : 'prod'); + $yiiDebug = $yiiDebug ?? (defined('YII_DEBUG') && YII_DEBUG); + $errors = []; + + if ($yiiEnv !== 'prod') { + $errors[] = "正式環境必須使用 YII_ENV=prod(目前為 {$yiiEnv}),請修正 .env 的 YII_ENV 或移除該設定"; + } + if ($yiiDebug) { + $errors[] = '正式環境不得啟用 YII_DEBUG,請在 .env 設定 YII_DEBUG=false'; + } + + return $errors; + } + /** * 正式環境應關閉舊制 AES 管理員密碼(遷移完成後) * diff --git a/config/test.php b/config/test.php index 176f15b..66796e5 100644 --- a/config/test.php +++ b/config/test.php @@ -43,6 +43,11 @@ 'defaultRoute' => 'vote/index', 'basePath' => $basePath, + // 測試專用登入路由 /index-test.php/test/login;類別位於 tests/,正式設定不掛載 + 'controllerMap' => [ + 'test' => \app\tests\_support\TestLoginController::class, + ], + 'bootstrap' => [ 'lang', 'log', function($app) { diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 5d7d224..deb8e23 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -75,7 +75,7 @@ APP_ENV=production | 功能特性 | `production` (正式) | `testing` (測試) | `development` (開發) | | :--- | :--- | :--- | :--- | -| **YII_DEBUG** | 強制關閉 (安全性) | 預設關閉 | 由 `.env` 決定 (預設開啟) | +| **YII_DEBUG/YII_ENV** | 必須 `YII_DEBUG=false`、`YII_ENV=prod`(或未設定),否則拒絕啟動 | 預設關閉 | 由 `.env` 決定 (預設開啟) | | **Debug/Gii 工具** | **強行禁用** | **關閉** | 允許載入 (需 Debug 開啟) | | **資料庫 (DB)** | 使用 `DB_DSN` 系列 | 使用 **`TEST_DB_`** 系列變數 | 使用 `DB_DSN` 系列 | | **資料庫快取** | **開啟** (效能優化) | 關閉 | 關閉 | diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 73393ac..76742ee 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -23,6 +23,8 @@ Acceptance tests 需依 [tests/README.md](../tests/README.md) 啟動測試伺服 依賴解析以 `composer.json` 的 `config.platform.php`(目前 8.2.0)為最低支援平台;`check-platform-reqs` 仍須在實際部署環境執行。升級最低 PHP 版本時,一併檢視 `.github/dependabot.yml` 中暫緩的 Yii2 測試模組主要版本及 QR Code 6.1 以上更新,再分別驗證測試隔離與 TOTP QR 綁定流程。 +部署正式環境時不需包含 `tests/`、`web/index-test.php` 與 `web/router-test.php`。正式機 `.env` 必須為 `YII_ENV=prod`(或未設定)且 `YII_DEBUG=false`;設定不符時,`APP_ENV=production` 的網站會拒絕啟動並列出原因。 + 若環境有安裝 Gitleaks,另執行: ```bash diff --git a/tests/TEST_PLAN.md b/tests/TEST_PLAN.md index 14cdbaf..7b406bc 100644 --- a/tests/TEST_PLAN.md +++ b/tests/TEST_PLAN.md @@ -78,7 +78,7 @@ RUN_ACCEPTANCE=1 tests/run-all.sh | BallotWorkController | ✅ BallotWorkControllerCest | 中 | | **VoteToResult** | ✅ VoteToResultCest (1) | 佳(P4 路徑 1 完整鏈) | | MultiRoundVote | ✅ MultiRoundVoteCest (3) | 佳(P4 路徑 2 functional) | -| TestController | — | 可忽略(非正式功能) | +| TestLoginController(`tests/_support`,僅測試設定掛載) | ✅ ProductionRuntimeModeTest(隔離與環境限制) | 非正式功能 | ### 3.2 Unit 模組覆蓋(摘要) diff --git a/controllers/TestController.php b/tests/_support/TestLoginController.php similarity index 74% rename from controllers/TestController.php rename to tests/_support/TestLoginController.php index aca0e3e..0b0ef47 100644 --- a/controllers/TestController.php +++ b/tests/_support/TestLoginController.php @@ -1,17 +1,35 @@ ` 登入,僅測試環境可用。 +- 以測試專用路由 `/index-test.php/test/login?user=` 登入(`tests/_support/TestLoginController.php`,只由 `config/test.php` 掛載),測試伺服器須以 `APP_ENV=testing` 啟動,否則回 404。 - 點擊對話框內的按鈕前,先用 `Browser::openModal()` 等淡入動畫結束,否則結果不穩定。 - 只有一、兩個字元的文字檔會被偵測為 `application/octet-stream`,測試檔請用正常長度的內容。 diff --git a/tests/unit/config/ProductionRuntimeModeTest.php b/tests/unit/config/ProductionRuntimeModeTest.php new file mode 100644 index 0000000..f17443f --- /dev/null +++ b/tests/unit/config/ProductionRuntimeModeTest.php @@ -0,0 +1,82 @@ +savedAppEnv = getenv('APP_ENV'); + } + + protected function _after() + { + if ($this->savedAppEnv === false) { + putenv('APP_ENV'); + } else { + putenv('APP_ENV=' . $this->savedAppEnv); + } + } + + public function testProductionModeAcceptsProdWithoutDebug() + { + $this->assertSame([], ConfigValidator::validateProductionRuntimeMode('prod', false)); + } + + public function testRejectsNonProdYiiEnv() + { + foreach (['dev', 'test'] as $yiiEnv) { + $errors = ConfigValidator::validateProductionRuntimeMode($yiiEnv, false); + $this->assertCount(1, $errors, $yiiEnv); + $this->assertStringContainsString('YII_ENV=prod', $errors[0]); + } + } + + public function testRejectsDebugMode() + { + $errors = ConfigValidator::validateProductionRuntimeMode('prod', true); + $this->assertCount(1, $errors); + $this->assertStringContainsString('YII_DEBUG', $errors[0]); + } + + public function testValidateAppliesRuntimeModeOnlyInProduction() + { + // 單元測試以 YII_ENV=test、YII_DEBUG=true 執行,正式環境應被拒絕 + $config = ['db_password' => 'x', 'cookieValidationKey' => str_repeat('a', 32)]; + + putenv('APP_ENV=production'); + $errors = implode("\n", ConfigValidator::validate($config, ['strict' => false])); + $this->assertStringContainsString('YII_ENV=prod', $errors); + $this->assertStringContainsString('YII_DEBUG', $errors); + + putenv('APP_ENV=development'); + $errors = implode("\n", ConfigValidator::validate($config, ['strict' => false])); + $this->assertStringNotContainsString('YII_ENV=prod', $errors); + } + + public function testTestLoginIsNotPartOfProductionCode() + { + $root = dirname(__DIR__, 3); + $this->assertFileDoesNotExist($root . '/controllers/TestController.php'); + $this->assertStringNotContainsString('TestLoginController', file_get_contents($root . '/config/web.php')); + $this->assertStringContainsString('TestLoginController', file_get_contents($root . '/config/test.php')); + } + + public function testTestLoginRequiresTestingAppEnv() + { + $this->assertTrue(TestLoginController::isAllowed('testing')); + $this->assertTrue(TestLoginController::isAllowed('test')); + foreach ([null, '', 'production', 'prod', 'development', 'dev'] as $appEnv) { + $this->assertFalse(TestLoginController::isAllowed($appEnv), var_export($appEnv, true)); + } + } +} From 2e90ba97356ac144766bffbfddefd6ecfe59a559 Mon Sep 17 00:00:00 2001 From: TUNG-YEN Date: Fri, 6 Nov 2026 12:08:15 +0800 Subject: [PATCH 2/2] Clarify that YII_ENV takes Yii values, not APP_ENV values APP_ENV uses production/testing/development while YII_ENV only accepts prod/dev/test. Say so in .env.example and in the startup error, and cover YII_ENV=production in the runtime mode test. Co-Authored-By: Claude Opus 5.5 --- .env.example | 8 +++++--- config/ConfigValidator.php | 2 +- tests/unit/config/ProductionRuntimeModeTest.php | 3 ++- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index a1d3b8e..a74c664 100644 --- a/.env.example +++ b/.env.example @@ -12,10 +12,12 @@ # 環境識別 # ============================================ APP_ENV=development -# 可選值:production, testing, development +# 可選值:production, testing, development(本系統的環境判定) -# Yii 執行環境 -# 未設定時預設為正式環境(YII_DEBUG=false、YII_ENV=prod) +# Yii 執行環境(與 APP_ENV 是不同變數) +# YII_ENV 只接受 Yii 的值:prod, dev, test(不要填 production) +# 未設定時預設為正式模式(YII_DEBUG=false、YII_ENV=prod) +# APP_ENV=production 時必須為 YII_ENV=prod(或未設定)且 YII_DEBUG=false,否則系統拒絕啟動 # 開發環境請設定: # YII_DEBUG=true # YII_ENV=dev diff --git a/config/ConfigValidator.php b/config/ConfigValidator.php index 30d1439..28739d5 100644 --- a/config/ConfigValidator.php +++ b/config/ConfigValidator.php @@ -173,7 +173,7 @@ public static function validateProductionRuntimeMode(?string $yiiEnv = null, ?bo $errors = []; if ($yiiEnv !== 'prod') { - $errors[] = "正式環境必須使用 YII_ENV=prod(目前為 {$yiiEnv}),請修正 .env 的 YII_ENV 或移除該設定"; + $errors[] = "正式環境必須使用 YII_ENV=prod(目前為 {$yiiEnv};YII_ENV 只接受 prod、dev、test,與 APP_ENV 不同),請修正 .env 的 YII_ENV 或移除該設定"; } if ($yiiDebug) { $errors[] = '正式環境不得啟用 YII_DEBUG,請在 .env 設定 YII_DEBUG=false'; diff --git a/tests/unit/config/ProductionRuntimeModeTest.php b/tests/unit/config/ProductionRuntimeModeTest.php index f17443f..85baba2 100644 --- a/tests/unit/config/ProductionRuntimeModeTest.php +++ b/tests/unit/config/ProductionRuntimeModeTest.php @@ -34,7 +34,8 @@ public function testProductionModeAcceptsProdWithoutDebug() public function testRejectsNonProdYiiEnv() { - foreach (['dev', 'test'] as $yiiEnv) { + // production 是 APP_ENV 的值,Yii 不視為正式模式 + foreach (['dev', 'test', 'production'] as $yiiEnv) { $errors = ConfigValidator::validateProductionRuntimeMode($yiiEnv, false); $this->assertCount(1, $errors, $yiiEnv); $this->assertStringContainsString('YII_ENV=prod', $errors[0]);