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 などの画像の再エンコードが行われていますが、入力画像と出力画像はまったく同じ(1 ピクセルあたり)です。

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 コードから <script> タグ(および <meta><head><style> など)を取り除き、CSS をインライン化するなどして、不正な事態を防ぐために HTML コードをサニタイズします。次の例について考え、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 文字列をシリアル化することにより、正しい形式であることを確認しますが、このステップではサニタイズは行われません。必要な操作はありません。別のアプリによってクリップボードに read() HTML を配置し、ウェブアプリで完全な忠実度コンテンツの取得を選択し、独自のコードでサニタイズを実行する必要がある場合は、プロパティ 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 チームによって行われました。