はじめに

平素は大変お世話になっております。
クイックガードのパー子です。

Webブラウザのセキュリティ機構の 1つである Content Security Policy (CSP) において、リビジョン Level 3strict-dynamic というキーワードが導入されました。

このキーワードは、NonceHash と組み合わせることで、Nonce/Hash により許可されたスクリプトからさらに派生してロードされるスクリプトの読み込みを伝播的に許可することができます。

つまり、例えば以下のように main.js から派生して main2.js をロードしている場合でも、main.js を Nonce/Hash で許可しておけば main2.js も派生して読み込みを許可される、というわけなのです。

index.html
<html>
  <head>
    <script
      src="./main.js"
      integrity="sha256-gEh1+8U9S1vkEuQSmmUMTZjyNSu5tIoECP4UXIEjMTk="></script>
  </head>
  <body>
    <h1>Example page!</h1>
  </body>
</html>
main.js
console.log("hello");

const scriptElement = document.createElement("script");
scriptElement.src = `main2.js`;

document.head.appendChild(scriptElement);
CSPヘッダ
Content-Security-Policy:
  script-src 'sha256-gEh1+8U9S1vkEuQSmmUMTZjyNSu5tIoECP4UXIEjMTk='

( コード例は developer.mozilla.org から引用 )

この例では scriptエレメントを経由してスクリプトを読み込んでいますが、JavaScript では他にも import宣言import()構文 でもスクリプトをロードすることができます。
(記事中では以降、それぞれ “Static import” / “Dynamic import” と呼称します。)

当記事では、HTMLエレメントの代わりに Static/Dynamic import でスクリプトをロードするとどのような振る舞いを示すのか、挙動を観察するとともに仕様を調べてみました。

挙動の観察

以下 3つのロード方式について、

  • HTMLエレメントを Append
  • Static import
  • Dynamic import

以下の手順で挙動を観察します。

準備

まず、以下の HTML と JSファイルを用意します。

index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />

    <title>CSP</title>
  </head>

  <body>
    <table>
      <tbody>
        <tr>
          <th>Append Element</th>

          <td data-js-type="append-element">Loading...</td>
        </tr>

        <tr>
          <th>Static Import</th>

          <td data-js-type="static-import">Loading...</td>
        </tr>

        <tr>
          <th>Dynamic Import</th>

          <td data-js-type="dynamic-import">Loading...</td>
        </tr>
      </tbody>
    </table>

    <script>
      const s = document.createElement("script");

      s.src = "./append-element.js";

      document.body.append(s);
    </script>

    <script type="module">
      import "./static-import.js";
    </script>

    <script>
      import("./dynamic-import.js");
    </script>
  </body>
</html>
append-element.js
document.querySelector('[data-js-type="append-element"]').textContent = "✓";
static-import.js
document.querySelector('[data-js-type="static-import"]').textContent = "✓";
dynamic-import.js
document.querySelector('[data-js-type="dynamic-import"]').textContent = "✓";

うまくロードできた場合は、

ロードできなかった場合は、

というように表示されます。

CSP を設定する

CSP は以下のいずれかの方法で設定できます。

  • HTTPレスポンス・ヘッダ

    Content-Security-Policy: <policy-directive>; <policy-directive>
    
  • HTML中の <meta>タグ

    <meta http-equiv="Content-Security-Policy" content="<policy-directive>; <policy-directive>;" />
    

今回は簡便な <meta>タグ方式を採用します。

まずはポリシーに strict-dynamic だけ設定してみます。

--- a/index.html
+++ b/index.html
@@ -4,6 +4,8 @@
     <meta charset="utf-8" />

     <title>CSP</title>
+
+    <meta http-equiv="Content-Security-Policy" content="script-src-elem 'strict-dynamic';" />
   </head>

   <body>

設定するディレクティブは script-src-elem です。
これは CSP Level 3 で導入された新しいディレクティブであり、(その片割れの script-src-attrディレクティブ とともに) 従来の script-srcディレクティブ よりもきめ細かい制御を可能とするものです。

strict-dynamic は Nonce/Hash と併用するべきキーワードであり、単独で使用するものではありません。
ロードを許可する Nonce/Hash を記述していないため、当然 3つのロード方式のすべてでロードに失敗します。

Hash値を記述する

ロードが許可されるように、ポリシーに Nonce/Hash を追加してみます。

本来であればリクエストのたびに予測不可能な Nonce値を生成したいところですが、今回の記事では静的な HTML で検証しているため、代わりに Hash を採用します。

Hash値は、各インライン・スクリプトに対して、そのスクリプト・テキストを SHA でハッシュ → Base64 で計算できます。
ハッシュ・アルゴリズムには SHA-256 や SHA-512 を指定できます。

これをいちいち計算してもよいのですが、ブラウザの DevTools に表示されるエラーメッセージから取得するのが簡単です。

Refused to execute inline script because it violates the following Content Security Policy directive: “script-src-elem ‘strict-dynamic’”. Either the ‘unsafe-inline’ keyword, a hash (‘sha256-datLBTFgLrORfIh+Hpz23g64JxDlXL1/9+YZla3a5VQ=’), or a nonce (’nonce-…’) is required to enable inline execution.

今回の例での各インライン・スクリプトに対応する Hash値は次のとおりです。

対象スクリプトHash値
Append Elementsha256-datLBTFgLrORfIh+Hpz23g64JxDlXL1/9+YZla3a5VQ=
Static Importsha256-iJXoNuFOegURuNNHi626GAcfPpYDopl0Iij6IIV4MrM=
Dynamic Importsha256-WSrJXBk1ggxoOdWVwzbnaqS0hmB4dMis9YkeOYWrhAc=

これらをポリシーに列挙します。

--- a/index.html
+++ b/index.html
@@ -5,7 +5,7 @@

     <title>CSP</title>

-    <meta http-equiv="Content-Security-Policy" content="script-src-elem 'strict-dynamic';" />
+    <meta http-equiv="Content-Security-Policy" content="script-src-elem 'strict-dynamic' 'sha256-datLBTFgLrORfIh+Hpz23g64JxDlXL1/9+YZla3a5VQ=' 'sha256-iJXoNuFOegURuNNHi626GAcfPpYDopl0Iij6IIV4MrM=' 'sha256-WSrJXBk1ggxoOdWVwzbnaqS0hmB4dMis9YkeOYWrhAc=';" />
   </head>

   <body>

この状態で、再度ブラウザで見てみましょう。

Append Element 方式ではロードに成功しましたが、Static/Dynamic の両Import方式では失敗しています。

エラーメッセージは次のように変わっており、

Refused to load the script ‘http://localhost:8000/dynamic-import.js’ because it violates the following Content Security Policy directive: “script-src-elem ‘strict-dynamic’ ‘sha256-datLBTFgLrORfIh+Hpz23g64JxDlXL1/9+YZla3a5VQ=’ ‘sha256-iJXoNuFOegURuNNHi626GAcfPpYDopl0Iij6IIV4MrM=’ ‘sha256-WSrJXBk1ggxoOdWVwzbnaqS0hmB4dMis9YkeOYWrhAc=’”.

Refused to load the script ‘http://localhost:8000/static-import.js’ because it violates the following Content Security Policy directive: “script-src-elem ‘strict-dynamic’ ‘sha256-datLBTFgLrORfIh+Hpz23g64JxDlXL1/9+YZla3a5VQ=’ ‘sha256-iJXoNuFOegURuNNHi626GAcfPpYDopl0Iij6IIV4MrM=’ ‘sha256-WSrJXBk1ggxoOdWVwzbnaqS0hmB4dMis9YkeOYWrhAc=’”.

インライン・スクリプトの実行には成功したものの、別の理由で import に失敗していることが読み取れます。

なぜ import に失敗するのか

なぜ import に失敗するのでしょうか。
仕様を確認してみましょう。

仕様としては、W3C が (Working Draft ですが) 最も信頼できそうです。

8.2. Usage of “‘strict-dynamic’” というセクションを見てみると、次のような記述がありました。

2. Script requests which are triggered by non-“parser-inserted” <script> elements are allowed.

「『“parser-inserted” ではない <script>タグ』を契機としたスクリプトのロードは許可される」ということなのですが、この一文からは言外に「そもそも <script>タグ以外のトリガーは許可されない」という解釈ができそうです。

なお参考までに、<script>タグには “parser-inserted” という概念があるようで、一例として、

var s = document.createElement('script');
s.src = 'https://othercdn.not-example.net/dependency.js';
document.head.appendChild(s);

document.write('<scr' + 'ipt src="/sadness.js"></scr' + 'ipt>');

このようなスクリプトがあった場合、

  • createElement() で作られた最初の <script> は “parser-inserted” ではない
  • document.write() で作られた後者は “parser-inserted” である

と判定されるようです。
( コード例は同セクションから引用 )

ちなみに、このセクションでは冒頭で “This section is not normative.” と宣言されており、実際、ブラウザごとに W3C の仕様への準拠具合が異なります。
Google Chrome と Mozilla Firefox は import によるロードを仕様どおりブロックする一方、Safari では仕様に反して import でも問題なくロードに成功します。

import を許可するには

いろいろと試してみたところ、import したいスクリプトを <link rel="modulepreload">タグ で列挙すればロードできることがわかりました。

--- a/index.html
+++ b/index.html
@@ -6,6 +6,9 @@
     <title>CSP</title>

     <meta http-equiv="Content-Security-Policy" content="script-src-elem 'strict-dynamic' 'sha256-datLBTFgLrORfIh+Hpz23g64JxDlXL1/9+YZla3a5VQ=' 'sha256-iJXoNuFOegURuNNHi626GAcfPpYDopl0Iij6IIV4MrM=' 'sha256-WSrJXBk1ggxoOdWVwzbnaqS0hmB4dMis9YkeOYWrhAc=';" />
+
+    <link rel="modulepreload" href="./static-import.js" />
+    <link rel="modulepreload" href="./dynamic-import.js" />
   </head>

   <body>

(これが正当な解決策なのかはわかりませんが) 無事にすべての方式でのロードに成功します。

使い所

今回ご紹介したトリックが役立つ一例としては、JavaScript用の Webフレームワーク Astro を SSG (= Static Site Generation) モードで利用するケースが挙げられます。

Astro には Islands Architecture という概念があり、Island (= ページ上の 1区画を形成する UIコンポーネント) への Hydration に Dynamic import を活用しているのです。

具体例を見ていきましょう。

まず、適当に Astroアプリを作成します。

$ npm create astro@latest

続いて、Island として、ここではお手軽に Preact でコンポーネントを作成することにします。
生成されたAstroアプリのディレクトリに移動して、以下のコマンドを叩きます。

$ npx astro add preact

コンポーネント ./src/components/Hello.jsx を作成します。

./src/components/Hello.jsx
export const Hello = () => {
  return <p>Hello</p>;
};

作成したコンポーネントがページに表示されるようにします。
編集するファイルは ./src/pages/index.astro です。

--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -1,5 +1,5 @@
 ---
-
+import { Hello } from '../components/Hello'
 ---

 <html lang="en">
@@ -12,5 +12,7 @@
        </head>
        <body>
                <h1>Astro</h1>
+
+               <Hello client:load />
        </body>
 </html>

HTML を生成します。

$ npm run build

./dist/配下にファイル一式が生成されます。

(ちょっと長いので折り畳みましたが) index.html の中身は次のようになっており、

./dist/index.html
<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="utf-8">
        <link rel="icon" type="image/svg+xml" href="/favicon.svg">
        <meta name="viewport" content="width=device-width">
        <meta name="generator" content="Astro v4.16.16">
        <title>Astro</title>
    </head>
    <body>
        <h1>Astro</h1>
        <style>
            astro-island,astro-slot,astro-static-slot {
                display: contents
            }
        </style>
        <script>
            ( () => {
                var e = async t => {
                    await (await t())()
                }
                ;
                (self.Astro || (self.Astro = {})).load = e;
                window.dispatchEvent(new Event("astro:load"));
            }
            )();
            ;( () => {
                var A = Object.defineProperty;
                var g = (i, o, a) => o in i ? A(i, o, {
                    enumerable: !0,
                    configurable: !0,
                    writable: !0,
                    value: a
                }) : i[o] = a;
                var d = (i, o, a) => g(i, typeof o != "symbol" ? o + "" : o, a);
                {
                    let i = {
                        0: t => m(t),
                        1: t => a(t),
                        2: t => new RegExp(t),
                        3: t => new Date(t),
                        4: t => new Map(a(t)),
                        5: t => new Set(a(t)),
                        6: t => BigInt(t),
                        7: t => new URL(t),
                        8: t => new Uint8Array(t),
                        9: t => new Uint16Array(t),
                        10: t => new Uint32Array(t),
                        11: t => 1 / 0 * t
                    }
                      , o = t => {
                        let[l,e] = t;
                        return l in i ? i[l](e) : void 0
                    }
                      , a = t => t.map(o)
                      , m = t => typeof t != "object" || t === null ? t : Object.fromEntries(Object.entries(t).map( ([l,e]) => [l, o(e)]));
                    class y extends HTMLElement {
                        constructor() {
                            super(...arguments);
                            d(this, "Component");
                            d(this, "hydrator");
                            d(this, "hydrate", async () => {
                                var b;
                                if (!this.hydrator || !this.isConnected)
                                    return;
                                let e = (b = this.parentElement) == null ? void 0 : b.closest("astro-island[ssr]");
                                if (e) {
                                    e.addEventListener("astro:hydrate", this.hydrate, {
                                        once: !0
                                    });
                                    return
                                }
                                let c = this.querySelectorAll("astro-slot")
                                  , n = {}
                                  , h = this.querySelectorAll("template[data-astro-template]");
                                for (let r of h) {
                                    let s = r.closest(this.tagName);
                                    s != null && s.isSameNode(this) && (n[r.getAttribute("data-astro-template") || "default"] = r.innerHTML,
                                    r.remove())
                                }
                                for (let r of c) {
                                    let s = r.closest(this.tagName);
                                    s != null && s.isSameNode(this) && (n[r.getAttribute("name") || "default"] = r.innerHTML)
                                }
                                let p;
                                try {
                                    p = this.hasAttribute("props") ? m(JSON.parse(this.getAttribute("props"))) : {}
                                } catch (r) {
                                    let s = this.getAttribute("component-url") || "<unknown>"
                                      , v = this.getAttribute("component-export");
                                    throw v && (s += ` (export ${v})`),
                                    console.error(`[hydrate] Error parsing props for component ${s}`, this.getAttribute("props"), r),
                                    r
                                }
                                let u;
                                await this.hydrator(this)(this.Component, p, n, {
                                    client: this.getAttribute("client")
                                }),
                                this.removeAttribute("ssr"),
                                this.dispatchEvent(new CustomEvent("astro:hydrate"))
                            }
                            );
                            d(this, "unmount", () => {
                                this.isConnected || this.dispatchEvent(new CustomEvent("astro:unmount"))
                            }
                            )
                        }
                        disconnectedCallback() {
                            document.removeEventListener("astro:after-swap", this.unmount),
                            document.addEventListener("astro:after-swap", this.unmount, {
                                once: !0
                            })
                        }
                        connectedCallback() {
                            if (!this.hasAttribute("await-children") || document.readyState === "interactive" || document.readyState === "complete")
                                this.childrenConnectedCallback();
                            else {
                                let e = () => {
                                    document.removeEventListener("DOMContentLoaded", e),
                                    c.disconnect(),
                                    this.childrenConnectedCallback()
                                }
                                  , c = new MutationObserver( () => {
                                    var n;
                                    ((n = this.lastChild) == null ? void 0 : n.nodeType) === Node.COMMENT_NODE && this.lastChild.nodeValue === "astro:end" && (this.lastChild.remove(),
                                    e())
                                }
                                );
                                c.observe(this, {
                                    childList: !0
                                }),
                                document.addEventListener("DOMContentLoaded", e)
                            }
                        }
                        async childrenConnectedCallback() {
                            let e = this.getAttribute("before-hydration-url");
                            e && await import(e),
                            this.start()
                        }
                        async start() {
                            let e = JSON.parse(this.getAttribute("opts"))
                              , c = this.getAttribute("client");
                            if (Astro[c] === void 0) {
                                window.addEventListener(`astro:${c}`, () => this.start(), {
                                    once: !0
                                });
                                return
                            }
                            try {
                                await Astro[c](async () => {
                                    let n = this.getAttribute("renderer-url")
                                      , [h,{default: p}] = await Promise.all([import(this.getAttribute("component-url")), n ? import(n) : () => () => {}
                                    ])
                                      , u = this.getAttribute("component-export") || "default";
                                    if (!u.includes("."))
                                        this.Component = h[u];
                                    else {
                                        this.Component = h;
                                        for (let f of u.split("."))
                                            this.Component = this.Component[f]
                                    }
                                    return this.hydrator = p,
                                    this.hydrate
                                }
                                , e, this)
                            } catch (n) {
                                console.error(`[astro-island] Error hydrating ${this.getAttribute("component-url")}`, n)
                            }
                        }
                        attributeChangedCallback() {
                            this.hydrate()
                        }
                    }
                    d(y, "observedAttributes", ["props"]),
                    customElements.get("astro-island") || customElements.define("astro-island", y)
                }
            }
            )();
        </script>
        <astro-island uid="1cB2Nz" component-url="/_astro/Hello.BDKucrsr.js" component-export="Hello" renderer-url="/_astro/client.DJRiuQLg.js" props="{}" ssr="" client="load" opts="{&quot;name&quot;:&quot;Hello&quot;,&quot;value&quot;:true}" await-children="">
            <p>Hello</p>
            <!--astro:end-->
        </astro-island>
    </body>
</html>
  • Island は <astro-island> というタグで定義される。
  • <astro-island>タグには component-urlrenderer-url という属性があり、関連する JSファイルの URL を保持する。
  • インライン・スクリプトで component-url / renderer-url のファイルを Dynamic import する。

という実装になっています。

実際、インライン・スクリプトの Hash値のみをポリシーに記述した状態だとエラーが発生します。

--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -9,6 +9,8 @@ import { Hello } from '../components/Hello'
                <meta name="viewport" content="width=device-width" />
                <meta name="generator" content={Astro.generator} />
                <title>Astro</title>
+
+               <meta http-equiv="Content-Security-Policy" content="script-src-elem 'strict-dynamic' 'sha256-WN0hqek1jEauhlhWVVXeQPa5BD3f0rsMdmwSZtw1Cys=';" />
        </head>
        <body>
                <h1>Astro</h1>

前のセクションで見たように、Island に関連する JSファイルを <link>タグで preload すればエラーは解消します。

--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -11,6 +11,9 @@ import { Hello } from '../components/Hello'
                <title>Astro</title>

                <meta http-equiv="Content-Security-Policy" content="script-src-elem 'strict-dynamic' 'sha256-WN0hqek1jEauhlhWVVXeQPa5BD3f0rsMdmwSZtw1Cys=';" />
+
+               <link rel="modulepreload" href="/_astro/Hello.BDKucrsr.js" />
+               <link rel="modulepreload" href="/_astro/client.DJRiuQLg.js" />
        </head>
        <body>
                <h1>Astro</h1>

Appendix

参考までに、インライン・スクリプトの Hash値の算出と、Island関連JSファイルを列挙するためのスクリプトを置いておきます。

インライン・スクリプトの Hash値を算出する
for (const script of document.querySelectorAll("script")) {
  if (script.hasAttribute("src")) {
    continue;
  }

  const buffer = await crypto.subtle.digest(
    "SHA-256",
    new TextEncoder().encode(script.textContent)
  );

  const base64 = btoa(String.fromCharCode(...new Uint8Array(buffer)));

  console.log(`'sha256-${base64}'`);
}
Island の関連JSファイルの URL を列挙する
const urls = [...document.querySelectorAll("astro-island")]
  .map((island) => {
    const componentUrl = island.getAttribute("component-url");
    const rendererUrl = island.getAttribute("renderer-url");

    return [
      ...(componentUrl ? [componentUrl] : []),
      ...(rendererUrl ? [rendererUrl] : []),
    ];
  })
  .flat();

console.log([...new Set(urls)]);

実運用としては、ビルド時に HTMLファイルへ自動で差し込みたいケースがほとんどだと思います。
Astro には各種イベントにフックするための Integration という仕組みがあるので、これを活用してビルド後 (= astro:build:done) にポリシーを差し込むようにするとよいでしょう。

まとめ

CSP Level 3 の strict-dynamic について、Static/Dynamic import の挙動を見ました。

W3C の仕様から、そもそも import は strict-dynamic の対象外であることがわかりました。
ただし、ブラウザによって挙動が異なり、Google Chrome と Mozilla Firefox は仕様準拠である一方、Safari は import でも問題なくロードできます。

import しているリソースを <link rel="modulepreload">タグで列挙することで Chrome / Firefox でもロードされるようになります。
これが役立つ一例として、Astro の SSG のケースを具体的なコードとともにご紹介しました。

今後ともよろしくお願い申し上げます。