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
7 changes: 4 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -171,9 +171,10 @@ CACHE_DURATION=3600
# 高敏感操作二次驗證(密碼匯出等;預設 true)
# SENSITIVE_REAUTH=true
#
# Content-Security-Policy:off | report-only(預設)| enforce
# CSP_MODE=report-only
# CSP 政策:compat(預設,允許 inline 與 eval)| strict(不允許,修正完成前只可搭配 report-only 於測試環境使用)
# Content-Security-Policy:off | report-only | enforce(預設)
# 正式環境若發現頁面功能被 CSP 擋住,可暫設 report-only 立即還原(不需重新部署)
# CSP_MODE=enforce
# CSP 政策:compat(預設,允許 inline、不允許 eval)| strict(不允許,修正完成前只可搭配 report-only 於測試環境使用)
# CSP_POLICY=compat
#
# HSTS(僅 APP_ENV=production 送出;不含 preload)
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@

### Security

* Content-Security-Policy 預設改為強制套用(`CSP_MODE=enforce`),`compat` 政策移除 `'unsafe-eval'`(E2E 盤點與實際載入的前端套件均未使用 eval),仍允許 inline script/style。**升級注意**:若正式環境有畫面功能被擋,可暫於 `.env` 設 `CSP_MODE=report-only` 還原,不需重新部署
* HSTS 改由安全標頭元件於正式環境的每個回應送出,預設 `max-age=31536000`,不再帶難以撤回的 `preload`;`includeSubDomains` 需確認所有子網域皆為 HTTPS 後以 `HSTS_INCLUDE_SUBDOMAINS=true` 開啟。原本僅在部分存檔動作才送出
* 測試專用登入路由移出正式程式目錄(`tests/_support/TestLoginController.php`),只由測試設定掛載,且須 Yii 測試模式加上 `APP_ENV=testing`/`test` 才可使用,其他情況回 404
* **升級注意**:正式環境(`APP_ENV=production`)必須為 `YII_ENV=prod`(或未設定)且 `YII_DEBUG=false`,否則系統拒絕啟動並列出原因;升級前請先檢查正式機 `.env`
Expand Down
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@

- `SA_DB_TOOLS_ENABLED` 預設為 **false**(SA 資料庫工具關閉)
- `SENSITIVE_REAUTH` 預設為 **true**(高敏感操作需管理員密碼確認)
- `CSP_MODE` 預設為 **report-only**(Content-Security-Policy 觀察模式)
- `CSP_MODE` 預設為 **enforce**(強制套用 Content-Security-Policy;`compat` 政策允許 inline、不允許 eval)
- `YII_DEBUG` 預設為 **false**
- 勿將 `.env` 或 `master.key` 提交至版本控制

Expand Down
2 changes: 1 addition & 1 deletion SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -667,7 +667,7 @@ SESSION_TIMEOUT=7200
- [ ] `DB_PASSWORD` 已加密(`php yii encrypt/db-password`);`php yii config/validate` 全綠
- [ ] `SA_DB_TOOLS_ENABLED` 未設或為 `false`
- [ ] `SENSITIVE_REAUTH=true`(密碼匯出、RBAC 同步等敏感操作)
- [ ] `CSP_MODE=report-only` 或依政策設 `enforce`
- [ ] `CSP_MODE` 未設或為 `enforce`(預設);僅在頁面功能異常時暫設 `report-only`
- [ ] `TRUSTED_PROXY_CIDRS` 已設(若反向代理不在 127.0.0.1)
- [ ] 遷移完成後 `DISABLE_LEGACY_ADMIN_PASSWORD=true`(先跑 `php yii migrate-passwords/admin-legacy-report`)
- [ ] `composer.lock` 已納入版控;部署使用 `composer install --no-dev`(post-install 會套用 `patches/`)
Expand Down
7 changes: 4 additions & 3 deletions components/SecurityHeadersBootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ private static function applyStrictTransportSecurity($headers): void

private static function applyContentSecurityPolicy($headers): void
{
$mode = strtolower(trim(getenv('CSP_MODE') ?: 'report-only'));
$mode = strtolower(trim(getenv('CSP_MODE') ?: 'enforce'));
if ($mode === 'off' || $mode === 'false' || $mode === '0') {
return;
}
Expand All @@ -90,7 +90,8 @@ private static function applyContentSecurityPolicy($headers): void
/**
* CSP 政策內容
*
* - compat(預設):AdminLTE / Bootstrap / Yii 需 inline script、style 與 eval,過渡用的寬鬆政策
* - compat(預設):AdminLTE / Bootstrap / Yii 需 inline script 與 style,過渡用政策;不允許 eval
* (E2E 盤點與實際載入的前端套件均未使用 eval)
* - strict:不允許任何 unsafe-*,為最終目標。搭配 report-only 用於測試環境找出違規
* (見 tests/e2e/README.md 的 CSP 違規收集);修正完成前不可搭配 enforce
*/
Expand All @@ -105,7 +106,7 @@ public static function contentSecurityPolicy(string $name): string
"frame-ancestors 'self'",
"object-src 'none'",
// report-sample:違規報告附上程式碼前 40 字,便於定位來源(不放寬任何限制)
$strict ? "script-src 'self' 'report-sample'" : "script-src 'self' 'unsafe-inline' 'unsafe-eval'",
$strict ? "script-src 'self' 'report-sample'" : "script-src 'self' 'unsafe-inline'",
$strict ? "style-src 'self' 'report-sample'" : "style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob:",
"font-src 'self' data:",
Expand Down
2 changes: 1 addition & 1 deletion docs/threat-model/03-stride.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
| I3 | 明文 CSV/密碼函外流 | Sensitive Reauth;TTL 解鎖 | TOTP 可選;檔案落地後是程序 | High |
| I4 | 發放名冊寫進系統/`mark` 填姓名 | 實務:名冊場外、系統只用 `sn` | 兩職合一或名冊匯入才破 A2 | Medium(程序) |
| I5 | IP+時間指紋 | 有權才看選票列表 | 小樣本+場外「誰何時到場」可推論(04) | Medium |
| I6 | XSS 讀管理員 session | CSRF;CSP **report-only** 且 `unsafe-inline/eval` | XSS 幾乎不算被 CSP 擋住(C2) | High |
| I6 | XSS 讀管理員 session | CSRF;CSP 預設 enforce(`compat`),仍允許 `unsafe-inline`,已移除 `unsafe-eval` | XSS 幾乎不算被 CSP 擋住(C2) | High |
| I7 | filePool 直連/路徑 | 目錄在 docroot 外;下載/看照片入口另有 basename+目錄限制 | helper 本身可 `../` 出池;HTTP 入口測不到(C3,2026-09-18) | Low |
| I8 | 除錯頁/`.env` | 正式 `YII_DEBUG=false`;gitignore;open-source-prep | 部署錯誤 | High(若發生) |

Expand Down
2 changes: 1 addition & 1 deletion tests/e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ php tests/e2e/run.php --base=http://localhost:18080

每次執行都會監聽頁面的 `securitypolicyviolation` 事件,結束時列出摘要並寫入 `tests/_output/e2e/csp-violations.json`。違規只做紀錄,不影響通過與否。

預設的 `compat` 政策允許 inline script/style 與 eval,因此不會回報這些依賴。要找出改用嚴格政策前需要修正的地方,測試伺服器改以嚴格政策的 report-only 啟動(只回報、不阻擋,畫面行為不變):
預設的 `compat` 政策(預設強制套用)允許 inline script/style、不允許 eval,因此只會回報 eval 等違規。要找出改用嚴格政策前需要修正的地方,測試伺服器改以嚴格政策的 report-only 啟動(只回報、不阻擋,畫面行為不變):

```bash
APP_ENV=testing CSP_MODE=report-only CSP_POLICY=strict php -S localhost:8080 -t web web/router-test.php
Expand Down
53 changes: 34 additions & 19 deletions tests/unit/components/SecurityHeadersBootstrapTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,31 +24,45 @@ public function testBeforeSendAddsSecurityHeaders()
$this->assertSame('0', $response->headers->get('X-XSS-Protection'));
}

public function testReportOnlyCspByDefault()
/**
* @return array{0: ?string, 1: ?string} [Content-Security-Policy, Content-Security-Policy-Report-Only]
*/
private function cspHeadersFor(?string $mode, ?string $policy = null): array
{
putenv('CSP_MODE');
(new SecurityHeadersBootstrap())->bootstrap(\Yii::$app);
$saved = [getenv('CSP_MODE'), getenv('CSP_POLICY')];
putenv($mode === null ? 'CSP_MODE' : "CSP_MODE={$mode}");
putenv($policy === null ? 'CSP_POLICY' : "CSP_POLICY={$policy}");
try {
(new SecurityHeadersBootstrap())->bootstrap(\Yii::$app);
$response = new Response(['format' => Response::FORMAT_HTML]);
$response->trigger(Response::EVENT_BEFORE_SEND);
return [
$response->headers->get('Content-Security-Policy'),
$response->headers->get('Content-Security-Policy-Report-Only'),
];
} finally {
putenv($saved[0] === false ? 'CSP_MODE' : 'CSP_MODE=' . $saved[0]);
putenv($saved[1] === false ? 'CSP_POLICY' : 'CSP_POLICY=' . $saved[1]);
}
}

$response = \Yii::$app->response;
$response->format = Response::FORMAT_HTML;
$response->trigger(Response::EVENT_BEFORE_SEND);
public function testCompatCspIsEnforcedByDefault()
{
[$enforced, $reportOnly] = $this->cspHeadersFor(null);
$this->assertSame(SecurityHeadersBootstrap::contentSecurityPolicy('compat'), $enforced);
$this->assertNull($reportOnly);
}

$this->assertNotNull($response->headers->get('Content-Security-Policy-Report-Only'));
$this->assertNull($response->headers->get('Content-Security-Policy'));
public function testReportOnlyModeRemainsAvailableForRollback()
{
[$enforced, $reportOnly] = $this->cspHeadersFor('report-only');
$this->assertNull($enforced);
$this->assertSame(SecurityHeadersBootstrap::contentSecurityPolicy('compat'), $reportOnly);
}

public function testCspOffSkipsHeader()
{
putenv('CSP_MODE=off');
(new SecurityHeadersBootstrap())->bootstrap(\Yii::$app);

$response = \Yii::$app->response;
$response->format = Response::FORMAT_HTML;
$response->trigger(Response::EVENT_BEFORE_SEND);

$this->assertNull($response->headers->get('Content-Security-Policy-Report-Only'));
$this->assertNull($response->headers->get('Content-Security-Policy'));
putenv('CSP_MODE');
$this->assertSame([null, null], $this->cspHeadersFor('off'));
}

/**
Expand Down Expand Up @@ -107,7 +121,8 @@ public function testHstsAppliesToRawResponses()
public function testCompatPolicyIsDefaultAndStrictDropsUnsafeSources()
{
$compat = SecurityHeadersBootstrap::contentSecurityPolicy('compat');
$this->assertStringContainsString("script-src 'self' 'unsafe-inline' 'unsafe-eval'", $compat);
$this->assertStringContainsString("script-src 'self' 'unsafe-inline';", $compat);
$this->assertStringNotContainsString('unsafe-eval', $compat);
$this->assertSame($compat, SecurityHeadersBootstrap::contentSecurityPolicy('anything-else'));

$strict = SecurityHeadersBootstrap::contentSecurityPolicy('strict');
Expand Down