diff --git a/.env.example b/.env.example index e328ae0..e4e1f81 100644 --- a/.env.example +++ b/.env.example @@ -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) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39bf8e7..93bc612 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` diff --git a/SECURITY.md b/SECURITY.md index 2cc6ae8..79dbd7b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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` 提交至版本控制 diff --git a/SETUP.md b/SETUP.md index e33b65c..9a170d2 100644 --- a/SETUP.md +++ b/SETUP.md @@ -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/`) diff --git a/components/SecurityHeadersBootstrap.php b/components/SecurityHeadersBootstrap.php index 5e92d0e..02fdef5 100644 --- a/components/SecurityHeadersBootstrap.php +++ b/components/SecurityHeadersBootstrap.php @@ -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; } @@ -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 */ @@ -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:", diff --git a/docs/threat-model/03-stride.md b/docs/threat-model/03-stride.md index 7724a9e..2c04848 100644 --- a/docs/threat-model/03-stride.md +++ b/docs/threat-model/03-stride.md @@ -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(若發生) | diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 4610f4b..c6d679c 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -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 diff --git a/tests/unit/components/SecurityHeadersBootstrapTest.php b/tests/unit/components/SecurityHeadersBootstrapTest.php index a14dc83..8817fa2 100644 --- a/tests/unit/components/SecurityHeadersBootstrapTest.php +++ b/tests/unit/components/SecurityHeadersBootstrapTest.php @@ -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')); } /** @@ -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');