概要
コンテンツセキュリティポリシー (Content-Security-Policy)とは、コンテンツが読み込めるソースを制御する仕組みです。
これにより、サイト管理者が意図しないスクリプトの実行や画像の表示を防ぐことができ、クロスサイトスクリプティング (XSS) といった攻撃に対する一つの対策となります。
実装上の課題
EC-CUBEでは
Issueも上がっていますが、標準機能としての実装には至っていません。
理由の一つとして、CSP導入時の設定網羅性の確保や、それに伴うカスタマイズ保守の難易度が高いことが挙げられます。
具体的には、CSPの設定では、自サイト以外でスクリプトの読み込みや実行を許可するドメインを列挙する必要があるのですが、このリストには、EC-CUBEが標準で利用する yubinbango.github.io の郵便番号検索ライブラリなどを含める必要があり、さらには、Googleタグマネージャーをはじめとするアクセス解析タグや各種マーケティングツールを追加するたびに、CSP設定の見直しが必要になります。
また、各種決済で利用される外部のスクリプトについても、漏れなく許可設定を行う必要があります。
インラインスクリプトへの対応
もう一つ、CSPを導入するうえで影響範囲が大きいのが、インラインスクリプトへの対応です。
CSPでは、HTMLに直接記述されたインラインスクリプトを許可する設定(unsafe-inline)も可能です。しかし、この設定を有効にすると、意図しないスクリプトまで実行される可能性があります。
例えば、次のようなインラインスクリプトも実行対象となります。
<script>
// 任意のJavaScript
</script>
そのため、インラインスクリプトを利用する場合は、nonce属性を用いて、信頼できるスクリプトのみ実行を許可する方法が推奨されています。
なお、nonceを利用する場合は、HTTPレスポンスヘッダとscriptタグの双方に同じ値を設定することで、そのスクリプトだけが実行されます。
実装
まず、.htaccessに以下を追加します。
Header always set Content-Security-Policy "default-src 'none'; frame-ancestors 'self'" "expr=-z %{resp:Content-Security-Policy}"
次に、以下のファイルをapp/Customize/eventSubscriberに置いてください。
namespace Customize\EventSubscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
class CspSubscriber implements EventSubscriberInterface
{
/**
* admin / front で共通のディレクティブ.
*
* base-uri / frame-ancestors / form-action は default-src へフォールバックしないため、
* 未指定だと無制限になる (ZAP の "Failure to Define Directive with No Fallback").
* base-uri と frame-ancestors はここで固定し、form-action は buildPolicy() で組む.
*/
private const COMMON_DIRECTIVES = [
"default-src 'self'",
"img-src 'self' data: https://www.googletagmanager.com https://www.google-analytics.com",
"font-src 'self' data: https://fonts.gstatic.com https://use.fontawesome.com",
"connect-src 'self' https://www.google-analytics.com https://region1.google-analytics.com",
"base-uri 'self'",
"frame-ancestors 'self'",
"object-src 'none'",
];
/**
* script-src で許可する外部ホスト.
*
* - unpkg.com FilePond (画像アップロード)
* - www.googletagmanager GTM / GA4
* - yubinbango.github.io 郵便番号からの住所自動入力
* ※他にも決済系が必要
*/
private const SCRIPT_HOSTS = [
'https://unpkg.com',
'https://www.googletagmanager.com',
'https://yubinbango.github.io',
'https://code.jquery.com',
];
/** style-src で許可する外部ホスト. */
private const STYLE_HOSTS = [
'https://unpkg.com',
'https://use.fontawesome.com',
];
/**
* 管理画面のみで必要な外部ホスト.
* CategoryAddContent43 のカテゴリコンテンツ編集画面が jQuery UI の JS と CSS を
* CDN から読み込む (admin/category_content_edit.twig:18,130).
*/
private const ADMIN_EXTRA_HOSTS = [
'https://ajax.googleapis.com',
];
public static function getSubscribedEvents(): array
{
// ResponseListener による Content-Type の確定と、他リスナによる本文の
// 書き換えより後に動かす必要があるため、最後に実行する.
return [
KernelEvents::RESPONSE => ['onKernelResponse', -1024],
];
}
public function onKernelResponse(ResponseEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$request = $event->getRequest();
// debug 時の Symfony 例外ページは CSP を外して表示する仕様.
// (HttpKernel\EventListener\ErrorListener::removeCspHeader).
// 当リスナは priority -1024 でそれより後に動くので、ここで明示的に除外する.
if ($request->attributes->get('_remove_csp_headers', false)) {
return;
}
$response = $event->getResponse();
$isAdmin = $this->isAdminRequest($request);
$nonce = base64_encode(random_bytes(16));
// form-action を組むために本文を先に読む.
$html = $this->isRewritableHtml($response) ? (string) $response->getContent() : null;
$formOrigins = $html === null ? [] : $this->collectExternalFormOrigins($html, $request);
$response->headers->set(
'Content-Security-Policy',
$this->buildPolicy($nonce, $isAdmin, $formOrigins)
);
// 管理画面は 'unsafe-inline' で許可しているため nonce の注入は不要.
if (!$isAdmin && $html !== null) {
$response->setContent($this->injectNonce($html, $nonce));
}
}
private function isAdminRequest(Request $request): bool
{
$adminPath = '/'.trim(env('ECCUBE_ADMIN_ROUTE', 'admin'), '/');
$pathInfo = $request->getPathInfo();
return $pathInfo === $adminPath || strpos($pathInfo, $adminPath.'/') === 0;
}
/**
* @param string[] $formOrigins その応答が送信先として必要とする外部オリジン
*/
private function buildPolicy(string $nonce, bool $isAdmin, array $formOrigins): string
{
$scriptHosts = self::SCRIPT_HOSTS;
$styleHosts = self::STYLE_HOSTS;
if ($isAdmin) {
$scriptHosts = array_merge($scriptHosts, self::ADMIN_EXTRA_HOSTS);
$styleHosts = array_merge($styleHosts, self::ADMIN_EXTRA_HOSTS);
$scriptSrc = "script-src 'self' 'unsafe-inline'";
} else {
// 'strict-dynamic' は GTM がスクリプトを動的挿入するため必須
$scriptSrc = "script-src 'self' 'nonce-".$nonce."' 'strict-dynamic'";
}
// style-src は 'unsafe-inline' を維持する.
$formAction = "form-action 'self'";
if ($formOrigins !== []) {
$formAction .= ' '.implode(' ', $formOrigins);
}
$directives = array_merge(
[
$scriptSrc.' '.implode(' ', $scriptHosts),
"style-src 'self' 'unsafe-inline' ".implode(' ', $styleHosts),
$formAction,
],
self::COMMON_DIRECTIVES
);
return implode('; ', $directives);
}
/**
* 応答内の <form action="..."> から、自オリジン以外の送信先オリジンを抽出する.
*
* とくに 3D セキュア 1.0 の ACS URL はカード発行会社ごとに異なり事前に列挙できないため、
* そのページが実際に必要とするオリジンだけを動的に許可する.
* 通常のページは外部送信先を持たないので form-action は 'self' のままになる.
*
* @return string[]
*/
private function collectExternalFormOrigins(string $html, Request $request): array
{
if (!preg_match_all('/
このコードでは、スタイルのインラインの記述は許容します。またスクリプトのハッシュ値は見ていません。
このコードにより、スクリプトにnonce属性を付与しています。
これで、EC-CUBEのフロントにscript-src 'unsafe-inline'を含まないCSPの設定ができました。