Async Clipboard API でのサニタイズされていない HTML

Chrome 120 以降、Async Clipboard API で新しい unsanitized オプションが利用可能になります。このオプションは、HTML の特殊な状況で、クリップボードの内容をコピーしたときと同じように貼り付ける必要がある場合に役立ちます。つまり、ブラウザが一般的に適用する中間的なサニタイズ ステップを省略します。このガイドではその使用方法について説明します。

Async Clipboard API を使用する場合、ほとんどの場合、クリップボード上の内容の完全性について心配する必要はなく、クリップボードに書き込み(コピー)したものは、クリップボードから読み取り(貼り付け)したものと同じであると想定できます。

これは間違いなくテキストの場合に当てはまります。次のコードを DevTools コンソールに貼り付け、すぐにページにフォーカスを合わせます。(setTimeout() は、ページにフォーカスを当てる十分な時間を確保するために必要です。これは Async Clipboard API の要件です)。ご覧のとおり、入力は出力とまったく同じです。

setTimeout(async () => {
  const input = 'Hello';
  await navigator.clipboard.writeText(input);
  const output = await navigator.clipboard.readText();
  console.log(input, output, input === output);
  // Logs "Hello Hello true".
}, 3000);

画像の場合は少し異なります。いわゆる圧縮ボム攻撃を防ぐため、ブラウザは PNG などの画像を再エンコードしますが、入力画像と出力画像はピクセル単位で視覚的にまったく同じです。

setTimeout(async () => {
  const dataURL =
    'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVQYV2NgYAAAAAMAAWgmWQ0AAAAASUVORK5CYII=';
  const input = await fetch(dataURL).then((response) => response.blob());
  await navigator.clipboard.write([
    new ClipboardItem({
      [input.type]: input,
    }),
  ]);
  const [clipboardItem] = await navigator.clipboard.read();
  const output = await clipboardItem.getType(input.type);
  console.log(input.size, output.size, input.type === output.type);
  // Logs "68 161 true".
}, 3000);

では HTML テキストはどうなるでしょうか。お察しのとおり、HTML では状況が異なります。この場合、ブラウザは HTML コードをサニタイズして、不具合が発生しないようにします。たとえば、HTML コードから <script> タグ(<meta><head><style> など)を削除したり、CSS をインライン化したりします。次の例を参考に、DevTools コンソールで試してみてください。出力は入力とかなり異なります。

setTimeout(async () => {
  const input = `<html>  
  <head>  
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />  
    <meta name="ProgId" content="Excel.Sheet" />  
    <meta name="Generator" content="Microsoft Excel 15" />  
    <style>  
      body {  
        font-family: HK Grotesk;  
        background-color: var(--color-bg);  
      }  
    </style>  
  </head>  
  <body>  
    <div>hello</div>  
  </body>  
</html>`;
  const inputBlob = new Blob([input], { type: 'text/html' });
  await navigator.clipboard.write([
    new ClipboardItem({
      'text/html': inputBlob,
    }),
  ]);
  const [clipboardItem] = await navigator.clipboard.read();
  const outputBlob = await clipboardItem.getType('text/html');
  const output = await outputBlob.text();
  console.log(input, output);
}, 3000);

通常、HTML のサニタイズは有益です。ほとんどの場合、未処理の HTML を許可してセキュリティの問題にさらされることはありません。ただし、デベロッパーが何をしているのかを正確に把握しており、アプリの正しい機能に HTML の入出力の完全性が不可欠なシナリオもあります。このような状況では、次の 2 つの方法があります。

  1. コピーと貼り付けの両方を制御する場合(たとえば、アプリ内からコピーしてアプリ内に貼り付ける場合など)、Async Clipboard API 用のウェブ カスタム フォーマットを使用する必要があります。ここまでお読みになったら、リンク先の記事をご確認ください。
  2. アプリで貼り付け側のみを制御し、コピー側は制御しない場合は、unsanitized オプションを使用する必要があります。これは、コピー オペレーションがウェブ カスタム形式をサポートしていないネイティブ アプリで行われるためです。このオプションについては、この記事の後半で説明します。

サニタイズには、script タグの削除、スタイルのインライン化、HTML の整形式化などが含まれます。このリストは網羅的なものではなく、今後さらに手順が追加される可能性があります。

サニタイズされていない HTML をコピーして貼り付ける

Async Clipboard API を使用して HTML をクリップボードに write()(コピー)すると、ブラウザは DOM パーサーを実行して結果の HTML 文字列をシリアル化し、HTML が適切に形成されていることを確認します。ただし、このステップではサニタイズは行われません。何もする必要はありません。別のアプリによってクリップボードに配置された HTML を read() し、ウェブアプリが完全な忠実度コンテンツの取得をオプトインし、独自のコードでサニタイズを実行する必要がある場合は、プロパティ unsanitized と値 ['text/html'] を使用してオプション オブジェクトを read() メソッドに渡すことができます。単独では、navigator.clipboard.read({ unsanitized: ['text/html'] }) のようになります。次のコードサンプルは、前述のコードサンプルとほぼ同じですが、unsanitized オプションが含まれています。DevTools コンソールで試すと、入力と出力が同じであることがわかります。

setTimeout(async () => {
  const input = `<html>  
  <head>  
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />  
    <meta name="ProgId" content="Excel.Sheet" />  
    <meta name="Generator" content="Microsoft Excel 15" />  
    <style>  
      body {  
        font-family: HK Grotesk;  
        background-color: var(--color-bg);  
      }  
    </style>  
  </head>  
  <body>  
    <div>hello</div>  
  </body>  
</html>`;
  const inputBlob = new Blob([input], { type: 'text/html' });
  await navigator.clipboard.write([
    new ClipboardItem({
      'text/html': inputBlob,
    }),
  ]);
  const [clipboardItem] = await navigator.clipboard.read({
    unsanitized: ['text/html'],
  });
  const outputBlob = await clipboardItem.getType('text/html');
  const output = await outputBlob.text();
  console.log(input, output);
}, 3000);

ブラウザのサポートと機能の検出

機能がサポートされているかどうかを直接確認する方法はないため、機能の検出は動作の観察に基づいています。したがって、次の例は、<style> タグが存続するか(サポートあり)、またはインライン化されているか(サポートされていないことを示す)の検出に依存しています。これが機能するには、ページがすでにクリップボードの権限を取得している必要があります。

const supportsUnsanitized = async () => {
  const input = `<style>p{color:red}</style><p>a`;
  const inputBlob = new Blob([input], { type: 'text/html' });
  await navigator.clipboard.write([
    new ClipboardItem({
      'text/html': inputBlob,
    }),
  ]);
  const [clipboardItem] = await navigator.clipboard.read({
    unsanitized: ['text/html],
  });
  const outputBlob = await clipboardItem.getType('text/html');
  const output = await outputBlob.text();
  return /<style>/.test(output);
};

デモ

unsanitized オプションの動作を確認するには、Glitch のデモソースコードをご覧ください。

まとめ

概要で説明したように、ほとんどのデベロッパーはクリップボードのサニタイズについて心配する必要はなく、ブラウザによって選択されたデフォルトのサニタイズで作業できます。デベロッパーが注意を払う必要があるまれなケースについては、unsanitized オプションがあります。

謝辞

この記事は、Anupam SnigdhaRachel Andrew が確認しました。この API は、Microsoft Edge チームによって指定および実装されました。