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 チームによって指定、実装されました。