From dad75b949bd92994706c0b5780f5cfba23016422 Mon Sep 17 00:00:00 2001 From: TUNG-YEN Date: Fri, 6 Nov 2026 14:08:37 +0800 Subject: [PATCH] Enforce the compatibility CSP by default and stop allowing eval The CSP was report-only, so it blocked nothing and scanners treated the site as having no policy. The E2E inventory found no eval use, and none of the loaded frontend assets call eval or new Function, so drop 'unsafe-eval' from the compat policy and enforce it by default. CSP_MODE=report-only remains as an immediate rollback. Co-Authored-By: Claude Opus 5.5 --- .env.example | 7 +-- CHANGELOG.md | 1 + SECURITY.md | 2 +- SETUP.md | 2 +- components/SecurityHeadersBootstrap.php | 7 +-- docs/threat-model/03-stride.md | 2 +- tests/e2e/README.md | 2 +- .../SecurityHeadersBootstrapTest.php | 53 ++++++++++++------- 8 files changed, 47 insertions(+), 29 deletions(-) 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');