Procházet zdrojové kódy

Show warning when unsafe CSP policy is in use (#7804)

* Show warning when unsafe CSP policy is in use

* Fix bare markdown URL

* i18n: fr

* Minor i18n: fr

* Add target="_blank" to i18n strings

---------

Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
Inverle před 8 měsíci
rodič
revize
2b85a50ed7

+ 1 - 0
app/FreshRSS.php

@@ -34,6 +34,7 @@ class FreshRSS extends Minz_FrontController {
 			// Relax Content Security Policy to allow external images if a custom logo HTML is used
 			Minz_ActionController::_defaultCsp([
 				'default-src' => "'self'",
+				'frame-ancestors' => "'none'",
 				'img-src' => '* data:',
 			]);
 		}

+ 1 - 0
app/Models/SystemConfiguration.php

@@ -27,6 +27,7 @@ declare(strict_types=1);
  * @property-read string $salt
  * @property-read bool $simplepie_syslog_enabled
  * @property bool $unsafe_autologin_enabled
+ * @property-read bool $suppress_csp_warning
  * @property array<string> $trusted_sources
  * @property array<string,array<string,mixed>> $extensions
  */

+ 1 - 0
app/i18n/cs/gen.php

@@ -155,6 +155,7 @@ return array(
 		'labels_empty' => 'Žádné štítky',
 		'new_article' => 'Jsou dostupné nové články, klikněte pro obnovení stránky.',
 		'should_be_activated' => 'JavaScript musí být povolen',
+		'unsafe_csp_header' => 'The CSP header in use is unsafe and FreshRSS may be vulnerable to XSS attacks. <a target="_blank" href="https://freshrss.github.io/FreshRSS/en/admins/10_ServerConfig.html#security">See documentation</a>',	// TODO
 	),
 	'lang' => array(
 		'cs' => 'Čeština',	// IGNORE

+ 1 - 0
app/i18n/de/gen.php

@@ -155,6 +155,7 @@ return array(
 		'labels_empty' => 'Keine Labels',
 		'new_article' => 'Es gibt neue verfügbare Artikel. Klicken Sie, um die Seite zu aktualisieren.',
 		'should_be_activated' => 'JavaScript muss aktiviert sein',
+		'unsafe_csp_header' => 'The CSP header in use is unsafe and FreshRSS may be vulnerable to XSS attacks. <a target="_blank" href="https://freshrss.github.io/FreshRSS/en/admins/10_ServerConfig.html#security">See documentation</a>',	// TODO
 	),
 	'lang' => array(
 		'cs' => 'Čeština',	// IGNORE

+ 1 - 0
app/i18n/el/gen.php

@@ -155,6 +155,7 @@ return array(
 		'labels_empty' => 'No labels',	// TODO
 		'new_article' => 'There are new articles available, click to refresh the page.',	// TODO
 		'should_be_activated' => 'JavaScript must be enabled',	// TODO
+		'unsafe_csp_header' => 'The CSP header in use is unsafe and FreshRSS may be vulnerable to XSS attacks. <a target="_blank" href="https://freshrss.github.io/FreshRSS/en/admins/10_ServerConfig.html#security">See documentation</a>',	// TODO
 	),
 	'lang' => array(
 		'cs' => 'Čeština',	// IGNORE

+ 1 - 0
app/i18n/en-us/gen.php

@@ -155,6 +155,7 @@ return array(
 		'labels_empty' => 'No labels',	// IGNORE
 		'new_article' => 'There are new articles available, click to refresh the page.',	// IGNORE
 		'should_be_activated' => 'JavaScript must be enabled',	// IGNORE
+		'unsafe_csp_header' => 'The CSP header in use is unsafe and FreshRSS may be vulnerable to XSS attacks. <a target="_blank" href="https://freshrss.github.io/FreshRSS/en/admins/10_ServerConfig.html#security">See documentation</a>',	// IGNORE
 	),
 	'lang' => array(
 		'cs' => 'Čeština',	// IGNORE

+ 1 - 0
app/i18n/en/gen.php

@@ -155,6 +155,7 @@ return array(
 		'labels_empty' => 'No labels',
 		'new_article' => 'There are new articles available, click to refresh the page.',
 		'should_be_activated' => 'JavaScript must be enabled',
+		'unsafe_csp_header' => 'The CSP header in use is unsafe and FreshRSS may be vulnerable to XSS attacks. <a target="_blank" href="https://freshrss.github.io/FreshRSS/en/admins/10_ServerConfig.html#security">See documentation</a>',
 	),
 	'lang' => array(
 		'cs' => 'Čeština',

+ 1 - 0
app/i18n/es/gen.php

@@ -155,6 +155,7 @@ return array(
 		'labels_empty' => 'Sin etiquetas',
 		'new_article' => 'Hay nuevos artículos disponibles. Pincha para refrescar la página.',
 		'should_be_activated' => 'JavaScript debe estar activado',
+		'unsafe_csp_header' => 'The CSP header in use is unsafe and FreshRSS may be vulnerable to XSS attacks. <a target="_blank" href="https://freshrss.github.io/FreshRSS/en/admins/10_ServerConfig.html#security">See documentation</a>',	// TODO
 	),
 	'lang' => array(
 		'cs' => 'Čeština',	// IGNORE

+ 1 - 0
app/i18n/fa/gen.php

@@ -155,6 +155,7 @@ return array(
 		'labels_empty' => 'بدون برچسب',
 		'new_article' => 'مقالات جدیدی موجود است',
 		'should_be_activated' => ' جاوا اسکریپت باید فعال باشد',
+		'unsafe_csp_header' => 'The CSP header in use is unsafe and FreshRSS may be vulnerable to XSS attacks. <a target="_blank" href="https://freshrss.github.io/FreshRSS/en/admins/10_ServerConfig.html#security">See documentation</a>',	// TODO
 	),
 	'lang' => array(
 		'cs' => 'Čeština',	// IGNORE

+ 1 - 0
app/i18n/fi/gen.php

@@ -155,6 +155,7 @@ return array(
 		'labels_empty' => 'Ei tunnisteita',
 		'new_article' => 'Uusia artikkeleita on saatavilla. Päivitä sivu napsauttamalla.',
 		'should_be_activated' => 'JavaScriptin on oltava käytössä',
+		'unsafe_csp_header' => 'The CSP header in use is unsafe and FreshRSS may be vulnerable to XSS attacks. <a target="_blank" href="https://freshrss.github.io/FreshRSS/en/admins/10_ServerConfig.html#security">See documentation</a>',	// TODO
 	),
 	'lang' => array(
 		'cs' => 'Čeština',	// IGNORE

+ 2 - 1
app/i18n/fr/gen.php

@@ -152,9 +152,10 @@ return array(
 			'request_failed' => 'Une requête a échoué, cela peut être dû à des problèmes de connexion à Internet.',
 			'title_new_articles' => 'FreshRSS : nouveaux articles !',
 		),
-		'labels_empty' => 'Pas d’étiquettes',	// DIRTY
+		'labels_empty' => 'Pas d’étiquettes',
 		'new_article' => 'Il y a de nouveaux articles disponibles, cliquez pour rafraîchir la page.',
 		'should_be_activated' => 'Le JavaScript doit être activé.',
+		'unsafe_csp_header' => 'L’en-tête CSP utilisé n’est pas sécurisé et FreshRSS peut être vulnérable aux attaques XSS. <a target="_blank" href="https://freshrss.github.io/FreshRSS/en/admins/10_ServerConfig.html#security">Voir la documentation</a>',
 	),
 	'lang' => array(
 		'cs' => 'Čeština',	// IGNORE

+ 1 - 0
app/i18n/he/gen.php

@@ -155,6 +155,7 @@ return array(
 		'labels_empty' => 'No labels',	// TODO
 		'new_article' => 'מאמרים חדשים זמינים, לחצו לרענון העמוד.',
 		'should_be_activated' => 'חובה להפעיל JavaScript',
+		'unsafe_csp_header' => 'The CSP header in use is unsafe and FreshRSS may be vulnerable to XSS attacks. <a target="_blank" href="https://freshrss.github.io/FreshRSS/en/admins/10_ServerConfig.html#security">See documentation</a>',	// TODO
 	),
 	'lang' => array(
 		'cs' => 'Čeština',	// IGNORE

+ 1 - 0
app/i18n/hu/gen.php

@@ -155,6 +155,7 @@ return array(
 		'labels_empty' => 'Nincsenek címkék',
 		'new_article' => 'Új cikkek elérhetőek, kattints a lap frissítéséhez.',
 		'should_be_activated' => 'A JavaScript futtatásának engedélyezve kell lennie',
+		'unsafe_csp_header' => 'The CSP header in use is unsafe and FreshRSS may be vulnerable to XSS attacks. <a target="_blank" href="https://freshrss.github.io/FreshRSS/en/admins/10_ServerConfig.html#security">See documentation</a>',	// TODO
 	),
 	'lang' => array(
 		'cs' => 'Čeština',	// IGNORE

+ 1 - 0
app/i18n/id/gen.php

@@ -155,6 +155,7 @@ return array(
 		'labels_empty' => 'Tidak ada label',
 		'new_article' => 'Tidak ada artikel baru yang tersedia, klik untuk menyegarkan halaman.',
 		'should_be_activated' => 'JavaScript harus diaktifkan',
+		'unsafe_csp_header' => 'The CSP header in use is unsafe and FreshRSS may be vulnerable to XSS attacks. <a target="_blank" href="https://freshrss.github.io/FreshRSS/en/admins/10_ServerConfig.html#security">See documentation</a>',	// TODO
 	),
 	'lang' => array(
 		'cs' => 'Čeština',	// IGNORE

+ 1 - 0
app/i18n/it/gen.php

@@ -155,6 +155,7 @@ return array(
 		'labels_empty' => 'Nessun tag',
 		'new_article' => 'Sono disponibili nuovi articoli, clicca qui per caricarli.',
 		'should_be_activated' => 'JavaScript deve essere abilitato',
+		'unsafe_csp_header' => 'The CSP header in use is unsafe and FreshRSS may be vulnerable to XSS attacks. <a target="_blank" href="https://freshrss.github.io/FreshRSS/en/admins/10_ServerConfig.html#security">See documentation</a>',	// TODO
 	),
 	'lang' => array(
 		'cs' => 'Čeština',	// IGNORE

+ 1 - 0
app/i18n/ja/gen.php

@@ -155,6 +155,7 @@ return array(
 		'labels_empty' => 'ラベルがありません',
 		'new_article' => '新しい記事があるのでクリックしてページをリフレッシュしてください。',
 		'should_be_activated' => 'JavaScriptは有効になっている必要があります。',
+		'unsafe_csp_header' => 'The CSP header in use is unsafe and FreshRSS may be vulnerable to XSS attacks. <a target="_blank" href="https://freshrss.github.io/FreshRSS/en/admins/10_ServerConfig.html#security">See documentation</a>',	// TODO
 	),
 	'lang' => array(
 		'cs' => 'Čeština',	// IGNORE

+ 1 - 0
app/i18n/ko/gen.php

@@ -155,6 +155,7 @@ return array(
 		'labels_empty' => '라벨 없음',
 		'new_article' => '새 글이 있습니다. 여기를 클릭하면 페이지를 다시 불러옵니다.',
 		'should_be_activated' => '자바스크립트를 사용하도록 설정해야합니다',
+		'unsafe_csp_header' => 'The CSP header in use is unsafe and FreshRSS may be vulnerable to XSS attacks. <a target="_blank" href="https://freshrss.github.io/FreshRSS/en/admins/10_ServerConfig.html#security">See documentation</a>',	// TODO
 	),
 	'lang' => array(
 		'cs' => 'Čeština',	// IGNORE

+ 1 - 0
app/i18n/lv/gen.php

@@ -155,6 +155,7 @@ return array(
 		'labels_empty' => 'No labels',	// TODO
 		'new_article' => 'Ir pieejami jauni raksti, noklikšķiniet, lai atsvaidzinātu lapu..',
 		'should_be_activated' => 'JavaScript jābūt ieslēgtam',
+		'unsafe_csp_header' => 'The CSP header in use is unsafe and FreshRSS may be vulnerable to XSS attacks. <a target="_blank" href="https://freshrss.github.io/FreshRSS/en/admins/10_ServerConfig.html#security">See documentation</a>',	// TODO
 	),
 	'lang' => array(
 		'cs' => 'Čeština',	// IGNORE

+ 1 - 0
app/i18n/nl/gen.php

@@ -155,6 +155,7 @@ return array(
 		'labels_empty' => 'Geen labels',
 		'new_article' => 'Er zijn nieuwe artikelen beschikbaar. Klik om de pagina te vernieuwen.',
 		'should_be_activated' => 'JavaScript moet aanstaan',
+		'unsafe_csp_header' => 'The CSP header in use is unsafe and FreshRSS may be vulnerable to XSS attacks. <a target="_blank" href="https://freshrss.github.io/FreshRSS/en/admins/10_ServerConfig.html#security">See documentation</a>',	// TODO
 	),
 	'lang' => array(
 		'cs' => 'Čeština',	// IGNORE

+ 1 - 0
app/i18n/oc/gen.php

@@ -155,6 +155,7 @@ return array(
 		'labels_empty' => 'No labels',	// TODO
 		'new_article' => 'I a d’articles nòus disponibles, clicatz per actualizar la pagina.',
 		'should_be_activated' => 'JavaScript deu èsser activat',
+		'unsafe_csp_header' => 'The CSP header in use is unsafe and FreshRSS may be vulnerable to XSS attacks. <a target="_blank" href="https://freshrss.github.io/FreshRSS/en/admins/10_ServerConfig.html#security">See documentation</a>',	// TODO
 	),
 	'lang' => array(
 		'cs' => 'Čeština',	// IGNORE

+ 1 - 0
app/i18n/pl/gen.php

@@ -155,6 +155,7 @@ return array(
 		'labels_empty' => 'Brak tagów',
 		'new_article' => 'Dostępne są nowe wiadomości. Kliknij, aby odświeżyć stronę.',
 		'should_be_activated' => 'JavaScript musi być włączony',
+		'unsafe_csp_header' => 'Używany nagłówek CSP jest niebezpieczny i FreshRSS może być podatny na ataki XSS. <a target="_blank" href="https://freshrss.github.io/FreshRSS/en/admins/10_ServerConfig.html#security">Zobacz dokumentację</a>',
 	),
 	'lang' => array(
 		'cs' => 'Čeština',	// IGNORE

+ 1 - 0
app/i18n/pt-br/gen.php

@@ -155,6 +155,7 @@ return array(
 		'labels_empty' => 'No labels',	// TODO
 		'new_article' => 'Há novos artigos disponíveis, clique para atualizar a página.',
 		'should_be_activated' => 'O JavaScript precisa estar ativo',
+		'unsafe_csp_header' => 'The CSP header in use is unsafe and FreshRSS may be vulnerable to XSS attacks. <a target="_blank" href="https://freshrss.github.io/FreshRSS/en/admins/10_ServerConfig.html#security">See documentation</a>',	// TODO
 	),
 	'lang' => array(
 		'cs' => 'Čeština',	// IGNORE

+ 1 - 0
app/i18n/pt-pt/gen.php

@@ -155,6 +155,7 @@ return array(
 		'labels_empty' => 'No labels',	// TODO
 		'new_article' => 'Há novos artigos disponíveis, clique para atualizar a página.',
 		'should_be_activated' => 'O JavaScript precisa estar ativo',
+		'unsafe_csp_header' => 'The CSP header in use is unsafe and FreshRSS may be vulnerable to XSS attacks. <a target="_blank" href="https://freshrss.github.io/FreshRSS/en/admins/10_ServerConfig.html#security">See documentation</a>',	// TODO
 	),
 	'lang' => array(
 		'cs' => 'Čeština',	// IGNORE

+ 1 - 0
app/i18n/ru/gen.php

@@ -155,6 +155,7 @@ return array(
 		'labels_empty' => 'Нет меток',
 		'new_article' => 'Появились новые статьи. Нажмите, чтобы обновить страницу.',
 		'should_be_activated' => 'JavaScript должен быть включён',
+		'unsafe_csp_header' => 'The CSP header in use is unsafe and FreshRSS may be vulnerable to XSS attacks. <a target="_blank" href="https://freshrss.github.io/FreshRSS/en/admins/10_ServerConfig.html#security">See documentation</a>',	// TODO
 	),
 	'lang' => array(
 		'cs' => 'Čeština',	// IGNORE

+ 1 - 0
app/i18n/sk/gen.php

@@ -155,6 +155,7 @@ return array(
 		'labels_empty' => 'Žiadne štítky',
 		'new_article' => 'Našli sa nové články. Kliknite na obnovenie stránky.',
 		'should_be_activated' => 'Musíte povoliť JavaScript',
+		'unsafe_csp_header' => 'The CSP header in use is unsafe and FreshRSS may be vulnerable to XSS attacks. <a target="_blank" href="https://freshrss.github.io/FreshRSS/en/admins/10_ServerConfig.html#security">See documentation</a>',	// TODO
 	),
 	'lang' => array(
 		'cs' => 'Čeština',	// IGNORE

+ 1 - 0
app/i18n/tr/gen.php

@@ -155,6 +155,7 @@ return array(
 		'labels_empty' => 'Etiket yok',
 		'new_article' => 'Yeni makaleler mevcut, sayfayı yenilemek için tıklayın.',
 		'should_be_activated' => 'JavaScript etkinleştirilmiş olmalı',
+		'unsafe_csp_header' => 'The CSP header in use is unsafe and FreshRSS may be vulnerable to XSS attacks. <a target="_blank" href="https://freshrss.github.io/FreshRSS/en/admins/10_ServerConfig.html#security">See documentation</a>',	// TODO
 	),
 	'lang' => array(
 		'cs' => 'Čeština',	// IGNORE

+ 1 - 0
app/i18n/zh-cn/gen.php

@@ -155,6 +155,7 @@ return array(
 		'labels_empty' => '无标签',
 		'new_article' => '发现新文章,点击刷新页面。',
 		'should_be_activated' => '必须启用 JavaScript',
+		'unsafe_csp_header' => 'The CSP header in use is unsafe and FreshRSS may be vulnerable to XSS attacks. <a target="_blank" href="https://freshrss.github.io/FreshRSS/en/admins/10_ServerConfig.html#security">See documentation</a>',	// TODO
 	),
 	'lang' => array(
 		'cs' => 'Čeština',	// IGNORE

+ 1 - 0
app/i18n/zh-tw/gen.php

@@ -155,6 +155,7 @@ return array(
 		'labels_empty' => '沒有標籤',
 		'new_article' => '發現新文章,點擊刷新頁面。',
 		'should_be_activated' => '必須啟用 JavaScript',
+		'unsafe_csp_header' => 'The CSP header in use is unsafe and FreshRSS may be vulnerable to XSS attacks. <a target="_blank" href="https://freshrss.github.io/FreshRSS/en/admins/10_ServerConfig.html#security">See documentation</a>',	// TODO
 	),
 	'lang' => array(
 		'cs' => 'Čeština',	// IGNORE

+ 3 - 0
app/views/helpers/javascript_vars.phtml

@@ -7,6 +7,8 @@ $extData = Minz_ExtensionManager::callHook('js_vars', []);
 echo json_encode([
 	'context' => [
 		'anonymous' => !FreshRSS_Auth::hasAccess(),
+		'admin' => FreshRSS_Auth::hasAccess('admin'),
+		'suppress_csp_warning' => FreshRSS_Context::systemConf()->suppress_csp_warning,
 		'auto_remove_article' => !!FreshRSS_Context::isAutoRemoveAvailable(),
 		'hide_posts' => !(FreshRSS_Context::userConf()->display_posts || Minz_Request::actionName() === 'reader'),
 		'display_order' => Minz_Request::paramString('order') ?: FreshRSS_Context::userConf()->sort_order,
@@ -77,6 +79,7 @@ echo json_encode([
 		'labels_empty' => _t('gen.js.labels_empty'),
 		'favicon_size_exceeded' => _t('feedback.sub.feed.favicon.too_large', format_bytes(FreshRSS_Context::systemConf()->limits['max_favicon_upload_size'])),
 		'language' => FreshRSS_Context::userConf()->language,
+		'unsafe_csp_header' => _t('gen.js.unsafe_csp_header'),
 	],
 	'icons' => [
 		'read' => rawurlencode(_i('read')),

+ 5 - 0
config.default.php

@@ -83,6 +83,11 @@ return [
 	#	https://example.net/FreshRSS/p/i/?c=auth&a=login&u=alice&p=1234
 	'unsafe_autologin_enabled' => false,
 
+	# By default, FreshRSS will display a warning to logged-in admin users if the CSP policy is insecure.
+	#	This setting can disable the warning.
+	#	For more information see: https://freshrss.github.io/FreshRSS/en/admins/10_ServerConfig.html#security
+	'suppress_csp_warning' => false,
+
 	# Enable or not the use of syslog to log the activity of
 	#	SimplePie, which is retrieving RSS feeds via HTTP requests.
 	'simplepie_syslog_enabled' => true,

+ 18 - 0
docs/en/admins/10_ServerConfig.md

@@ -112,3 +112,21 @@ server {
 	}
 }
 ```
+
+## Security
+
+Avoid overwriting the [`Content-Security-Policy`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP) header with directives such as `more_set_headers "Content-Security-Policy: ..."`
+This will likely make your FreshRSS instance vulnerable to event handler XSS attacks, since FreshRSS does not yet blacklist all event attributes.
+
+✅ Example of good CSP: `default-src 'self' frame-ancestors 'self'`
+❌ Bad CSP: `upgrade-insecure-requests`
+
+Debug CSP header:
+* With DevTools network tab: press F12
+* [CSP Evaluator](https://csp-evaluator.withgoogle.com/)
+
+If you're aware of the risks and want to ignore the warning shown to admin users, change the `suppress_csp_warning` setting to `true` in `./data/config.php`
+
+Note that FreshRSS already ships with a secure CSP configuration, therefore it's not necessary to make any adjustments to CSP unless you're writing an extension.
+
+For that, look into the [`Minz_ActionController::_csp`](https://github.com/FreshRSS/FreshRSS/blob/d9197d7e32a97f29829ffd4cf4371b1853e51fa2/lib/Minz/ActionController.php#L76-L96) function and use it in individual actions.

+ 2 - 2
docs/i18n/flags/gen/cs.svg

@@ -1,7 +1,7 @@
 <!-- This file is automatically generated by `cli/check.translation.php -g` -->
 <svg xmlns="http://www.w3.org/2000/svg" width="70" height="20">
 	<g fill="white" font-size="12" font-family="Verdana" text-anchor="middle">
-		<rect rx="3" width="70" height="20" fill="green" />
-		<text x="34" y="14">🇨🇿 90%</text>
+		<rect rx="3" width="70" height="20" fill="gold" />
+		<text x="34" y="14">🇨🇿 89%</text>
 	</g>
 </svg>

+ 1 - 1
docs/i18n/flags/gen/ja.svg

@@ -2,6 +2,6 @@
 <svg xmlns="http://www.w3.org/2000/svg" width="70" height="20">
 	<g fill="white" font-size="12" font-family="Verdana" text-anchor="middle">
 		<rect rx="3" width="70" height="20" fill="green" />
-		<text x="34" y="14">🇯🇵 97%</text>
+		<text x="34" y="14">🇯🇵 96%</text>
 	</g>
 </svg>

+ 2 - 2
docs/i18n/flags/gen/ko.svg

@@ -1,7 +1,7 @@
 <!-- This file is automatically generated by `cli/check.translation.php -g` -->
 <svg xmlns="http://www.w3.org/2000/svg" width="70" height="20">
 	<g fill="white" font-size="12" font-family="Verdana" text-anchor="middle">
-		<rect rx="3" width="70" height="20" fill="green" />
-		<text x="34" y="14">🇰🇷 90%</text>
+		<rect rx="3" width="70" height="20" fill="gold" />
+		<text x="34" y="14">🇰🇷 89%</text>
 	</g>
 </svg>

+ 2 - 2
docs/i18n/flags/gen/ru.svg

@@ -1,7 +1,7 @@
 <!-- This file is automatically generated by `cli/check.translation.php -g` -->
 <svg xmlns="http://www.w3.org/2000/svg" width="70" height="20">
 	<g fill="white" font-size="12" font-family="Verdana" text-anchor="middle">
-		<rect rx="3" width="70" height="20" fill="green" />
-		<text x="34" y="14">🇷🇺 90%</text>
+		<rect rx="3" width="70" height="20" fill="gold" />
+		<text x="34" y="14">🇷🇺 89%</text>
 	</g>
 </svg>

+ 2 - 2
docs/i18n/flags/gen/zh-cn.svg

@@ -1,7 +1,7 @@
 <!-- This file is automatically generated by `cli/check.translation.php -g` -->
 <svg xmlns="http://www.w3.org/2000/svg" width="70" height="20">
 	<g fill="white" font-size="12" font-family="Verdana" text-anchor="middle">
-		<rect rx="3" width="70" height="20" fill="green" />
-		<text x="34" y="14">🇨🇳 90%</text>
+		<rect rx="3" width="70" height="20" fill="gold" />
+		<text x="34" y="14">🇨🇳 89%</text>
 	</g>
 </svg>

+ 2 - 2
docs/i18n/flags/gen/zh-tw.svg

@@ -1,7 +1,7 @@
 <!-- This file is automatically generated by `cli/check.translation.php -g` -->
 <svg xmlns="http://www.w3.org/2000/svg" width="70" height="20">
 	<g fill="white" font-size="12" font-family="Verdana" text-anchor="middle">
-		<rect rx="3" width="70" height="20" fill="green" />
-		<text x="34" y="14">🇹🇼 90%</text>
+		<rect rx="3" width="70" height="20" fill="gold" />
+		<text x="34" y="14">🇹🇼 89%</text>
 	</g>
 </svg>

+ 5 - 4
lib/Minz/ActionController.php

@@ -76,16 +76,17 @@ abstract class Minz_ActionController {
 	/**
 	 * Set CSP policies.
 	 *
-	 * A default-src directive should always be given.
+	 * default-src and frame-ancestors directives should always be given.
 	 *
 	 * References:
-	 * - https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP
-	 * - https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/default-src
+	 * - https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP
+	 * - https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/default-src
+	 * - https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/frame-ancestors
 	 *
 	 * @param array<string,string> $policies An array where keys are directives and values are sources.
 	 */
 	protected function _csp(array $policies): void {
-		if (!isset($policies['default-src'])) {
+		if (!isset($policies['default-src']) || !isset($policies['frame-ancestors'])) {
 			$action = Minz_Request::controllerName() . '#' . Minz_Request::actionName();
 			Minz_Log::warning(
 				"Default CSP policy is not declared for action {$action}.",

+ 21 - 0
p/scripts/main.js

@@ -2181,6 +2181,26 @@ function init_normal() {
 	});
 }
 
+function init_csp_alert() {
+	if (!context.admin || context.suppress_csp_warning) {
+		return;
+	}
+
+	try {
+		// eslint-disable-next-line no-new-func
+		Function();
+	} catch (_) {
+		// Exit if 'script-src' is set and 'unsafe-eval' isn't set in CSP
+		return;
+	}
+
+	document.body.insertAdjacentHTML('afterbegin', `
+	<div class="alert alert-error">
+		<span>${context.i18n.unsafe_csp_header}</span>
+	</div>
+	`);
+}
+
 function init_main_beforeDOM() {
 	history.scrollRestoration = 'manual';
 	document.scrollingElement.scrollTop = 0;
@@ -2193,6 +2213,7 @@ function init_main_beforeDOM() {
 function init_main_afterDOM() {
 	removeFirstLoadSpinner();
 	init_notifications();
+	init_csp_alert();
 	init_confirm_action();
 	const stream = document.getElementById('stream');
 	if (stream) {