Obsidian plugin development feels deceptively safe.
You are not building a public website. You are writing code for a note-taking app, usually for power users, often for yourself first. That mindset is exactly why XSS bugs slip in. The UI still renders HTML. The app still runs JavaScript. The plugin still touches untrusted content from notes, frontmatter, sync sources, APIs, and other plugins.
And because Obsidian sits on Electron, a bad XSS bug can feel worse than the browser kind. Sometimes it is “just” UI injection. Sometimes it is account token theft from a plugin settings panel. Sometimes it becomes local file access or remote code execution territory if the plugin mixes unsafe rendering with privileged APIs.
The short version: if your plugin turns note content, metadata, or external data into HTML, you need to treat it like a web app.
The common XSS hotspots in Obsidian plugins
These are the places I keep seeing trouble:
- Rendering markdown-derived content with custom HTML
- Using
innerHTMLfor convenience in setting tabs, modals, and side panes - Displaying remote API responses in plugin views
- Building template previews from frontmatter or note titles
- Passing user content into
MarkdownRenderChildwrappers without validating assumptions - Mixing DOM event handlers into generated HTML strings
A lot of plugin code starts like this:
containerEl.innerHTML = `
<div class="my-plugin-card">
<h3>${noteTitle}</h3>
<p>${description}</p>
</div>
`;
If noteTitle or description can be influenced by note content, frontmatter, or external APIs, that is an XSS sink. In Obsidian, “user content” is everywhere, and a vault is not necessarily trusted data.
Approach 1: Raw innerHTML
This is the fastest approach and the one I trust the least.
Pros
- Extremely easy to write
- Familiar to frontend developers
- Fine for fully static markup with no interpolation
Cons
- Highest XSS risk
- Easy to accidentally mix trusted and untrusted content
- Hard to review once templates get bigger
- Tends to spread through the codebase because it feels convenient
Bad example:
function renderResult(containerEl: HTMLElement, result: { title: string; body: string }) {
containerEl.innerHTML = `
<article>
<h2>${result.title}</h2>
<div>${result.body}</div>
</article>
`;
}
If result.body contains <img src=x onerror=alert(1)>, you have a problem.
Safer version if you do not need HTML rendering:
function renderResult(containerEl: HTMLElement, result: { title: string; body: string }) {
containerEl.empty();
const article = containerEl.createEl("article");
article.createEl("h2", { text: result.title });
article.createEl("div", { text: result.body });
}
My opinion: if you are reaching for innerHTML, you should feel slightly guilty every time.
Approach 2: DOM APIs and Obsidian element helpers
This is usually the best default.
Obsidian gives you convenient helpers like createEl, and plain DOM APIs are safer because they push you toward textContent instead of HTML parsing.
Pros
- Strong XSS resistance by default
- Easier code review
- Clear separation between text and structure
- Works well for settings tabs, lists, modals, and dashboards
Cons
- Verbose for complex layouts
- Harder if you genuinely need rich HTML
- Developers sometimes fall back to
innerHTMLmidway through a refactor
Example:
function renderNotes(containerEl: HTMLElement, notes: Array<{ title: string; excerpt: string }>) {
containerEl.empty();
const list = containerEl.createEl("div", { cls: "notes-list" });
for (const note of notes) {
const card = list.createEl("div", { cls: "note-card" });
card.createEl("h3", { text: note.title });
card.createEl("p", { text: note.excerpt });
}
}
This is boring code, which is exactly why I like it. Boring code causes fewer incidents.
Approach 3: Sanitized HTML with DOMPurify
Sometimes you really do need HTML. Maybe your plugin renders formatted previews, remote content snippets, or custom markdown-like output. In those cases, sanitization is the minimum bar.
Pros
- Lets you preserve limited rich formatting
- Far safer than raw
innerHTML - Flexible allowlist configuration
Cons
- Easy to misconfigure
- Developers often allow too much
- Sanitization is not a substitute for safe architecture
- You still need to think about URL-based injection, data URIs, and odd browser behaviors
Example:
import DOMPurify from "dompurify";
function renderPreview(containerEl: HTMLElement, html: string) {
const clean = DOMPurify.sanitize(html, {
ALLOWED_TAGS: ["p", "b", "i", "em", "strong", "a", "ul", "ol", "li", "code", "pre"],
ALLOWED_ATTR: ["href", "title"],
ALLOW_DATA_ATTR: false
});
containerEl.innerHTML = clean;
}
That is much better than unsanitized HTML, but do not stop thinking after adding DOMPurify. If you allow links, validate schemes too:
function isSafeUrl(url: string): boolean {
try {
const parsed = new URL(url, "https://example.com");
return ["http:", "https:", "mailto:"].includes(parsed.protocol);
} catch {
return false;
}
}
A lot of real-world XSS bugs survive “sanitization” because the developer forgot that dangerous behavior can hide in URLs and attributes, not just <script> tags.
Approach 4: Markdown rendering APIs
Many Obsidian plugins want to render markdown, not arbitrary HTML. That is a much better place to start. If you can stay inside Obsidian’s markdown rendering flow, do it.
Pros
- Better aligned with Obsidian’s content model
- Less custom parsing code
- Usually safer than inventing your own mini renderer
Cons
- Not automatically safe if you bolt on custom HTML transforms
- Plugin ecosystems create weird trust boundaries
- You still need to be careful with post-processing hooks
A common mistake is rendering markdown, then doing string replacements on the output HTML:
const rendered = markdownToHtml(userMarkdown);
containerEl.innerHTML = rendered.replace(/\[!badge:(.*?)\]/g, '<span class="badge">$1</span>');
That undo button does not exist. You just took a safer pipeline and shoved it back into unsafe string-based HTML generation.
If you need custom syntax, parse it structurally before rendering, or transform the DOM after rendering using node APIs.
Approach 5: Framework rendering inside Obsidian views
Some plugin authors bring React, Svelte, or Vue into Obsidian. That can help, but it is not magic.
Pros
- Better component structure
- Built-in escaping in most template systems
- Easier state management for complex plugins
Cons
- False sense of safety
- Dangerous escape hatches still exist
- More bundle complexity
- Third-party component libraries can reintroduce XSS sinks
React example, safe by default:
export function ResultCard({ title, body }: { title: string; body: string }) {
return (
<article>
<h2>{title}</h2>
<p>{body}</p>
</article>
);
}
React example, not safe by default:
export function HtmlPreview({ html }: { html: string }) {
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
If your framework offers escaping by default, keep it that way. The minute you use the “dangerous” API, you own the threat model.
Comparison guide: what I would choose
Here is the practical ranking.
Best default: DOM APIs or framework auto-escaping
Use this for:
- Settings tabs
- Modals
- Lists of notes
- Search results
- Metadata displays
Why: lowest cognitive load, safest default behavior.
Best for markdown-centric plugins: native markdown rendering
Use this for:
- Note previews
- Markdown panels
- Read-only content views
Why: it matches the problem. Do not convert markdown to ad hoc HTML with string templates unless you enjoy security reviews.
Acceptable with discipline: sanitized HTML
Use this for:
- Rich previews from controlled sources
- Limited formatting from external APIs
- Structured snippets where markdown is not enough
Why: sometimes you need it, but keep the allowed surface small.
Worst option: raw innerHTML
Use this for:
- Static markup only
- Nothing interpolated
- Cases where you could defend every byte in a code review
Why: it is the easiest way to ship an XSS bug.
Obsidian-specific hardening tips
A few habits make a big difference:
Treat vault content as untrusted
People install plugins from strangers, sync notes across devices, paste web content into notes, and import vaults from elsewhere. “It is my local note” is not a security boundary.
Be careful with plugin settings UIs
Settings tabs often interpolate API responses, file names, templates, and secrets-adjacent values. These panels are attractive targets because they often run with more trust and visibility.
Audit event handler injection
Never build HTML like this:
containerEl.innerHTML = `<button onclick="doThing('${userInput}')">Run</button>`;
Attach listeners with code instead:
const button = containerEl.createEl("button", { text: "Run" });
button.addEventListener("click", () => doThing(userInput));
Validate URLs before rendering links
Even when text is escaped, links can still be dangerous. Restrict schemes. Reject javascript: and weird encoded variants.
Use CSP where it applies
Obsidian plugins are not normal websites, so CSP is not always under your full control. But if your plugin serves local webviews, companion pages, docs portals, or settings-related web content, ship a CSP that blocks inline scripts and tightens resource loading. If you need a practical CSP reference, csp-guide.com is solid.
Check your security headers on companion web endpoints
A surprising number of plugin ecosystems also run mini backends, auth callbacks, docs microsites, or update endpoints. If you expose any web surface around your plugin, test it. I usually run it through HeaderTest to catch missing headers and obvious policy gaps fast.
My blunt recommendation
If you are writing an Obsidian plugin, start with this rule:
- Use DOM APIs or framework escaping for everything
- Use markdown rendering for markdown
- Use DOMPurify only when rich HTML is genuinely required
- Avoid raw
innerHTMLunless the content is fully static
That stack covers most plugin needs without drama.
The biggest mistake I see is developers treating Obsidian like a cozy desktop sandbox. It is not. It is a rich client that renders content, loads plugins, and often processes data from messy sources. That is exactly where XSS thrives.
Write your plugin like someone else will feed it hostile input tomorrow. Because eventually, someone will.