-
Notifications
You must be signed in to change notification settings - Fork 0
Security Model
Mosaic takes security seriously. The application employs defense in depth — multiple independent security layers that protect users even if one layer is compromised.
Layer 1: Tauri CSP (Browser-level policy)
Layer 2: Tauri Capabilities (System-level permissions)
Layer 3: Web Worker Isolation (Code execution sandbox)
Layer 4: JavaScript/Python Sandboxes (Language-level restrictions)
Layer 5: Data Validation (Input sanitization)
Layer 6: API Key Encryption (Storage protection)
The CSP is enforced at the Tauri level in tauri.conf.json (the old CSP meta tag in index.html was removed in v0.3.0 to avoid duplication):
{
"security": {
"csp": "default-src 'self';
script-src 'self' 'wasm-unsafe-eval' https://cdn.jsdelivr.net;
worker-src 'self' blob: https://cdn.jsdelivr.net;
connect-src 'self' https://api.mistral.ai
https://api.openai.com
https://api.anthropic.com
https://generativelanguage.googleapis.com
https://cdn.jsdelivr.net
https://files.pythonhosted.org
https://pyodide-cdn2.iodide.io
http://localhost:11434;
img-src 'self' data: blob:;
style-src 'self' 'unsafe-inline';
font-src 'self' data:;
frame-src 'none';
object-src 'none';
base-uri 'self';"
}
}| Directive | Policy | Purpose |
|---|---|---|
default-src |
'self' |
Fallback for all resource types — only same-origin |
script-src |
'self' 'wasm-unsafe-eval' https://cdn.jsdelivr.net |
Allows Pyodide WASM loading from CDN; blocks inline scripts |
worker-src |
'self' blob: https://cdn.jsdelivr.net |
Allows Web Workers only from same origin or Pyodide CDN |
connect-src |
Provider APIs + CDNs + localhost | Restricts network requests to known safe endpoints |
img-src |
'self' data: blob: |
Blocks external image loading (prevents tracking pixels) |
frame-src |
'none' |
No iframes allowed |
object-src |
'none' |
No plugins (Flash, Java) |
base-uri |
'self' |
Prevents base URI injection |
The CSP prevents:
-
XSS attacks: Inline scripts are blocked; only scripts from
'self'andcdn.jsdelivr.netcan execute -
Data exfiltration:
connect-srcrestricts what URLs the app can contact; even if XSS succeeds, data can only go to known endpoints -
Clickjacking: No iframes allowed (
frame-src: 'none') -
Drive-by downloads:
object-src: 'none'blocks plugin-based attacks
Tauri v2 uses a capability-based permission model. Mosaic's capabilities are minimal:
{
"identifier": "default",
"description": "Default capability set",
"windows": ["main"],
"permissions": [
"core:default"
]
}Only "core:default" is granted. This includes basic window management, but no:
- File system access
- Shell/process execution
- Clipboard access
- Global shortcuts
- HTTP requests (all networking is done from the webview, not the Rust backend)
This is intentionally minimal. The Rust backend serves only as a webview container — all application logic runs in the sandboxed frontend.
| System Access | Mosaic | Typical Electron App | Typical Tauri App |
|---|---|---|---|
| File system | ❌ (blocked by capabilities) | ✅ (Node.js fs) | ✅ (via commands) |
| Network (system level) | ❌ (blocked by capabilities) | ✅ (Node.js net) | ✅ (via commands) |
| Process spawning | ❌ | ✅ (child_process) | ✅ (via shell plugin) |
| Clipboard | ❌ (blocked by capabilities) | ✅ (navigator.clipboard) | ✅ (via clipboard plugin) |
| Global shortcuts | ❌ | ✅ (globalShortcut) | ✅ (via plugin) |
| Window management | ✅ (core:default) | ✅ | ✅ |
| HTTP requests (webview) | ✅ (restricted by CSP) | ✅ | ✅ |
| WebSocket (webview) | ❌ (blocked by CSP) | ✅ | ✅ |
By using the minimalist capability set, Mosaic significantly reduces the attack surface. Even if an attacker gains code execution in the webview, they cannot:
- Read or write files on the user's system
- Execute shell commands
- Access system-level APIs
- Register global shortcuts (which could be used for keylogging)
All user code (JavaScript and Python) executes in a Web Worker, which provides:
- No DOM access: Workers cannot read or manipulate the DOM
- No store access: Workers cannot access Zustand stores or localStorage
- Separate global scope: Workers have their own global scope, separate from the main thread
-
Message-based communication: Workers communicate with the main thread only via
postMessage - Terminable: Workers can be terminated at any time, killing all running code
Main Thread (privileged) Web Worker (sandboxed)
┌──────────────────────────┐ ┌──────────────────────┐
│ API keys (in memory) │ │ Only receives: │
│ Canvas data (stores) │ │ - exec_id │
│ localStorage (all data) │ │ - lang │
│ DOM (full access) │ msg │ - code │
│ fetch (unrestricted) │◄───────►│ - stdout/result │
│ Node.js file system │ │ - No DOM/stores │
└──────────────────────────┘ └──────────────────────┘
Even if malicious code breaks out of the JavaScript/Python sandbox, it's still confined to the Worker scope and cannot access sensitive data on the main thread.
JavaScript code is executed via new Function() with explicit parameter injection:
// Safe: Every global must be explicitly passed
const fn = new Function(
'Math', 'Date', 'JSON', 'console',
// ... 40+ allowlisted globals
code
);
return fn(Math, Date, JSON, console, /* ... */);Any reference to a non-passed symbol (like fetch, document, localStorage, process) throws a ReferenceError. This is a strict allowlist approach — everything is blocked by default, only explicitly listed symbols are available.
Blocked APIs: fetch, XMLHttpRequest, WebSocket, Worker, SharedWorker, localStorage, sessionStorage, indexedDB, open, close, import(), Document, Window, Navigator, Location, History, Screen.
Python code runs in Pyodite (CPython in WebAssembly) with additional restrictions:
- Pip packages: Only 15 allowlisted packages can be installed
-
Network: Python's
urllib/requests/http.clientare patched to only allow CDN hosts -
Platform modules:
msvcrt,termios,fcntl,mmapare blocked or restricted - C extensions: Platform-specific C extensions are blocked (they don't work in WASM anyway)
- Execution timeout: 30 seconds maximum
All data loaded from external sources is validated before use.
When importing a canvas JSON file, validateImportedCanvas() checks:
function validateImportedCanvas(data: unknown): data is CanvasData {
if (!data || typeof data !== 'object') return false;
const d = data as Record<string, unknown>;
// Must have nodes and edges arrays
if (!Array.isArray(d.nodes) || !Array.isArray(d.edges)) return false;
// Each node must have valid structure
for (const node of d.nodes) {
if (!node.id || !node.type || !node.position || !node.data) return false;
if (typeof node.position.x !== 'number' || typeof node.position.y !== 'number') return false;
if (!validateNodeData(node.data)) return false;
}
// Each edge must have valid structure
for (const edge of d.edges) {
if (!edge.id || !edge.source || !edge.target) return false;
}
return true;
}All persisted stores validate their data on load:
| Store | Validator | Behavior on Invalid Data |
|---|---|---|
| canvasManagerStore | validateCanvasData() |
Reset to default (empty canvases) |
| uiStore | validateUIState() |
Reset to default settings |
| ragStore | validateRagDocs() |
Reset to empty document list |
| analyticsStore | None (numeric data) | Keep as-is |
API keys are stored in localStorage using XOR encryption with a salt:
const SALT = 'MosaicKeySalt';
function encryptKey(plaintext: string): string {
let result = '';
for (let i = 0; i < plaintext.length; i++) {
result += String.fromCharCode(
plaintext.charCodeAt(i) ^ SALT.charCodeAt(i % SALT.length)
);
}
return btoa(result);
}
function decryptKey(encoded: string): string {
const ciphertext = atob(encoded);
let result = '';
for (let i = 0; i < ciphertext.length; i++) {
result += String.fromCharCode(
ciphertext.charCodeAt(i) ^ SALT.charCodeAt(i % SALT.length)
);
}
return result;
}Important limitations:
- XOR encryption is not production-grade — it prevents casual exposure (e.g., someone looking at your localStorage)
- A determined attacker with access to the binary can extract the salt and decrypt keys
- For production use, consider OS-level credential storage (Keychain, Credential Manager, Secret Service)
In v0.3.0, API key storage was upgraded from a single shared key to per-provider keys:
| Provider | localStorage Key |
|---|---|
| Mistral | mosaic-api-key-mistral |
| OpenAI | mosaic-api-key-openai |
| Anthropic | mosaic-api-key-anthropic |
| Gemini | mosaic-api-key-gemini |
After decryption, API keys are cached in memory (a Map<ProviderId, string>) to avoid repeated decryption:
const keyCache = new Map<string, string>();
function getApiKey(provider: string): string | null {
if (keyCache.has(provider)) return keyCache.get(provider)!;
const stored = localStorage.getItem(`mosaic-api-key-${provider}`);
if (!stored) return null;
const decrypted = decryptKey(stored);
keyCache.set(provider, decrypted);
return decrypted;
}The Web Worker is created with strict controls:
function createSandboxedWorker(): Worker {
// Create worker from a blob URL (inline worker)
const workerCode = `
// Worker code for sandboxed execution
self.onmessage = function(event) {
const { exec_id, lang, code } = event.data;
executeCode(exec_id, lang, code);
};
`;
const blob = new Blob([workerCode], { type: 'application/javascript' });
const url = URL.createObjectURL(blob);
const worker = new Worker(url);
// Clean up blob URL
URL.revokeObjectURL(url);
// Set up message handler
worker.onmessage = handleWorkerMessage;
worker.onerror = handleWorkerError;
return worker;
}Using a Blob URL instead of an external file prevents:
- Path traversal attacks (cannot load arbitrary worker files)
- Import/require in the worker (Blob workers have no URL to resolve imports from)
- Source mapping leaks (worker source is embedded, not served from filesystem)
The worker can be terminated in three scenarios:
| Scenario | Trigger | Behavior |
|---|---|---|
| Execution timeout | 30-second timer expires |
worker.terminate() → new worker created |
| User abort | User clicks stop button | AbortController aborts fetch; worker continues |
| App unmount | Component unmounts |
worker.terminate() for cleanup |
When a worker is terminated due to timeout, a fresh worker is always created to replace it. This ensures that even if the worker's internal state was corrupted by malicious code, subsequent executions start clean.
JavaScript code is executed via new Function() with explicit parameter injection:
// Safe: Every global must be explicitly passed
const fn = new Function(
'Math', 'Date', 'JSON', 'console',
// ... 40+ allowlisted globals
code
);
return fn(Math, Date, JSON, console, /* ... */);Any reference to a non-passed symbol (like fetch, document, localStorage, process) throws a ReferenceError. This is a strict allowlist approach — everything is blocked by default, only explicitly listed symbols are available.
Blocked APIs: fetch, XMLHttpRequest, WebSocket, Worker, SharedWorker, localStorage, sessionStorage, indexedDB, open, close, import(), Document, Window, Navigator, Location, History, Screen.
Python code runs in Pyodite (CPython in WebAssembly) with additional restrictions:
- Pip packages: Only 15 allowlisted packages can be installed
-
Network: Python's
urllib/requests/http.clientare patched to only allow CDN hosts -
Platform modules:
msvcrt,termios,fcntl,mmapare blocked or restricted - C extensions: Platform-specific C extensions are blocked (they don't work in WASM anyway)
- Execution timeout: 30 seconds maximum
Mosaic considered using iframes for sandboxing but chose Web Workers for these reasons:
| Aspect | Web Worker | iframe |
|---|---|---|
| DOM access | None | Requires sandbox attribute |
| Network access | None (unless code allows) | Blocked by CSP |
| Thread isolation | Separate OS thread | Same thread (event loop) |
| Message passing | postMessage | postMessage |
| Termination |
terminate() available |
Cannot be reliably killed |
| Performance | Low overhead | Full browser context overhead |
| Shared state | None | Can access parent via frameElement |
Workers are strictly more isolated than iframes for code execution scenarios.
Before executing AI-generated code, Mosaic shows a confirmation dialog:
⚠️ This code was generated by AI. Are you sure you want to run it?
[Cancel] [Run Anyway]
This only appears once per session for AI-generated code blocks. User-written code does not trigger the warning.
When loading Pyodide from the CDN, Mosaic verifies the module integrity using checksums to prevent tampering:
const INTEGRITY_HASHES = {
'pyodide-0.26.2': 'sha384-...',
};Failed integrity checks prevent the module from loading and show an error to the user.
Mosaic does not collect or transmit any telemetry data:
- No analytics SDKs
- No error tracking services
- No usage statistics
- All analytics data is stored locally in localStorage
| Concern | Mitigated By |
|---|---|
| Cross-site scripting (XSS) | CSP, no inline scripts |
| Data exfiltration | connect-src CSP, sandboxed workers |
| Malicious code execution | Worker isolation, Function sandbox, pip allowlist |
| API key theft | XOR encryption, in-memory only after decryption |
| Corrupted data crashes | Schema validation on all imports |
| Network attacks | CSP, CDN allowlist |
| Memory access | Web Worker separation |
| Extension exploits | Disabled plugins, no iframes |
If you discover a security vulnerability in Mosaic, please report it by opening a GitHub issue with the label security. Do not publicly disclose the vulnerability until it has been addressed.
- Code Sandbox Architecture — Deep dive into sandboxed execution
- Deployment and Distribution — How builds are secured
- Troubleshooting and FAQ — Common security-related questions
Mosaic — Branch, explore, and run code inline — an infinite canvas for AI conversations.
Built with Tauri, React, and Mistral AI.
GitHub Repository |
Report an Issue |
Releases
- Canvas and Node System
- LLM Provider Integration
- RAG System Guide
- Advanced AI Features
- Keyboard Shortcuts and UI Reference
- Tutorial - Branching and Parallel Exploration
- Tutorial - Using RAG with Documents
- Tutorial - Advanced Features in Practice
- Tutorial - Customizing Mosaic