From d1cc6e1fa53b5a836eb1f01b54e62728cd19d50a Mon Sep 17 00:00:00 2001 From: TUNG-YEN Date: Fri, 6 Nov 2026 13:02:43 +0800 Subject: [PATCH] Send HSTS from the security headers component without preload HSTS was emitted by a data-diff helper, so only some save actions sent it, and it carried preload, which is hard to undo. Send it on every production response instead, drop preload, and make includeSubDomains opt-in until every subdomain is confirmed to be HTTPS. Co-Authored-By: Claude Opus 5.5 --- .env.example | 6 ++ CHANGELOG.md | 1 + components/SecurityHeadersBootstrap.php | 33 ++++++++++- components/helper/ArrayHelper.php | 4 +- .../SecurityHeadersBootstrapTest.php | 59 +++++++++++++++++++ 5 files changed, 99 insertions(+), 4 deletions(-) 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);