- iOS: Capacitor vs WKWebView vs React Native WebView analysis - Mac: Tauri v2 vs Electron comparison with menu bar app patterns - Plugins: Signature validation, sandboxed iframes, permission system Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
7.3 KiB
Plugin System Hardening Research — AIUI
Current State
The plugin system has these existing components:
packages/core/src/types/plugin.ts—AIUIPlugininterface,PluginContext,PluginTypepackages/core/src/plugins/registry.ts— in-memory Vue ref-based registrypackages/app/src/stores/pluginMarketplace.ts—InstalledPlugin,PluginPermission,installPlugin,hasPermissionpackages/app/src/components/settings/PluginMarketplace.vue— permissions dialogpackages/app/src/components/renderers/CodeRunner.vue— existing<iframe sandbox="allow-scripts">+ postMessage pattern
Gaps: No cryptographic signature verification, no runtime permission enforcement in sandbox, no CSP on plugin iframes.
1. Signature Validation for Community Plugins
Problem
importFromUrl fetches arbitrary aiui-plugin.json from any URL with no integrity check. A compromised URL grants arbitrary code execution.
Recommended: Ed25519 Detached Signatures
Aligns with existing crypto posture (tweetnacl.js for E2E, Web Crypto for storage).
Manifest format:
{
"id": "my-plugin",
"name": "My Plugin",
"version": "1.2.0",
"signature": {
"algorithm": "ed25519",
"publicKey": "base64-public-key",
"value": "base64-signature-over-canonical-manifest"
}
}
Verification: Canonical payload = manifest JSON minus signature field, sorted keys. Verify via crypto.subtle.verify('Ed25519', ...) (Chrome 113+, Firefox 130+, Safari 17+) with tweetnacl.js fallback.
Integration point: Gate installPlugin() on signature verification for Tier 2+ plugins.
Secondary: SRI Hash
bundleUrl + integrity field enables browser-native subresource integrity enforcement at load time.
Trust Model
| Tier | Who | Signing | Sandbox | Verification |
|---|---|---|---|---|
| 1 — Built-in | AIUI maintainers | Bundled in app | None (trusted) | None needed |
| 2 — Verified | Reviewed by AIUI team | Ed25519 by author | Full iframe sandbox | Signature + hash |
| 3 — Sideloaded | User-imported URL | Optional | Full iframe sandbox, stricter CSP | Warn prominently |
2. Sandboxed iframe Execution
Architecture
Each community plugin runs in an isolated iframe. AIUI manages a PluginBridge service:
AIUI App (parent)
│
│ postMessage (structured protocol)
│
└── Plugin Sandbox iframe (origin: null, sandbox="allow-scripts")
└── Plugin code (no DOM, no storage, no cross-origin network)
Sandbox Configuration
<!-- Tier 2 plugin (headless, no UI) -->
<iframe sandbox="allow-scripts" srcdoc="..." style="display: none" />
<!-- Tier 2 plugin with UI panel -->
<iframe sandbox="allow-scripts allow-popups-to-escape-sandbox" srcdoc="..." />
Never grant: allow-same-origin (breaks isolation), allow-forms, allow-top-navigation, allow-modals unless explicitly user-approved.
postMessage Protocol
Typed message envelopes following the archyBridge pattern:
// Plugin → Host
interface PluginRequest {
type: 'plugin:request'
id: string // correlation ID
pluginId: string
capability: string // e.g. 'storage:get', 'network:fetch'
payload: unknown
}
// Host → Plugin
interface PluginResponse {
type: 'plugin:response'
id: string
success: boolean
data?: unknown
error?: string
}
Host-side Validation
event.originmust benull(sandboxed srcdoc iframes)pluginIdmust match the iframe→plugin mapping- Requested capability must be in
grantedPermissions - Rate-limit: reject if > N requests/second (DoS prevention)
Network Restriction
With sandbox="allow-scripts" alone, iframes can still fetch(). Block direct network via CSP in srcdoc:
<meta http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'unsafe-inline'; connect-src 'none'">
Plugins requiring network use the network:fetch capability — host proxies the request after validating the URL.
Storage Isolation
Sandboxed iframes without allow-same-origin cannot access parent's localStorage/IndexedDB. Plugin storage goes through the storage capability, namespaced under plugin::{id}::.
3. Permission System Per Plugin
Expanded Permissions
export type PluginPermission =
| 'chat-read' // read chat history
| 'chat-inject' // inject messages (high risk)
| 'chat-messages' // read + inject (legacy, maps to both)
| 'network' // outbound HTTPS via host proxy
| 'favorites' // read/write favorites
| 'storage' // namespaced plugin storage
| 'nostr' // access Nostr identity (high risk)
| 'wallet' // deep-link to wallet (high risk)
| 'clipboard' // read/write clipboard
| 'notifications' // send notifications
| 'media-playback' // control media player
| 'renderer' // register content renderer
| 'settings-read' // read AIUI settings
Risk Classification
const permissionRisk: Record<PluginPermission, 'low' | 'medium' | 'high'> = {
'network': 'low',
'storage': 'low',
'chat-read': 'low',
'favorites': 'low',
'notifications': 'medium',
'clipboard': 'medium',
'media-playback': 'medium',
'renderer': 'medium',
'settings-read': 'medium',
'chat-messages': 'high',
'chat-inject': 'high',
'nostr': 'high',
'wallet': 'high',
}
User Consent Flow
- High-risk permissions default to unchecked in consent dialog
- Show risk badges (low/medium/high) next to each permission
- Unverified plugins (Tier 3) show prominent warning before permissions dialog
- Progressive disclosure: summary before detailed checkboxes
Permission Revocation
- Terminate plugin iframe immediately (
iframe.remove()) - Host handler rechecks
hasPermission()on every capability call - Emit
plugin:permission-revokedevent so plugin can react gracefully
Least Privilege
- Plugins declare minimum permissions in manifest
- All capabilities gated on
hasPermission()— no admin override - Storage namespaced under
plugin::{id}:: - Optional
allowedOrigins[]in manifest restricts network targets - Audit log: capability invocations logged with plugin ID
Integration Points
Files to modify:
packages/core/src/types/plugin.ts — Add tier, signature, sandbox fields to manifest type.
packages/app/src/stores/pluginMarketplace.ts — Add verifyManifestSignature() in installPlugin(). Add revokePermission(). Move grantedPermissions to encrypted storage.
packages/core/src/plugins/registry.ts — Evolve into PluginHost service that manages sandbox iframe lifecycle and routes postMessage capability requests.
packages/app/src/plugins/index.ts — Tier 1 plugins use registerPlugin() directly. Tier 2+ load through PluginHost.loadSandboxed(manifest).
Concrete Next Steps
- Define
PluginSignaturetype andverifyManifestSignature()using tweetnacl.js - Create
PluginSandboxservice to manage iframe lifecycle + postMessage routing - Add CSP meta tag to plugin srcdoc template
- Implement capability handlers (storage, network, chat) with permission checks
- Update consent dialog with risk classification badges
- Add
revokePermission()with live iframe termination - Create CLI tool for plugin authors to sign manifests