Claude
Skills
Sign in
Back

obsidian-security-basics

Included with Lifetime
$97 forever

Implement secure Obsidian plugin development practices. Covers credential storage, input validation, XSS prevention, network security, URI handler safety, and Electron security. Use when handling user data, storing API keys, making network requests, or preparing for community plugin submission. Trigger with phrases like "obsidian security", "secure obsidian plugin", "obsidian data protection", "obsidian privacy", "obsidian api key storage".

Backend & APIsobsidiansecurityauthenticationprivacyelectron

What this skill does

# Obsidian Security Basics

## Overview

Security practices for Obsidian plugin development. Plugins run with full vault filesystem access and can make arbitrary network requests inside Electron. Responsible development requires protecting credentials, sanitizing external data, validating URI handlers, minimizing permissions, and following Obsidian's plugin guidelines to avoid community submission rejection.

## Prerequisites

- Obsidian plugin development environment
- Understanding that `.obsidian/plugins/<id>/data.json` is synced by cloud services
- Awareness of [Obsidian Plugin Guidelines](https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines)

## Instructions

### Step 1: Credential Storage — Never in data.json

Plugin settings (`data.json`) live inside the vault and are synced by iCloud, Dropbox, Obsidian Sync, and Git. API keys stored here are effectively public.

```typescript
// BAD: API key stored in plugin settings (synced to cloud, committed to Git)
interface BadSettings {
  apiKey: string; // This ends up in .obsidian/plugins/my-plugin/data.json
}

// GOOD: Use Electron's safeStorage for desktop (encrypted at OS level)
import { Platform } from 'obsidian';

export class SecureStorage {
  private plugin: Plugin;

  constructor(plugin: Plugin) { this.plugin = plugin; }

  async storeSecret(key: string, value: string): Promise<void> {
    if (Platform.isDesktopApp) {
      // Electron's safeStorage uses OS keychain (Keychain on macOS, DPAPI on Windows)
      const { safeStorage } = require('electron').remote || require('@electron/remote');
      if (safeStorage.isEncryptionAvailable()) {
        const encrypted = safeStorage.encryptString(value);
        const data = await this.plugin.loadData() ?? {};
        data[`_encrypted_${key}`] = encrypted.toString('base64');
        await this.plugin.saveData(data);
        return;
      }
    }
    // Fallback for mobile or when encryption unavailable: prompt each session
    // Store only in memory — never persisted
    this.memoryStore.set(key, value);
  }

  async getSecret(key: string): Promise<string | null> {
    if (Platform.isDesktopApp) {
      const { safeStorage } = require('electron').remote || require('@electron/remote');
      const data = await this.plugin.loadData();
      const encrypted = data?.[`_encrypted_${key}`];
      if (encrypted && safeStorage.isEncryptionAvailable()) {
        return safeStorage.decryptString(Buffer.from(encrypted, 'base64'));
      }
    }
    return this.memoryStore.get(key) ?? null;
  }

  private memoryStore = new Map<string, string>();
}

// Alternative: prompt user each session (simplest, most secure)
async onload() {
  if (!this.apiKey) {
    this.apiKey = await this.promptForApiKey();
  }
}
```

### Step 2: Input Validation and XSS Prevention

Data from HTTP responses, clipboard, or URI handlers must be sanitized before rendering.

```typescript
// Sanitize HTML content before inserting into Obsidian views
function sanitizeHtml(input: string): string {
  // Strip dangerous elements
  input = input.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '');
  input = input.replace(/<iframe[^>]*>[\s\S]*?<\/iframe>/gi, '');
  input = input.replace(/<object[^>]*>[\s\S]*?<\/object>/gi, '');
  input = input.replace(/<embed[^>]*>/gi, '');
  // Strip event handlers
  input = input.replace(/\bon\w+\s*=\s*"[^"]*"/gi, '');
  input = input.replace(/\bon\w+\s*=\s*'[^']*'/gi, '');
  // Strip javascript: URIs
  input = input.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href="#"');
  return input;
}

// For plain text in DOM elements — escape instead of strip
function escapeHtml(text: string): string {
  const div = document.createElement('div');
  div.textContent = text;
  return div.innerHTML;
}

// Safe DOM creation (preferred in Obsidian)
// Use createEl with text content — Obsidian escapes automatically
container.createEl('p', { text: userInput }); // Safe — text is escaped
container.createEl('p').innerHTML = userInput;  // DANGEROUS — raw HTML injection

// For markdown content from external sources
function sanitizeMarkdown(md: string): string {
  // Remove HTML blocks that could contain scripts
  md = md.replace(/<script[\s\S]*?<\/script>/gi, '');
  // Remove image onerror handlers
  md = md.replace(/onerror\s*=\s*["'][^"']*["']/gi, '');
  // Limit length to prevent DoS
  if (md.length > 500_000) md = md.substring(0, 500_000);
  return md;
}
```

### Step 3: Secure URI Handler Registration

Obsidian's `registerObsidianProtocolHandler` lets external apps trigger plugin actions via `obsidian://` URIs. Validate all parameters.

```typescript
this.registerObsidianProtocolHandler('myplugin', async (params) => {
  // Whitelist allowed actions
  const ALLOWED_ACTIONS = ['open', 'create', 'search'] as const;
  type Action = typeof ALLOWED_ACTIONS[number];

  const action = params.action as string;
  if (!ALLOWED_ACTIONS.includes(action as Action)) {
    new Notice(`Invalid action: ${action}`);
    return;
  }

  // Sanitize file paths — prevent directory traversal
  const path = params.path?.replace(/\.\./g, '').replace(/^\//, '');
  if (!path) {
    new Notice('Missing path parameter');
    return;
  }

  // Validate path is within vault
  const normalized = normalizePath(path);
  if (normalized.includes('..') || normalized.startsWith('/')) {
    new Notice('Invalid path');
    return;
  }

  // Limit content length
  const content = params.content?.substring(0, 100_000) ?? '';

  switch (action as Action) {
    case 'open': {
      const file = this.app.vault.getAbstractFileByPath(normalized);
      if (file instanceof TFile) {
        await this.app.workspace.getLeaf().openFile(file);
      } else {
        new Notice(`File not found: ${normalized}`);
      }
      break;
    }
    case 'create': {
      await this.app.vault.create(normalized, content);
      new Notice(`Created: ${normalized}`);
      break;
    }
    case 'search': {
      // Use Obsidian's built-in search
      (this.app as any).internalPlugins.plugins['global-search']
        ?.instance.openGlobalSearch(content);
      break;
    }
  }
});
```

### Step 4: Secure Network Requests

```typescript
import { requestUrl, RequestUrlParam } from 'obsidian';

// Always use Obsidian's requestUrl — it respects proxy settings and CORS
async function secureFetch(url: string, options?: Partial<RequestUrlParam>): Promise<any> {
  // Enforce HTTPS
  if (!url.startsWith('https://')) {
    throw new Error('Only HTTPS requests are allowed');
  }

  // Allowlist domains (prevents SSRF if URL comes from user input)
  const ALLOWED_DOMAINS = ['api.example.com', 'cdn.example.com'];
  const urlObj = new URL(url);
  if (!ALLOWED_DOMAINS.includes(urlObj.hostname)) {
    throw new Error(`Domain not allowed: ${urlObj.hostname}`);
  }

  const response = await requestUrl({
    url,
    method: 'GET',
    headers: {
      'User-Agent': 'ObsidianPlugin/1.0',
      ...options?.headers,
    },
    ...options,
  });

  if (response.status < 200 || response.status >= 300) {
    throw new Error(`HTTP ${response.status}: ${url}`);
  }

  return response.json;
}

// Never log or display full API responses — they may contain PII
function redactForLogging(data: any): any {
  const redacted = { ...data };
  const sensitiveKeys = ['apiKey', 'token', 'password', 'secret', 'authorization'];
  for (const key of Object.keys(redacted)) {
    if (sensitiveKeys.some(s => key.toLowerCase().includes(s))) {
      redacted[key] = '[REDACTED]';
    }
  }
  return redacted;
}
```

### Step 5: Permission Minimization

```typescript
// manifest.json — only set isDesktopOnly if you actually need Electron APIs
{
  "isDesktopOnly": false
  // Obsidian has no granular permission model in manifest.json.
  // The review team evaluates your code for:
  // - Network requests: must be essential to plugin function
  // - Filesystem access outside vault: strongly discouraged
  // - No telemetry/analytics without explicit user consent
  // - No remote code lo

Related in Backend & APIs