Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
26 changes: 26 additions & 0 deletions config/ConfigValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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. 檢查資料庫配置
Expand Down Expand Up @@ -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};YII_ENV 只接受 prod、dev、test,與 APP_ENV 不同),請修正 .env 的 YII_ENV 或移除該設定";
}
if ($yiiDebug) {
$errors[] = '正式環境不得啟用 YII_DEBUG,請在 .env 設定 YII_DEBUG=false';
}

return $errors;
}

/**
* 正式環境應關閉舊制 AES 管理員密碼(遷移完成後)
*
Expand Down
5 changes: 5 additions & 0 deletions config/test.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 系列 |
| **資料庫快取** | **開啟** (效能優化) | 關閉 | 關閉 |
Expand Down
2 changes: 2 additions & 0 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion tests/TEST_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 模組覆蓋(摘要)

Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,35 @@
<?php
namespace app\controllers;
namespace app\tests\_support;

use Yii;
use yii\web\Controller;
use yii\web\NotFoundHttpException;
use app\models\Users;
use app\components\AdminIdentity;

class TestController extends Controller
/**
* 測試專用登入(acceptance/瀏覽器 E2E)。
*
* 只由 config/test.php 的 controllerMap 掛載為 `test` 路由,正式設定與 controllers/ 不含此類別;
* 部署時 tests/ 目錄不需隨程式發布。
*/
class TestLoginController extends Controller
{
/**
* 測試環境才允許:Yii 以測試模式啟動,且 APP_ENV 為 testing/test(與測試入口相同政策)
*/
public static function isAllowed(?string $appEnv): bool
{
$isTestEnvironment = require dirname(__DIR__, 2) . '/config/test-entry-policy.php';

return YII_ENV_TEST && $isTestEnvironment($appEnv);
}

public function actionLogin($user)
{
if (!YII_ENV_TEST) {
throw new \yii\web\ForbiddenHttpException('Not allowed');
$appEnv = getenv('APP_ENV');
if (!self::isAllowed($appEnv === false ? null : $appEnv)) {
throw new NotFoundHttpException();
}

// 使用 cn 欄位查找使用者
Expand Down
2 changes: 1 addition & 1 deletion tests/e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,6 @@ php tests/e2e/run.php --base=http://localhost:18080

## 注意事項

- 以測試專用路由 `/index-test.php/test/login?user=<cn>` 登入,僅測試環境可用。
- 以測試專用路由 `/index-test.php/test/login?user=<cn>` 登入(`tests/_support/TestLoginController.php`,只由 `config/test.php` 掛載),測試伺服器須以 `APP_ENV=testing` 啟動,否則回 404。
- 點擊對話框內的按鈕前,先用 `Browser::openModal()` 等淡入動畫結束,否則結果不穩定。
- 只有一、兩個字元的文字檔會被偵測為 `application/octet-stream`,測試檔請用正常長度的內容。
83 changes: 83 additions & 0 deletions tests/unit/config/ProductionRuntimeModeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php

namespace app\tests\unit\config;

use app\config\ConfigValidator;
use app\tests\_support\TestLoginController;

/**
* 正式環境執行模式檢查與測試登入路由隔離(S4/P10)
*/
class ProductionRuntimeModeTest extends \Codeception\Test\Unit
{
/** @var string|false */
private $savedAppEnv;

protected function _before()
{
$this->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()
{
// 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]);
}
}

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));
}
}
}