diff --git a/.env.example b/.env.example index a74c664..865ac96 100644 --- a/.env.example +++ b/.env.example @@ -174,6 +174,12 @@ CACHE_DURATION=3600 # Content-Security-Policy:off | report-only(預設)| enforce # CSP_MODE=report-only # +# HSTS(僅 APP_ENV=production 送出;不含 preload) +# 有效期(秒),預設一年;設為 0 停用 +# HSTS_MAX_AGE=31536000 +# 確認同網域的所有子網域都只走 HTTPS 後才設為 true +# HSTS_INCLUDE_SUBDOMAINS=false +# # 反向代理 CIDR(逗號分隔,擴充 request.trustedHosts) # TRUSTED_PROXY_CIDRS=10.0.0.0/8,172.16.0.0/12 # diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a0ff20..354ee4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ ### Security +* 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` * 投票頁自訂欄位標題、候選名單內容與紙本選票的中英文資料統一淨化原本直接輸出的 HTML;列印分段表頭也套用淨化,保留既有換行與合法排版 diff --git a/components/SecurityHeadersBootstrap.php b/components/SecurityHeadersBootstrap.php index bc38faa..c8f1c25 100644 --- a/components/SecurityHeadersBootstrap.php +++ b/components/SecurityHeadersBootstrap.php @@ -24,11 +24,15 @@ public function bootstrap($app): void Event::on(Response::class, Response::EVENT_BEFORE_SEND, static function ($event): void { /** @var Response $response */ $response = $event->sender; + $headers = $response->headers; + + // HSTS 適用所有回應(含檔案下載) + static::applyStrictTransportSecurity($headers); + if ($response->format === Response::FORMAT_RAW) { return; } - $headers = $response->headers; $headers->set('X-Content-Type-Options', 'nosniff'); $headers->set('X-Frame-Options', 'SAMEORIGIN'); $headers->set('Referrer-Policy', 'strict-origin-when-cross-origin'); @@ -39,6 +43,33 @@ public function bootstrap($app): void }); } + /** + * 正式環境送出 HSTS。 + * + * 瀏覽器會忽略經 HTTP 收到的 HSTS,因此不需判斷是否為 HTTPS 連線(反向代理終結 TLS 時亦適用)。 + * 不加 preload(難以撤回);includeSubDomains 需確認同網域所有子網域皆為 HTTPS 後再以 + * HSTS_INCLUDE_SUBDOMAINS=true 開啟。HSTS_MAX_AGE=0 可停用。 + */ + private static function applyStrictTransportSecurity($headers): void + { + if (!\app\config\ConfigValidator::isProductionEnvironment()) { + return; + } + + $maxAge = getenv('HSTS_MAX_AGE'); + $maxAge = ($maxAge === false || trim($maxAge) === '') ? 31536000 : (int) $maxAge; + if ($maxAge <= 0) { + return; + } + + $value = 'max-age=' . $maxAge; + if (filter_var(getenv('HSTS_INCLUDE_SUBDOMAINS') ?: 'false', FILTER_VALIDATE_BOOLEAN)) { + $value .= '; includeSubDomains'; + } + + $headers->set('Strict-Transport-Security', $value); + } + private static function applyContentSecurityPolicy($headers): void { $mode = strtolower(trim(getenv('CSP_MODE') ?: 'report-only')); diff --git a/components/helper/ArrayHelper.php b/components/helper/ArrayHelper.php index 2ffcd80..e13868f 100644 --- a/components/helper/ArrayHelper.php +++ b/components/helper/ArrayHelper.php @@ -59,9 +59,7 @@ public static function getAttributesMigration($attributes, $oldAttributes) { // 先確認是否有差異 $diff = array_diff_assoc($attributes, $oldAttributes); - header('Content-Type: text/html; charset=utf-8'); - // Missing HSTS Header - header('Strict-Transport-Security: max-age=31536000; includeSubDomains; preload'); + // 安全標頭(含 HSTS)統一由 SecurityHeadersBootstrap 送出 if (empty($diff)) { return []; } diff --git a/tests/unit/components/SecurityHeadersBootstrapTest.php b/tests/unit/components/SecurityHeadersBootstrapTest.php index 7d1a510..62b3b41 100644 --- a/tests/unit/components/SecurityHeadersBootstrapTest.php +++ b/tests/unit/components/SecurityHeadersBootstrapTest.php @@ -51,6 +51,65 @@ public function testCspOffSkipsHeader() putenv('CSP_MODE'); } + /** + * 以全新 Response 觸發,避免沿用其他測試留下的標頭 + */ + private function hstsFor(array $env, string $format = Response::FORMAT_HTML): ?string + { + $names = ['APP_ENV', 'HSTS_MAX_AGE', 'HSTS_INCLUDE_SUBDOMAINS']; + $saved = []; + foreach ($names as $name) { + $saved[$name] = getenv($name); + putenv(array_key_exists($name, $env) ? "{$name}={$env[$name]}" : $name); + } + try { + (new SecurityHeadersBootstrap())->bootstrap(\Yii::$app); + $response = new Response(['format' => $format]); + $response->trigger(Response::EVENT_BEFORE_SEND); + return $response->headers->get('Strict-Transport-Security'); + } finally { + foreach ($saved as $name => $value) { + putenv($value === false ? $name : "{$name}={$value}"); + } + } + } + + public function testProductionSendsHstsWithoutPreloadOrSubdomainsByDefault() + { + $this->assertSame('max-age=31536000', $this->hstsFor(['APP_ENV' => 'production'])); + } + + public function testHstsIncludeSubdomainsIsOptIn() + { + $value = $this->hstsFor(['APP_ENV' => 'production', 'HSTS_INCLUDE_SUBDOMAINS' => 'true']); + $this->assertSame('max-age=31536000; includeSubDomains', $value); + $this->assertStringNotContainsString('preload', $value); + } + + public function testHstsMaxAgeConfigurableAndZeroDisables() + { + $this->assertSame('max-age=600', $this->hstsFor(['APP_ENV' => 'production', 'HSTS_MAX_AGE' => '600'])); + $this->assertNull($this->hstsFor(['APP_ENV' => 'production', 'HSTS_MAX_AGE' => '0'])); + } + + public function testNonProductionSendsNoHsts() + { + foreach (['development', 'testing'] as $appEnv) { + $this->assertNull($this->hstsFor(['APP_ENV' => $appEnv]), $appEnv); + } + } + + public function testHstsAppliesToRawResponses() + { + $this->assertSame('max-age=31536000', $this->hstsFor(['APP_ENV' => 'production'], Response::FORMAT_RAW)); + } + + public function testAttributeDiffHelperNoLongerSendsHeaders() + { + $source = file_get_contents(dirname(__DIR__, 3) . '/components/helper/ArrayHelper.php'); + $this->assertStringNotContainsString('Strict-Transport-Security', $source); + } + public function testRawFormatSkipsSecurityHeaders() { (new SecurityHeadersBootstrap())->bootstrap(\Yii::$app);