Unsanitized HTML in the Async Clipboard API

From Chrome 120, a new unsanitized option is available in the Async Clipboard API. This option can help in special situations with HTML, where you need to paste the contents of the clipboard identical to how it was when it was copied. That is, without any intermediate sanitization step that browsers commonly—and for good reasons—apply. Learn how to use it in this guide.

When working with the Async Clipboard API, in the majority of cases, developers don't need to worry about the integrity of the content on the clipboard and can assume that what they write onto the clipboard (copy) is the same what they will get when they read the data from the clipboard (paste).

This is definitely true for text. Try pasting the following code in the DevTools Console and then refocus the page immediately. (The setTimeout() is necessary so you have enough time to focus the page, which is a requirement of the Async Clipboard API.) As you see, the input is exactly the same as the output.

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);

With images, it's a little different. To prevent so-called compression bomb attacks, browsers re-encode images like PNGs, but the input and the output images are visually exactly the same, pixel per pixel.

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);

What happens with HTML text, though? As you may have guessed, with HTML, the situation is different. Here, the browser sanitizes the HTML code to prevent bad things from happening, by, for example, stripping <script> tags from the HTML code (and others like <meta>, <head>, and <style>) and by inlining CSS. Consider the following example and try it in the DevTools Console. You will notice that the output differs quite significantly from the input.

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 sanitization generally is a good thing. You don't want to expose yourself to security issues by allowing unsanitized HTML in the majority of cases. There are scenarios, though, where the developer knows exactly what they are doing and where the integrity of the in- and output HTML is crucial to the correct functioning of the app. Under these circumstances, you have two choices:

  1. If you control both the copying and the pasting end, for example, if you copy from within your app to then likewise paste within your app, you should use Web custom formats for the Async Clipboard API. Stop reading here and check the linked article.
  2. If you only control the pasting end in your app, but not the copying end, maybe because the copy operation happens in a native app that doesn't support web custom formats, you should use the unsanitized option, which is explained in the rest of this article.

Sanitization includes things like removing script tags, inlining styles, and ensuring the HTML is well-formed. This list is non-comprehensive, and more steps may be added in the future.

Copy and paste of unsanitized HTML

When you write() (copy) HTML to the clipboard with the Async Clipboard API, the browser makes sure that it is well formed by running it through a DOM parser and serializing the resulting HTML string, but no sanitization is happening at this step. There is nothing you need to do. When you read() HTML placed on the clipboard by another application, and your web app is opting in to getting the full fidelity content and needing to perform any sanitization in your own code, you can pass an options object to the read() method with a property unsanitized and a value of ['text/html']. In isolation, it looks like this: navigator.clipboard.read({ unsanitized: ['text/html'] }). The following code sample below is almost the same as the one shown previously, but this time with the unsanitized option. When you try it in the DevTools Console, you will see that the input and the output are the same.

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);

Browser support and feature detection

There's no direct way to check whether the feature is supported, so feature detection is based on observing the behavior. Therefore, the following example relies on the detection of the fact whether a <style> tag survives, which indicates support, or is being inlined, which indicates non-support. Note that for this to work, the page already needs to have obtained the clipboard permission.

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);
};

Demo

To see the unsanitized option in action, see the demo on Glitch and check out its source code.

Conclusions

As outlined in the introduction, most developers will never need to worry about clipboard sanitization and can just work with the default sanitization choices made by the browser. For the rare cases where developers need to care, the unsanitized option exists.

Acknowledgements

This article was reviewed by Anupam Snigdha and Rachel Andrew. The API was specified and implemented by the Microsoft Edge team.