security: harden Electron app - context isolation, preload, fix XSS & command injection

- Enable contextIsolation, disable nodeIntegration, remove enableRemoteModule
- Add preload.js with safe contextBridge API exposure
- Fix OS command injection: exec() → execFile() in open-file-in-player
- Fix DOM XSS: replace innerHTML with textContent/createElement in FileListManager, UIManager, ModalManager
- Remove direct fs/electron access from renderer, route through IPC
- Add validate-folder and get-file-stats IPC handlers
- Load renderer modules via script tags with require polyfill
This commit is contained in:
Jarian Cottingham 2026-07-05 07:31:06 +00:00
parent 38b7bc0d6e
commit 73f5585488
7 changed files with 314 additions and 116 deletions

View File

@ -637,6 +637,29 @@
<span id="tagged-count">0</span>
</div>
<script>
// Module system polyfill for renderer (contextIsolation enabled, nodeIntegration disabled)
window.require = window.createModuleRequire();
var module = { exports: {} };
</script>
<script src="utils/renderer/AppState.js"></script>
<script>window.AppState = module.exports; module={exports:{};};</script>
<script src="utils/renderer/FileListManager.js"></script>
<script>window.FileListManager = module.exports; module={exports:{};};</script>
<script src="utils/renderer/FileManager.js"></script>
<script>window.FileManager = module.exports; module={exports:{};};</script>
<script src="utils/renderer/TagManager.js"></script>
<script>window.TagManager = module.exports; module={exports:{};};</script>
<script src="utils/renderer/EpisodeManager.js"></script>
<script>window.EpisodeManager = module.exports; module={exports:{};};</script>
<script src="utils/renderer/SearchManager.js"></script>
<script>window.SearchManager = module.exports; module={exports:{};};</script>
<script src="utils/renderer/ModalManager.js"></script>
<script>window.ModalManager = module.exports; module={exports:{};};</script>
<script src="utils/renderer/ProgressManager.js"></script>
<script>window.ProgressManager = module.exports; module={exports:{};};</script>
<script src="utils/renderer/UIManager.js"></script>
<script>window.UIManager = module.exports; module={exports:{};};</script>
<script src="renderer.js"></script>
</body>
</html>

53
main.js
View File

@ -31,9 +31,10 @@ function createWindow() {
width: 1200,
height: 800,
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
enableRemoteModule: true,
preload: path.join(__dirname, 'preload.js'),
nodeIntegration: false,
contextIsolation: true,
sandbox: false,
},
});
@ -844,24 +845,28 @@ ipcMain.handle('move-to-extras', async (event, filePath) => {
});
// IPC handler for opening file in default player
// FIXED: Use execFile instead of exec to prevent command injection
ipcMain.handle('open-file-in-player', async (event, filePath) => {
try {
const { exec } = require('child_process');
const { execFile } = require('child_process');
// Open file in default player based on OS
let command;
// Open file in default player based on OS using execFile (safe against injection)
let command, args;
if (process.platform === 'darwin') {
// macOS
command = `open "${filePath}"`;
command = 'open';
args = [filePath];
} else if (process.platform === 'win32') {
// Windows
command = `start "" "${filePath}"`;
command = 'cmd.exe';
args = ['/c', 'start', '""', filePath];
} else {
// Linux
command = `xdg-open "${filePath}"`;
command = 'xdg-open';
args = [filePath];
}
exec(command, (error, stdout, stderr) => {
execFile(command, args, (error, stdout, stderr) => {
if (error) {
console.error('Error opening file:', error);
} else {
@ -876,6 +881,32 @@ ipcMain.handle('open-file-in-player', async (event, filePath) => {
}
});
// IPC handler for validating folder path (renderer can't access fs directly)
ipcMain.handle('validate-folder', async (event, folderPath) => {
try {
const stats = fs.statSync(folderPath);
return { success: true, exists: true, isDirectory: stats.isDirectory() };
} catch (error) {
return { success: false, exists: false, isDirectory: false, error: error.message };
}
});
// IPC handler for getting file stats (renderer can't access fs directly)
ipcMain.handle('get-file-stats', async (event, filePath) => {
try {
const stats = fs.statSync(filePath);
return {
success: true,
size: stats.size,
mtime: stats.mtime,
isFile: stats.isFile(),
isDirectory: stats.isDirectory()
};
} catch (error) {
return { success: false, error: error.message };
}
});
// IPC handler for logging audit events
ipcMain.handle('log-audit-event', async (event, { directoryPath, action, details }) => {
try {
@ -906,4 +937,4 @@ app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});

53
preload.js Normal file
View File

@ -0,0 +1,53 @@
const { contextBridge, ipcRenderer } = require('electron');
const path = require('path');
const fakeIpcRenderer = {
invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
on: (channel, callback) => {
const listener = (_event, ...args) => callback(...args);
ipcRenderer.on(channel, listener);
return () => ipcRenderer.removeListener(channel, listener);
},
};
const fakeElectronModule = {
ipcRenderer: fakeIpcRenderer,
app: null,
BrowserWindow: null,
dialog: null,
ipcMain: null,
};
const fakePathModule = {
join: (...args) => path.join(...args),
dirname: (p) => path.dirname(p),
basename: (p) => path.basename(p),
extname: (p) => path.extname(p),
sep: path.sep,
resolve: (...args) => path.resolve(...args),
};
contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer: fakeIpcRenderer,
});
contextBridge.exposeInMainWorld('createModuleRequire', () => {
return function requirePolyfill(moduleName) {
if (moduleName === 'electron') {
return fakeElectronModule;
}
if (moduleName === 'path') {
return fakePathModule;
}
if (moduleName === 'fs') {
throw new Error('Direct fs access disabled. Use IPC.');
}
if (moduleName.startsWith('./')) {
const baseName = moduleName.replace('./', '').replace('.js', '');
const mod = window[baseName];
if (mod) return mod;
throw new Error(`Module not found: ${moduleName}`);
}
throw new Error(`Module not allowed: ${moduleName}`);
};
});

View File

@ -1,4 +1,13 @@
const UIManager = require('./utils/renderer/UIManager');
// UIManager is loaded via window.UIManager from index.html
const UIManager = window.UIManager;
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
window.escapeHtml = escapeHtml;
// Initialize the application when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
@ -47,4 +56,4 @@ document.addEventListener('DOMContentLoaded', () => {
console.log('UIManager initialized and exposed to window');
});
console.log('Movie Mapper renderer loaded');
console.log('Movie Mapper renderer loaded');

View File

@ -60,24 +60,37 @@ class FileListManager {
_createFolderElement(file) {
const fileItem = document.createElement('div');
fileItem.className = 'file-item folder-item';
fileItem.innerHTML = `
<div style="font-size: 18px; margin-right: 10px;">📁</div>
<div class="file-name folder-name" data-file-path="${file.path}">${file.name}</div>
<div class="file-duration"></div>
<div class="file-quality"></div>
<div class="file-fps"></div>
<div class="file-tags"></div>
`;
// Add click handler to navigate into folder
const icon = document.createElement('div');
icon.style.cssText = 'font-size: 18px; margin-right: 10px;';
icon.textContent = '\uD83D\uDCC1';
const nameEl = document.createElement('div');
nameEl.className = 'file-name folder-name';
nameEl.textContent = file.name;
const durationEl = document.createElement('div');
durationEl.className = 'file-duration';
const qualityEl = document.createElement('div');
qualityEl.className = 'file-quality';
const fpsEl = document.createElement('div');
fpsEl.className = 'file-fps';
const tagsEl = document.createElement('div');
tagsEl.className = 'file-tags';
fileItem.append(icon, nameEl, durationEl, qualityEl, fpsEl, tagsEl);
const filePath = file.path;
const fileName = file.name;
fileItem.addEventListener('click', (e) => {
// Don't trigger if clicking on any child elements
if (e.target !== fileItem && e.target.className !== 'file-name folder-name') {
return;
}
if (this.onFolderClick) {
this.onFolderClick(file.path, file.name);
this.onFolderClick(filePath, fileName);
}
});
@ -97,35 +110,91 @@ class FileListManager {
fileItem.dataset.index = index;
fileItem.dataset.filePath = file.path;
// Use actual duration from file metadata
const duration = file.duration || '00:00';
const quality = file.quality || 'unknown';
const fps = file.fps || 'unknown';
// Add label for problematic files
const problemLabel = file.isProblematic ? '<span class="problematic-label">⚠️</span> ' : '';
const dragHandle = document.createElement('div');
dragHandle.className = 'drag-handle';
dragHandle.textContent = '\u22EE\u22EE';
fileItem.innerHTML = `
<div class="drag-handle"></div>
<div class="episode-number-container">
<button class="episode-arrow episode-arrow-left" data-file-path="${file.path}" title="Decrease episode range"></button>
<div class="episode-number" data-episode-start="${episodeNumber}" data-episode-end="${episodeNumber}">${episodeNumber}</div>
<button class="episode-arrow episode-arrow-right" data-file-path="${file.path}" title="Increase episode range"></button>
</div>
<div class="file-name" data-file-path="${file.path}">${file.name}</div>
<div class="file-duration">${duration}</div>
<div class="file-quality">${quality}</div>
<div class="file-fps">${fps}</div>
<div class="file-tags">
<span class="tag-icon extra-tag" data-file-path="${file.path}" title="Mark as Extra">🏷</span>
<span class="tag-icon behind-the-scenes-tag" data-file-path="${file.path}" title="Add Behind the Scenes">🎥</span>
<span class="tag-icon delete-tag" data-file-path="${file.path}" title="Mark for Deletion">🗑</span>
<span class="video-preview-btn" data-file-path="${file.path}" title="Preview Video">🎬</span>
<button class="play-button" style="opacity: 0.3; cursor: default; flex-shrink: 0;" data-file-path="${file.path}" disabled></button>
</div>
`;
const episodeContainer = document.createElement('div');
episodeContainer.className = 'episode-number-container';
const leftArrow = document.createElement('button');
leftArrow.className = 'episode-arrow episode-arrow-left';
leftArrow.title = 'Decrease episode range';
leftArrow.textContent = '\u25C0';
const episodeNum = document.createElement('div');
episodeNum.className = 'episode-number';
episodeNum.dataset.episodeStart = episodeNumber;
episodeNum.dataset.episodeEnd = episodeNumber;
episodeNum.textContent = episodeNumber;
const rightArrow = document.createElement('button');
rightArrow.className = 'episode-arrow episode-arrow-right';
rightArrow.title = 'Increase episode range';
rightArrow.textContent = '\u25B6';
episodeContainer.append(leftArrow, episodeNum, rightArrow);
const nameEl = document.createElement('div');
nameEl.className = 'file-name';
nameEl.textContent = file.name;
if (file.isProblematic) {
const warn = document.createElement('span');
warn.className = 'problematic-label';
warn.textContent = '\u26A0\ufe0f';
nameEl.prepend(warn, ' ');
}
const durEl = document.createElement('div');
durEl.className = 'file-duration';
durEl.textContent = duration;
const qualEl = document.createElement('div');
qualEl.className = 'file-quality';
qualEl.textContent = quality;
const fpsEl = document.createElement('div');
fpsEl.className = 'file-fps';
fpsEl.textContent = fps;
const tagsEl = document.createElement('div');
tagsEl.className = 'file-tags';
const extraTag = document.createElement('span');
extraTag.className = 'tag-icon extra-tag';
extraTag.title = 'Mark as Extra';
extraTag.textContent = '\uD83C\uDFF7\uFE0F';
const btsTag = document.createElement('span');
btsTag.className = 'tag-icon behind-the-scenes-tag';
btsTag.title = 'Add Behind the Scenes';
btsTag.textContent = '\uD83C\uDFA5';
const delTag = document.createElement('span');
delTag.className = 'tag-icon delete-tag';
delTag.title = 'Mark for Deletion';
delTag.textContent = '\uD83D\uDDD1\uFE0F';
const previewBtn = document.createElement('span');
previewBtn.className = 'video-preview-btn';
previewBtn.title = 'Preview Video';
previewBtn.textContent = '\uD83C\uDFAC';
const playBtn = document.createElement('button');
playBtn.className = 'play-button';
playBtn.style.cssText = 'opacity: 0.3; cursor: default; flex-shrink: 0;';
playBtn.disabled = true;
playBtn.textContent = '\u25B6\uFE0F';
tagsEl.append(extraTag, btsTag, delTag, previewBtn, playBtn);
fileItem.append(dragHandle, episodeContainer, nameEl, durEl, qualEl, fpsEl, tagsEl);
// Add drag and drop event listeners
fileItem.addEventListener('dragstart', this._handleDragStart.bind(this));
fileItem.addEventListener('dragend', this._handleDragEnd.bind(this));
fileItem.addEventListener('dragover', this._handleDragOver.bind(this));

View File

@ -1,5 +1,4 @@
const { ipcRenderer } = require('electron');
const fs = require('fs');
const { ipcRenderer } = window.electronAPI;
/**
* ModalManager - Manages video preview modal
@ -142,50 +141,42 @@ class ModalManager {
const videoContainer = document.getElementById('video-preview-container');
const loadingIndicator = document.getElementById('video-preview-loading');
// Show loading
loadingIndicator.style.display = 'block';
videoContainer.innerHTML = '';
fileInfo.innerHTML = `<strong>Loading:</strong> ${filePath.split('/').pop()}`;
videoContainer.textContent = '';
const fileName = filePath.split('/').pop();
fileInfo.textContent = `Loading: ${fileName}`;
try {
// Check if file exists
if (!fs.existsSync(filePath)) {
const statsResult = await ipcRenderer.invoke('get-file-stats', filePath);
if (!statsResult.success) {
throw new Error('File not found');
}
// Get file stats for info
const stats = fs.statSync(filePath);
const fileName = filePath.split('/').pop();
fileInfo.textContent = `File: ${fileName} | Size: ${(statsResult.size / (1024 * 1024)).toFixed(2)} MB | Path: ${filePath}`;
// Update file info
fileInfo.innerHTML = `
<strong>File:</strong> ${fileName}<br>
<strong>Size:</strong> ${(stats.size / (1024 * 1024)).toFixed(2)} MB<br>
<strong>Path:</strong> ${filePath}
`;
// For MKV files, open directly in default player
const ext = filePath.toLowerCase().split('.').pop();
if (ext === 'mkv') {
// Close the modal and open in default player directly
this.videoPreviewModal.style.display = 'none';
ipcRenderer.invoke('open-file-in-player', filePath);
} else {
// For other formats, try to create a video player
videoContainer.innerHTML = `
<video id="preview-video" controls style="width: 100%; max-width: 800px; height: auto; margin: 0 auto; display: block;">
<source src="${filePath}" type="video/mp4">
Your browser does not support the video tag.
</video>
`;
// Hide loading
const video = document.createElement('video');
video.id = 'preview-video';
video.controls = true;
video.style.cssText = 'width: 100%; max-width: 800px; height: auto; margin: 0 auto; display: block;';
const source = document.createElement('source');
source.src = `file://${filePath}`;
source.type = 'video/mp4';
video.appendChild(source);
videoContainer.appendChild(video);
loadingIndicator.style.display = 'none';
}
} catch (error) {
console.error('Error loading video preview:', error);
fileInfo.innerHTML = `<strong>Error:</strong> Could not load preview for ${filePath}`;
videoContainer.innerHTML = '<p style="color: #dc3545; text-align: center;">Error loading video preview</p>';
fileInfo.textContent = 'Error: Could not load preview';
const p = document.createElement('p');
p.style.cssText = 'color: #dc3545; text-align: center;';
p.textContent = 'Error loading video preview';
videoContainer.appendChild(p);
loadingIndicator.style.display = 'none';
}
}

View File

@ -1,5 +1,5 @@
const { ipcRenderer } = require('electron');
const path = require('path');
const { ipcRenderer } = window.electronAPI;
const AppState = require('./AppState');
const FileListManager = require('./FileListManager');
const TagManager = require('./TagManager');
@ -417,7 +417,10 @@ class UIManager {
this.searchResultsEl.innerHTML = '';
if (results.length === 0) {
this.searchResultsEl.innerHTML = '<p style="color: #888; text-align: center; padding: 10px;">No shows found.</p>';
const p = document.createElement('p');
p.style.cssText = 'color: #888; text-align: center; padding: 10px;';
p.textContent = 'No shows found.';
this.searchResultsEl.appendChild(p);
return;
}
@ -425,14 +428,20 @@ class UIManager {
const resultItem = document.createElement('div');
resultItem.className = 'search-result-item';
// Create a more detailed display with separate elements
const showName = show.seriesName || show.name;
const showYear = show.firstAired ? show.firstAired.split('-')[0] : '';
resultItem.innerHTML = `
<div class="show-name">${showName}</div>
${showYear ? `<div class="show-year">${showYear}</div>` : ''}
`;
const nameEl = document.createElement('div');
nameEl.className = 'show-name';
nameEl.textContent = showName;
resultItem.appendChild(nameEl);
if (showYear) {
const yearEl = document.createElement('div');
yearEl.className = 'show-year';
yearEl.textContent = showYear;
resultItem.appendChild(yearEl);
}
resultItem.addEventListener('click', () => this.selectShow(show));
this.searchResultsEl.appendChild(resultItem);
@ -1609,60 +1618,74 @@ class UIManager {
const videoContainer = document.getElementById('video-preview-container');
const loadingIndicator = document.getElementById('video-preview-loading');
// Show loading
loadingIndicator.style.display = 'block';
if (videoContainer) videoContainer.innerHTML = '';
if (fileInfo) fileInfo.innerHTML = `<strong>Loading:</strong> ${filePath.split('/').pop()}`;
if (fileInfo) {
const fileName = filePath.split('/').pop();
const strong = document.createElement('strong');
strong.textContent = 'Loading: ';
fileInfo.textContent = '';
fileInfo.appendChild(strong);
fileInfo.appendChild(document.createTextNode(fileName));
}
try {
// Check if file exists
const fs = require('fs');
if (!fs.existsSync(filePath)) {
const statsResult = await ipcRenderer.invoke('get-file-stats', filePath);
if (!statsResult.success) {
throw new Error('File not found');
}
// Get file stats for info
const stats = fs.statSync(filePath);
const fileName = filePath.split('/').pop();
// Update file info
if (fileInfo) {
fileInfo.innerHTML = `
<strong>File:</strong> ${fileName}<br>
<strong>Size:</strong> ${(stats.size / (1024 * 1024)).toFixed(2)} MB<br>
<strong>Path:</strong> ${filePath}
`;
fileInfo.textContent = '';
const parts = [
{ bold: 'File: ', text: fileName },
{ bold: '\nSize: ', text: `${(statsResult.size / (1024 * 1024)).toFixed(2)} MB` },
{ bold: '\nPath: ', text: filePath }
];
parts.forEach(p => {
const strong = document.createElement('strong');
strong.textContent = p.bold;
fileInfo.appendChild(strong);
fileInfo.appendChild(document.createTextNode(p.text));
});
}
// For MKV files, open directly in default player
const ext = filePath.toLowerCase().split('.').pop();
if (ext === 'mkv') {
// Close the modal and open in default player directly
if (this.videoPreviewModal) {
this.videoPreviewModal.style.display = 'none';
}
ipcRenderer.invoke('open-file-in-player', filePath);
} else {
// For other formats, try to create a video player
if (videoContainer) {
videoContainer.innerHTML = `
<video id="preview-video" controls style="width: 100%; max-width: 800px; height: auto; margin: 0 auto; display: block;">
<source src="${filePath}" type="video/mp4">
Your browser does not support the video tag.
</video>
`;
const video = document.createElement('video');
video.id = 'preview-video';
video.controls = true;
video.style.cssText = 'width: 100%; max-width: 800px; height: auto; margin: 0 auto; display: block;';
const source = document.createElement('source');
source.src = `file://${filePath}`;
source.type = 'video/mp4';
video.appendChild(source);
videoContainer.appendChild(video);
}
// Hide loading
if (loadingIndicator) loadingIndicator.style.display = 'none';
}
} catch (error) {
console.error('Error loading video preview:', error);
if (fileInfo) {
fileInfo.innerHTML = `<strong>Error:</strong> Could not load preview for ${filePath}`;
fileInfo.textContent = '';
const strong = document.createElement('strong');
strong.textContent = 'Error: ';
fileInfo.appendChild(strong);
fileInfo.appendChild(document.createTextNode('Could not load preview'));
}
if (videoContainer) {
videoContainer.innerHTML = '<p style="color: #dc3545; text-align: center;">Error loading video preview</p>';
const p = document.createElement('p');
p.style.cssText = 'color: #dc3545; text-align: center;';
p.textContent = 'Error loading video preview';
videoContainer.appendChild(p);
}
if (loadingIndicator) loadingIndicator.style.display = 'none';
}
@ -1698,14 +1721,13 @@ class UIManager {
async handleFolderClick(folderPath, folderName) {
console.log('Folder clicked:', folderName, 'at path:', folderPath);
// Validate folder path exists
const fs = require('fs');
if (!fs.existsSync(folderPath) || !fs.statSync(folderPath).isDirectory()) {
// Validate folder path exists via IPC (no direct fs access)
const validation = await ipcRenderer.invoke('validate-folder', folderPath);
if (!validation.exists || !validation.isDirectory) {
console.error('Invalid folder path:', folderPath);
return; // Don't navigate
return;
}
// Open the folder
await this.openDirectory(folderPath);
}