VS Code extension authors often assume XSS is “just a web app problem.” That’s a mistake.
If your extension uses a webview, custom editor, notebook renderer, or any HTML UI at all, you’re building a mini browser surface inside an editor that developers trust deeply. That trust changes the impact. A sloppy XSS bug in a marketing site is bad. A sloppy XSS bug in a VS Code extension can expose workspace data, abuse extension APIs indirectly, phish secrets, or trick users into running commands.
The tricky part is that VS Code gives you several ways to build UI, and each comes with different security tradeoffs. Some are safer by default. Some are flexible but easy to mess up.
Here’s the practical comparison.
Where XSS happens in VS Code extensions
The usual hotspots are:
WebviewPanelWebviewView- Custom editors
- Notebook output renderers
- Tree/item tooltips rendered as markdown or HTML-like content
- Any extension code that takes workspace content, API data, or user input and injects it into HTML
The biggest risk is almost always the same: untrusted content reaches a browser-like sink.
Typical vulnerable code looks like this:
panel.webview.html = `
<html>
<body>
<h2>${userTitle}</h2>
<div>${markdownPreview}</div>
</body>
</html>
`;
If userTitle or markdownPreview can contain attacker-controlled input, you’ve built an XSS sink.
Option 1: Raw HTML templating in webviews
This is the fastest way to ship a webview. It’s also where I see the most broken extensions.
Pros
- Extremely flexible
- Easy to prototype
- Works with any frontend stack or plain HTML
- Full control over markup, scripts, styles, and message passing
Cons
- Easiest path to DOM XSS
- Developers often interpolate strings directly into HTML
- CSP is usually missing or too permissive
- Inline scripts/styles tempt people into bad habits
- Harder to audit once the HTML template grows
A lot of extension authors start with template strings because they’re convenient:
function renderPanel(content: string) {
return `
<!DOCTYPE html>
<html>
<body>
<div id="app">${content}</div>
</body>
</html>
`;
}
That’s fine only if content is fully trusted. In real extensions, it usually isn’t.
Better pattern
Render a static shell and populate content with safe DOM APIs:
panel.webview.html = `
<!DOCTYPE html>
<html>
<body>
<div id="title"></div>
<script nonce="${nonce}">
const data = ${JSON.stringify({ title: userTitle })};
document.getElementById('title').textContent = data.title;
</script>
</body>
</html>
`;
textContent is dramatically safer than innerHTML.
My opinion: if you’re hand-rolling HTML strings, you need a very good reason to ever use innerHTML.
Option 2: Frontend frameworks inside webviews
React, Vue, Svelte, and friends can reduce accidental XSS, but they don’t magically solve it.
Pros
- Escaping is usually safe by default in templating
- Better separation between data and UI
- Easier to maintain large extension UIs
- Component boundaries make code review easier
Cons
- Dangerous escape hatches still exist
- Developers eventually reach for raw HTML rendering
- Third-party components may bypass safe defaults
- Build tooling can hide what actually ends up in the DOM
For example, React escapes string output by default:
export function Panel({ title }: { title: string }) {
return <h1>{title}</h1>;
}
That’s good.
But the moment someone uses dangerouslySetInnerHTML, you’re back in XSS territory:
export function Preview({ html }: { html: string }) {
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
If that HTML came from markdown, file contents, API responses, or another extension, sanitize it first.
Best use case
A framework-based webview is often the best balance for non-trivial UIs, as long as you keep these rules:
- Prefer escaped rendering
- Sanitize any HTML you intentionally allow
- Lock down CSP
- Avoid inline script patterns when possible
I trust framework rendering more than raw string interpolation, but only by a margin. Bad decisions still punch right through the safety rails.
Option 3: Markdown rendering
Many extensions render markdown for previews, hover content, documentation panes, or custom views. This feels safer because markdown isn’t “raw HTML” in the developer’s head. That assumption gets people burned.
Pros
- Better authoring experience
- Easier than hand-writing HTML
- Some renderers support safe modes or HTML disabling
- Good fit for docs and previews
Cons
- Markdown often allows embedded HTML
- Link handling can be abused
- Renderer configs vary a lot
- Syntax highlighting plugins and markdown extensions may introduce unsafe HTML
If you render markdown to HTML, treat the output as untrusted unless you control the full pipeline.
Bad pattern:
const html = marked.parse(userMarkdown);
panel.webview.html = `<html><body>${html}</body></html>`;
Safer pattern:
- Disable raw HTML in the markdown renderer if possible
- Sanitize rendered output
- Restrict allowed tags and attributes
- Validate links before rendering or clicking
If your feature really only needs plain text or very limited formatting, don’t support arbitrary HTML at all. That’s the simplest win.
Option 4: Sanitization libraries
Sanitization is necessary sometimes, but developers often treat it like a silver bullet. It isn’t.
Pros
- Lets you support rich content safely
- Useful for markdown previews, documentation panes, and imported HTML fragments
- Easier than building your own allowlist parser
Cons
- Easy to misconfigure
- Dangerous if you sanitize once and mutate later
- Doesn’t fix unsafe JavaScript patterns elsewhere
- Can create false confidence
If you absolutely must render HTML, sanitize before insertion:
import DOMPurify from 'dompurify';
const clean = DOMPurify.sanitize(untrustedHtml, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'p', 'code', 'pre', 'ul', 'ol', 'li', 'a'],
ALLOWED_ATTR: ['href']
});
container.innerHTML = clean;
That’s much better than raw insertion, but I still prefer avoiding HTML injection entirely when the UI can be expressed with DOM nodes and text.
My rule: sanitize only when rendering HTML is a product requirement, not because it’s convenient.
Option 5: Content Security Policy in webviews
CSP is one of the strongest controls available for VS Code webviews, and too many extensions either skip it or use a weak policy that barely helps.
VS Code webviews support CSP through a meta tag. Official docs cover the webview model and security behavior, and they’re worth reading carefully:
For CSP implementation details and patterns, https://csp-guide.com is also useful.
Pros
- Limits script execution even if markup injection happens
- Blocks many exploit paths
- Forces cleaner asset loading patterns
- Reduces blast radius of mistakes
Cons
- Can be annoying during development
- Breaks inline scripts unless you use nonces or hashes
- Requires discipline around asset loading and bundling
- Weak policies give only cosmetic protection
A decent webview CSP looks like this:
const csp = `
default-src 'none';
img-src ${panel.webview.cspSource} https: data:;
style-src ${panel.webview.cspSource};
script-src 'nonce-${nonce}';
font-src ${panel.webview.cspSource};
connect-src ${panel.webview.cspSource};
`;
And then:
panel.webview.html = `
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Security-Policy" content="${csp}">
</head>
<body>
<script nonce="${nonce}" src="${scriptUri}"></script>
</body>
</html>
`;
If your policy includes 'unsafe-inline' for scripts, you’re giving away a lot of the value.
My take
If you use webviews without CSP, you’re gambling. Even a decent sanitization setup benefits from a restrictive CSP behind it.
Option 6: Message passing between extension host and webview
This isn’t XSS by itself, but it becomes part of the exploit chain all the time.
Webviews can post messages to the extension host, and the extension host can post messages back. If an attacker gets JavaScript execution in the webview, they will absolutely poke at that channel.
Pros
- Clean separation between UI and privileged extension logic
- Necessary for most interactive extensions
- Easier to reason about than exposing everything to the page
Cons
- Tempts developers to trust webview messages too much
- XSS in the webview can become command abuse
- Poor message validation leads to privilege escalation patterns
Bad pattern:
panel.webview.onDidReceiveMessage((msg) => {
if (msg.command === 'openFile') {
vscode.commands.executeCommand('vscode.open', vscode.Uri.file(msg.path));
}
});
If the webview gets compromised, that handler becomes an attack surface.
Better pattern:
panel.webview.onDidReceiveMessage((msg) => {
if (!msg || typeof msg !== 'object') return;
switch (msg.type) {
case 'showInfo':
if (typeof msg.text !== 'string' || msg.text.length > 200) return;
vscode.window.showInformationMessage(msg.text);
break;
default:
return;
}
});
Treat webview messages like API input from an untrusted client. Because that’s what they are.
Best approach by use case
Simple data display
Use static HTML plus safe DOM assignment with textContent.
Best for: titles, file metadata, status panels
Avoid: innerHTML, inline event handlers
Complex interactive UI
Use a frontend framework with default escaping, plus strict CSP.
Best for: dashboards, custom editors, multi-step tools
Watch for: raw HTML escape hatches
Markdown/document previews
Use a markdown renderer configured to disable raw HTML where possible, then sanitize if needed.
Best for: docs, changelogs, previews
Watch for: embedded HTML and dangerous links
Rich HTML rendering requirement
Use a sanitizer with a tight allowlist and back it with CSP.
Best for: imported trusted-ish formatted content
Watch for: overbroad allowed tags/attributes
What I’d recommend for most extension authors
If I were building a VS Code extension UI today, my default stack would be:
- Webview with strict CSP
- Framework rendering with escaped output by default
- No
dangerouslySetInnerHTMLorinnerHTMLunless there’s a hard requirement - Sanitization only for specific rich-content features
- Strict validation on every message from the webview
- No trust in workspace content, filenames, markdown, or API responses
That setup is not the most “flexible.” Good. Flexibility is usually how XSS gets in.
The real comparison is simple:
- Raw HTML gives maximum control and maximum XSS risk.
- Framework rendering gives better defaults but still needs discipline.
- Markdown pipelines are convenient but easy to overtrust.
- Sanitization is useful but never enough by itself.
- CSP is your strongest safety net.
- Message validation decides whether XSS stays local or turns into something worse.
If your extension renders content you didn’t hardcode yourself, assume an attacker can reach it. Build from that assumption, and your odds of shipping an XSS bug drop fast.