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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
#
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;列印分段表頭也套用淨化,保留既有換行與合法排版
Expand Down
33 changes: 32 additions & 1 deletion components/SecurityHeadersBootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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'));
Expand Down
4 changes: 1 addition & 3 deletions components/helper/ArrayHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 [];
}
Expand Down
59 changes: 59 additions & 0 deletions tests/unit/components/SecurityHeadersBootstrapTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down