| 7min read | 1361 words | Photo by John T on Unsplash

Hacking the VSCode Markdown Preview

How to inject user-provided scripts into VSCode's Markdown Preview despite CSP restrictions

Share

Have you ever tried to build a plugin system for your app? How do you load the code at runtime? Whos code is actually loaded and to what trust level? You try to execute user-provided JavaScript inside VSCode’s Markdown Preview and inevitably run into the firm hands of security policies like CSP.

“It’s in place to protect your users from Cross-Site-Scripting attacks (XSS)”, they say. XSS by whom? By the user them-selves? But, that’s pretty much what we wanted to enable.

1 An Illustrative Example

While working on UltiNotes I needed to extend the VSCode markdown preview, the requirement being:

Let the user have custom scripts for the markdown preview so they can have web components and other arbitrary interactivity in markdown.

So I started hacking away and immediately ran into problems. My first approach was to transform code blocks into executable scripts.

# This code block in markdown

    ```md-scripts
      console.log("Hello World")
    ```

# leads to the following output

<script>console.log("Hello World")</script>

For this to work I wrote a markdown-it plugin. Code-wise it looked fine and should work. But on the first run I was greeted by an error message:

CSP violation

How do we make it work? We change the preview security setting to disabled. So now it works, but should it?

2 What is CSP?

Content Security Policy is a set of instructions via the HTTP headers or HTML head of the page that specifies sources from which different types of content (scripts, fonts, styles, requests, media) can be loaded.

Basically this allows you to whitelist certain locations or media that your site uses, while preventing content loaded on your site to inject its own scripts and media, effectively preventing attacks such as Cross-Site Scripting (XSS), clickjacking, and data injection.

A basic CSP would look like this:

Content-Security-Policy: default-src 'self'; script-src 'self' https://trusted-scripts.com;

In this example, the default-src directive specifies that all content must come from the same origin (‘self’), and the script-src directive allows scripts only from the same origin and a trusted external source, https://trusted-scripts.com.

3 How CSP Works in VSCode

Here’s what the strict CSP configuration looks like in the VSCode Markdown Preview (as JSON for readability):

{
  "default-src": "'none'",
  "img-src": ["'self'", "https://*.vscode-cdn.net", "https:", "data:"],
  "media-src": ["'self'", "https://*.vscode-cdn.net", "https:", "data:"],
  "script-src": ["'nonce-uv9jVP9Fl7QnYo0bAkfg3ZwSNMefcu6OSi2tXbs6rxQfrwRsvTc8gL5AIF4n2hmW'"],
  "style-src": ["'self'", "https://*.vscode-cdn.net", "'unsafe-inline'", "https:", "data:"],
  "font-src": ["'self'", "https://*.vscode-cdn.net", "https:", "data:"]
}

If we relax the preview security settings to allow insecure local content or allow insecure content it will add the policies to allow loading media and styles via HTTP. But no matter what you choose you will always end up with a nonce-restricted script policy:

  "script-src": ["'nonce-uv9jVP9Fl7QnYo0bAkfg3ZwSNMefcu6OSi2tXbs6rxQfrwRsvTc8gL5AIF4n2hmW'"],

In this case the Markdown Preview will generate a nonce everytime the preview is loaded and supply the included script tags with this nonce to whitelist them. If a script loaded as part of the content tries to load additional scripts, they won’t execute due to the missing nonce in the script tag.

Besides that the CSP can also blacklist certain mechanisms inside scripts, such as the use of eval(), content of iframes a.s.o.

Now if you remember our requirement from the beginning, I want my extension to load JS code provided by the user, which involves loading scripts dynamically at runtime and thus directly opposes the constraints by the CSP.

4 Dumb Try: Appending to Body

First thing I tried was to write a script that constructs script tags whenever the preview is opened.

const scripts = document.querySelectorAll < HTMLDivElement > '.md-scripts';
scripts.forEach((script) => {
  const executableScript = document.createElement('script');
  const scriptElement = document.createElement('script');
  scriptElement.innerHTML = script.innerText;
  document.body.appendChild(scriptElement);
});

This ‘loader’ script was provided to VSCode via a contribution point inside the extension’s package.json:

...
"contributes": {
    "markdown.previewScripts": [
      "./out/preview/index.js"
    ],
    ...
}

The loader script was running but the loaded scripts will run into a CSP violation, because it obviously tries to create new executable code, and this was the main motivator for me to dig into this topic.

But hold on! The loaded scripts were blocked, but the ‘loader’ script wasn’t. So we learned one important thing: The loader script itself had a nonce and was whitelisted and running fine.

5 Smart Try: Markdown Compilation

My second approach was to do the same thing but during markdown compilation. I wanted to find out whether the nonce was generated after the markdown compilation or before. To my surprise this didn’t work, and I couldn’t figure out a way to retrieve this nonce during compilation. It seems like the nonce-whitelisting of scripts happens way before the markdown is compiled. But, as mentioned above, it happens after the extension supplies its own scripts.

In despair I tried many more things, like:

Only once the CSP is disabled can we include scripts without a nonce. This comes with a lot of additional negative side-effects since, due to the way VSCode works, we cannot change the script-src policy in isolation and therefore need to disable all other policies as well.

Besides that, even though I am in an adverse relationship with CSP at the moment, I see the benefit of giving the user control by saying ‘Your machine will only run code that you explicitely whitelist’. Maybe hacks for this exist, but since I want UltiNotes to be as unintrusive as possible I didn’t want to build it on top of weird hacks and altered security settings as a foundation. The strict CSP will stay for now.

6 Why not a Web View?

You might ask yourself why I didn’t build a new Web View. Many VSCode extensions come with a custom Web View that allows the extension creators to display whatever is possible with web technologies. My problem with that is that I wanted UltiNotes to interop with other markdown extensions. In theory this should be possible, as my extension can activate other extensions, get their public interface and see if they extend markdown-it or not. But that’s a topic for another post and certainly the more difficult route, since there is another way now, as you will read in a moment.

7 Using Local Extensions

So as I’ve been struggling with this issue for a few months, time has caught up and, in a weird twist of fate, VSCode now enables the user to install local workspace extensions1. Remember the ‘loader’ script? The only proven way to inject scripts into the Markdown Preview seems to be through contribution points, defined within the extension. But because no average user would access the code of the extension and modify it, there was no way for the user to provide scripts to it.

Well, since VSCode 1.89 (May 2024) you are able to install extensions locally to your workspace. Which means users are now able to access the extension on the code-level.

8 Third Time is a Charm

So I had a working prototype and began extending it with features, as I noticed that the solution has been there all along and doesn’t even require local extensions in the first place. I can just provide script paths via the settings and then let the extension rewrite its own contribution points.

Let’s take a look at the current solution:

// ...
export function activate(context: vscode.ExtensionContext) {
  const absoluteExtensionPath = context.extensionPath;

  const loadScriptCommand = vscode.commands.registerCommand('load script', async () => {
    // get current workspace (usually the first)
    const workspaceFolders = vscode.workspace.workspaceFolders;
    const workspacePath = workspaceFolders[0].uri.fsPath;

    // parse package.json as json ...
    const contentString = await fs.readFile(path.join(absoluteExtensionPath, 'package.json'), {
      encoding: 'utf-8',
    });

    // ... update it ...
    const contentJson = JSON.parse(contentString) as {
      contributes: { 'markdown.previewScripts': string[] };
    };
    contentJson.contributes['markdown.previewScripts'] = [
      ...defaultScriptPaths, // string array of extension scripts
      ...getSettings().addedScripts, // string array of user-provided scripts
    ];

    // ... and write it back
    await fs.writeFile(path.join(absoluteExtensionPath, 'package.json'), JSON.stringify(contentJson, undefined, 2));

    // reload the extension
    // ...
  });
  // ...
}
// ...

And now it just works! 🥳

See you next time…

9 Further reading

Share