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
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.
interface BadSettings {
apiKey: string;
}
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) {
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;
}
}
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>();
}
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.
function sanitizeHtml(input: string): string {
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, '');
input = input.replace(/\bon\w+\s*=\s*"[^"]*"/gi, '');
input = input.replace(/\bon\w+\s*=\s*'[^']*'/gi, '');
input = input.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href="#"');
return input;
}
function escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
container.(, { : userInput });
container.(). = userInput;
(): {
md = md.(, );
md = md.(, );
(md. > ) md = md.(, );
md;
}
Step 3: Secure URI Handler Registration
Obsidian's registerObsidianProtocolHandler lets external apps trigger plugin actions via obsidian:// URIs. Validate all parameters.
this.registerObsidianProtocolHandler('myplugin', async (params) => {
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;
}
const path = params.path?.replace(/\.\./g, '').replace(/^\//, '');
if (!path) {
new Notice('Missing path parameter');
return;
}
const normalized = normalizePath(path);
if (normalized.includes() || normalized.()) {
();
;
}
content = params.?.(, ) ?? ;
(action ) {
: {
file = ...(normalized);
(file ) {
...().(file);
} {
();
}
;
}
: {
...(normalized, content);
();
;
}
: {
(. )..[]
?..(content);
;
}
}
});
Step 4: Secure Network Requests
import { requestUrl, RequestUrlParam } from 'obsidian';
async function secureFetch(url: string, options?: Partial<RequestUrlParam>): Promise<any> {
if (!url.startsWith('https://')) {
throw new Error('Only HTTPS requests are allowed');
}
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?.,
},
...options,
});
(response. < || response. >= ) {
();
}
response.;
}
(): {
redacted = { ...data };
sensitiveKeys = [, , , , ];
( key .(redacted)) {
(sensitiveKeys.( key.().(s))) {
redacted[key] = ;
}
}
redacted;
}
Step 5: Permission Minimization
{
"isDesktopOnly": false
}
import { Platform, FileSystemAdapter } from 'obsidian';
function getVaultBasePath(): string | null {
if (this.app.vault.adapter instanceof FileSystemAdapter) {
return this.app.vault.adapter.getBasePath();
}
return null;
}
if (Platform.isDesktopApp) {
} else {
}
Step 6: Plugin Review Rejection Checklist
Obsidian's plugin review team will reject plugins for these violations:
eval(userInput);
new Function('return ' + code)();
document.createElement('script');
const script = document.createElement('script');
script.src = 'https://cdn.example.com/lib.js';
console.log('user data:', settings);
this.saveData({ apiKey: 'sk-abc123' });
fetch('https://analytics.example.com/track', { body: ... });
Output
- SecureStorage class using Electron's safeStorage for encrypted credential storage
- HTML and markdown sanitization for all external content
- URI handler with action whitelist and path validation
- Secure network request wrapper with HTTPS enforcement and domain allowlist
- Platform-specific guards for desktop/mobile code paths
- Plugin review rejection checklist with approved alternatives
Error Handling
| Issue | Cause | Solution |
|---|
| API key synced to cloud | Stored in data.json | Use SecureStorage with Electron safeStorage |
| XSS in note preview | Unsanitized external HTML | Use createEl with text property, or sanitizeHtml |
| Directory traversal via URI | Unvalidated path parameter | Strip .., normalize, validate within vault |
| SSRF from user-provided URL | No domain allowlist | Validate against ALLOWED_DOMAINS before requestUrl |
| Plugin rejected on review | eval, console.log, or telemetry | Follow rejection checklist (Step 6) |
safeStorage unavailable | Older Electron version or mobile | Fall back to per-session prompt |
Examples
Content Security for Custom Views
async onOpen() {
const externalData = await this.fetchData();
const container = this.containerEl.children[1];
container.empty();
container.createEl('h3', { text: externalData.title });
container.createEl('p', { text: externalData.summary });
const safeHtml = sanitizeHtml(externalData.htmlContent);
const htmlContainer = container.createEl('div');
htmlContainer.innerHTML = safeHtml;
}
Audit Your Plugin for Security Issues
grep -rn 'eval(\|new Function(' src/ --include="*.ts" && echo "FAIL: dynamic code execution"
grep -rn 'innerHTML\s*=' src/ --include="*.ts" && echo "WARN: check for XSS"
grep -rn 'console\.log' src/ --include="*.ts" | grep -v '// DEBUG' && echo "WARN: console.log in prod"
grep -rn 'apiKey\|secret\|password' src/ --include="*.ts" && echo "CHECK: credential handling"
echo "Done."
Resources
Next Steps
For production readiness checks, see obsidian-prod-checklist.
For deployment and community submission, see obsidian-deploy-integration.