diff --git a/.env.example b/.env.example index 865ac96..e328ae0 100644 --- a/.env.example +++ b/.env.example @@ -173,6 +173,8 @@ CACHE_DURATION=3600 # # Content-Security-Policy:off | report-only(預設)| enforce # CSP_MODE=report-only +# CSP 政策:compat(預設,允許 inline 與 eval)| strict(不允許,修正完成前只可搭配 report-only 於測試環境使用) +# CSP_POLICY=compat # # HSTS(僅 APP_ENV=production 送出;不含 preload) # 有效期(秒),預設一年;設為 0 停用 diff --git a/CHANGELOG.md b/CHANGELOG.md index 354ee4a..39bf8e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ ### Added +* `CSP_POLICY=strict`(不允許 inline 與 eval,僅供測試環境搭配 report-only 使用);瀏覽器 E2E 收集 CSP 違規並輸出 `tests/_output/e2e/csp-violations.json`,作為改用強制 CSP 前的盤點。測試設定改為與正式設定相同送出安全標頭 * 管理員忘記密碼:登入頁自助重設(須先跑 `docs/migrations/20261021_password_reset.sql`;`users.email`、30 分鐘單次連結、既有 session 作廢;不關閉 TOTP) * 新裝精靈 `scripts/install.sh`(composer、可寫目錄、`.env`、可選建庫/`init_db.sql`/admin) * `install.sh` 環境檢查(PHP 硬擋;DB/Web 版本僅警示)、既有 DB/`master.key`/`.env`/admin 沿用 diff --git a/components/SecurityHeadersBootstrap.php b/components/SecurityHeadersBootstrap.php index c8f1c25..5e92d0e 100644 --- a/components/SecurityHeadersBootstrap.php +++ b/components/SecurityHeadersBootstrap.php @@ -77,25 +77,39 @@ private static function applyContentSecurityPolicy($headers): void return; } - // AdminLTE / Bootstrap 需 inline script/style;先以 report-only 或寬鬆 policy 過渡 - $policy = implode('; ', [ + $policy = static::contentSecurityPolicy(getenv('CSP_POLICY') ?: 'compat'); + + if ($mode === 'enforce') { + $headers->set('Content-Security-Policy', $policy); + return; + } + + $headers->set('Content-Security-Policy-Report-Only', $policy); + } + + /** + * CSP 政策內容 + * + * - compat(預設):AdminLTE / Bootstrap / Yii 需 inline script、style 與 eval,過渡用的寬鬆政策 + * - strict:不允許任何 unsafe-*,為最終目標。搭配 report-only 用於測試環境找出違規 + * (見 tests/e2e/README.md 的 CSP 違規收集);修正完成前不可搭配 enforce + */ + public static function contentSecurityPolicy(string $name): string + { + $strict = strtolower(trim($name)) === 'strict'; + + return implode('; ', [ "default-src 'self'", "base-uri 'self'", "form-action 'self'", "frame-ancestors 'self'", "object-src 'none'", - "script-src 'self' 'unsafe-inline' 'unsafe-eval'", - "style-src 'self' 'unsafe-inline'", + // report-sample:違規報告附上程式碼前 40 字,便於定位來源(不放寬任何限制) + $strict ? "script-src 'self' 'report-sample'" : "script-src 'self' 'unsafe-inline' 'unsafe-eval'", + $strict ? "style-src 'self' 'report-sample'" : "style-src 'self' 'unsafe-inline'", "img-src 'self' data: blob:", "font-src 'self' data:", "connect-src 'self'", ]); - - if ($mode === 'enforce') { - $headers->set('Content-Security-Policy', $policy); - return; - } - - $headers->set('Content-Security-Policy-Report-Only', $policy); } } diff --git a/config/test.php b/config/test.php index 66796e5..270efe5 100644 --- a/config/test.php +++ b/config/test.php @@ -50,6 +50,8 @@ 'bootstrap' => [ 'lang', 'log', + // 與正式設定相同的安全標頭,讓 acceptance/E2E 在相同的 CSP 等標頭下執行 + \app\components\SecurityHeadersBootstrap::class, function($app) { unset($GLOBALS['config']); unset($GLOBALS['__composer_autoload_files']); diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 1525df1..4610f4b 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -53,6 +53,18 @@ php tests/e2e/run.php --base=http://localhost:18080 測試會在測試資料庫與 FilePool 建立資料(附件、照片、候選設定與候選人、開票結果),結束前會刪除附件、照片與測試候選人並還原設定;`anon` 組會實際送出一張選票(該密碼標記為已投票),`NoAuthVoteTest` 的候選設定與開票結果會保留。重新載入 fixture 即可全部還原。 +## CSP 違規收集 + +每次執行都會監聽頁面的 `securitypolicyviolation` 事件,結束時列出摘要並寫入 `tests/_output/e2e/csp-violations.json`。違規只做紀錄,不影響通過與否。 + +預設的 `compat` 政策允許 inline script/style 與 eval,因此不會回報這些依賴。要找出改用嚴格政策前需要修正的地方,測試伺服器改以嚴格政策的 report-only 啟動(只回報、不阻擋,畫面行為不變): + +```bash +APP_ENV=testing CSP_MODE=report-only CSP_POLICY=strict php -S localhost:8080 -t web web/router-test.php +``` + +摘要依指令(如 `script-src-elem`、`script-src-attr`、`style-src-attr`)分組,列出頁面數與主要來源(`inline @ 檔名`、`eval` 或被擋的網址)。這個收集只涵蓋 E2E 走過的畫面。 + ## 新增畫面 在 `suites/pages.php` 的清單加一行即可套用全部共用檢查。只放 GET 時不會變更資料的畫面(刪除、重設、匯出、切換等動作不要放)。 diff --git a/tests/e2e/lib/Browser.php b/tests/e2e/lib/Browser.php index 186e63c..f4246af 100644 --- a/tests/e2e/lib/Browser.php +++ b/tests/e2e/lib/Browser.php @@ -13,6 +13,11 @@ class Browser public $results = []; /** @var string 目前的測試組別 */ public $group = ''; + /** @var array 收集到的 CSP 違規(以內容去重) */ + public $csp = []; + + /** 頁面腳本以此前綴經 console.debug 回報 CSP 違規 */ + const CSP_PREFIX = '__E2E_CSP__'; /** * @param string $endpoint Chrome 遠端除錯位址 @@ -29,6 +34,39 @@ public function __construct($endpoint, $base, $outDir, $isolated = false) $this->cdp->send('Page.enable'); $this->cdp->send('Runtime.enable'); $this->cdp->send('Emulation.setDeviceMetricsOverride', ['width' => 1400, 'height' => 900, 'deviceScaleFactor' => 1, 'mobile' => false]); + // 在頁面腳本執行前註冊 CSP 違規監聽(report-only 也會觸發) + $this->cdp->send('Page.addScriptToEvaluateOnNewDocument', ['source' => "document.addEventListener('securitypolicyviolation', function (e) {" + ."console.debug('".self::CSP_PREFIX."' + JSON.stringify({directive: e.effectiveDirective || e.violatedDirective," + ." blocked: e.blockedURI, source: e.sourceFile, line: e.lineNumber, page: location.pathname," + ." disposition: e.disposition, sample: e.sample}));});"]); + } + + /** 將已收到的 CSP 違規事件移入 $this->csp(清除事件緩衝前呼叫) */ + private function harvestCsp() + { + foreach ($this->cdp->events('Runtime.consoleAPICalled') as $e) { + $text = $e['args'][0]['value'] ?? ''; + if (!is_string($text) || strpos($text, self::CSP_PREFIX) !== 0) { + continue; + } + $v = json_decode(substr($text, strlen(self::CSP_PREFIX)), true); + if (!is_array($v)) { + continue; + } + $v['group'] = $this->group; + $key = implode('|', [$v['directive'] ?? '', $v['blocked'] ?? '', $v['source'] ?? '', $v['line'] ?? '', $v['page'] ?? '']); + if (!isset($this->csp[$key])) { + $this->csp[$key] = $v; + } + } + } + + /** 目前為止收集到的 CSP 違規 */ + public function cspViolations() + { + $this->js('0'); // 讀入尚在途中的事件 + $this->harvestCsp(); + return array_values($this->csp); } public function url($path) @@ -43,6 +81,7 @@ public function url($path) */ public function go($path, $referrer = null) { + $this->harvestCsp(); $this->cdp->clearEvents(); $params = ['url' => $this->url($path)]; if ($referrer) { @@ -110,6 +149,7 @@ public function click($selectorJs) public function mouse($x, $y) { + $this->harvestCsp(); $this->cdp->clearEvents(); foreach (['mousePressed', 'mouseReleased'] as $type) { $this->cdp->send('Input.dispatchMouseEvent', ['type' => $type, 'x' => $x, 'y' => $y, 'button' => 'left', 'clickCount' => 1]); diff --git a/tests/e2e/run.php b/tests/e2e/run.php index 5abd5c8..057acf8 100644 --- a/tests/e2e/run.php +++ b/tests/e2e/run.php @@ -95,6 +95,36 @@ echo " ✖ [{$r['group']}] {$r['name']}\n {$r['detail']}\n"; } file_put_contents($outDir.'/results.json', json_encode($results, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)); + + // CSP 違規彙整(僅紀錄,不影響結果);伺服器以 CSP_POLICY=strict 啟動時才能看出 inline/eval 的依賴 + $csp = array_merge(...array_map(function ($b) { + try { + return $b->cspViolations(); + } catch (Throwable $e) { + return []; + } + }, $browsers)); + file_put_contents($outDir.'/csp-violations.json', json_encode($csp, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)); + $byDirective = []; + foreach ($csp as $v) { + $d = $v['directive'] ?? '?'; + $byDirective[$d]['count'] = ($byDirective[$d]['count'] ?? 0) + 1; + $byDirective[$d]['pages'][$v['page'] ?? '?'] = true; + $src = ($v['blocked'] ?? '') === 'inline' || ($v['blocked'] ?? '') === 'eval' + ? $v['blocked'].' @ '.basename(parse_url($v['source'] ?? '', PHP_URL_PATH) ?: '(page)') + : ($v['blocked'] ?? ''); + $byDirective[$d]['sources'][$src] = ($byDirective[$d]['sources'][$src] ?? 0) + 1; + } + printf("\n== CSP 違規(僅紀錄):%d 筆不重複\n", count($csp)); + ksort($byDirective); + foreach ($byDirective as $d => $info) { + arsort($info['sources']); + printf(" %s:%d 筆,%d 個頁面\n", $d, $info['count'], count($info['pages'])); + foreach (array_slice($info['sources'], 0, 5, true) as $src => $n) { + echo " $n × $src\n"; + } + } + echo "明細:$outDir/csp-violations.json\n"; echo "截圖:$outDir/shots\n"; $exitCode = $failed ? 1 : 0; } finally { diff --git a/tests/unit/components/SecurityHeadersBootstrapTest.php b/tests/unit/components/SecurityHeadersBootstrapTest.php index 62b3b41..a14dc83 100644 --- a/tests/unit/components/SecurityHeadersBootstrapTest.php +++ b/tests/unit/components/SecurityHeadersBootstrapTest.php @@ -104,6 +104,38 @@ public function testHstsAppliesToRawResponses() $this->assertSame('max-age=31536000', $this->hstsFor(['APP_ENV' => 'production'], Response::FORMAT_RAW)); } + public function testCompatPolicyIsDefaultAndStrictDropsUnsafeSources() + { + $compat = SecurityHeadersBootstrap::contentSecurityPolicy('compat'); + $this->assertStringContainsString("script-src 'self' 'unsafe-inline' 'unsafe-eval'", $compat); + $this->assertSame($compat, SecurityHeadersBootstrap::contentSecurityPolicy('anything-else')); + + $strict = SecurityHeadersBootstrap::contentSecurityPolicy('strict'); + $this->assertStringNotContainsString('unsafe-', $strict); + $this->assertStringContainsString("script-src 'self' 'report-sample'", $strict); + $this->assertStringContainsString("object-src 'none'", $strict); + } + + public function testStrictPolicyIsSentInReportOnlyMode() + { + $saved = [getenv('CSP_MODE'), getenv('CSP_POLICY')]; + putenv('CSP_MODE=report-only'); + putenv('CSP_POLICY=strict'); + try { + (new SecurityHeadersBootstrap())->bootstrap(\Yii::$app); + $response = new Response(['format' => Response::FORMAT_HTML]); + $response->trigger(Response::EVENT_BEFORE_SEND); + $this->assertSame( + SecurityHeadersBootstrap::contentSecurityPolicy('strict'), + $response->headers->get('Content-Security-Policy-Report-Only') + ); + $this->assertNull($response->headers->get('Content-Security-Policy')); + } finally { + putenv($saved[0] === false ? 'CSP_MODE' : 'CSP_MODE=' . $saved[0]); + putenv($saved[1] === false ? 'CSP_POLICY' : 'CSP_POLICY=' . $saved[1]); + } + } + public function testAttributeDiffHelperNoLongerSendsHeaders() { $source = file_get_contents(dirname(__DIR__, 3) . '/components/helper/ArrayHelper.php');