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
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 停用
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 沿用
Expand Down
36 changes: 25 additions & 11 deletions components/SecurityHeadersBootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
2 changes: 2 additions & 0 deletions config/test.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@

'bootstrap' => [
'lang', 'log',
// 與正式設定相同的安全標頭,讓 acceptance/E2E 在相同的 CSP 等標頭下執行
\app\components\SecurityHeadersBootstrap::class,
function($app) {
unset($GLOBALS['config']);
unset($GLOBALS['__composer_autoload_files']);
Expand Down
12 changes: 12 additions & 0 deletions tests/e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 時不會變更資料的畫面(刪除、重設、匯出、切換等動作不要放)。
Expand Down
40 changes: 40 additions & 0 deletions tests/e2e/lib/Browser.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ class Browser
public $results = [];
/** @var string 目前的測試組別 */
public $group = '';
/** @var array<string, array> 收集到的 CSP 違規(以內容去重) */
public $csp = [];

/** 頁面腳本以此前綴經 console.debug 回報 CSP 違規 */
const CSP_PREFIX = '__E2E_CSP__';

/**
* @param string $endpoint Chrome 遠端除錯位址
Expand All @@ -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)
Expand All @@ -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) {
Expand Down Expand Up @@ -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]);
Expand Down
30 changes: 30 additions & 0 deletions tests/e2e/run.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
32 changes: 32 additions & 0 deletions tests/unit/components/SecurityHeadersBootstrapTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down