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 innerHTML for 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 MarkdownRenderChild wrappers 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 innerHTML midway 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));

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 innerHTML unless 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.