diff --git a/main.js b/main.js index 0954e2e..0e7c338 100644 --- a/main.js +++ b/main.js @@ -9,6 +9,7 @@ const PerformanceMonitor = require('./performance-monitor'); const GPUFallback = require('./gpu-fallback'); const GPUConfig = require('./gpu-config'); const PluginManager = require('./plugin-manager'); +const portableData = require('./portable-data'); // Initialize performance monitoring and GPU management const perfMonitor = new PerformanceMonitor(); @@ -16,6 +17,39 @@ const gpuFallback = new GPUFallback(); const gpuConfig = new GPUConfig(); const pluginManager = new PluginManager(); +// Initialize portable data paths BEFORE app.ready (must be done early) +// This only affects Linux when NEBULA_PORTABLE=1 is set +portableData.initialize(); + +/** + * Get the path for a user data file (bookmarks, history, etc.) + * Uses portable path on Linux when in portable mode, otherwise uses __dirname + * @param {string} filename - The filename (e.g., 'bookmarks.json') + * @returns {string} The full path to the file + */ +function getDataFilePath(filename) { + const portablePath = portableData.getDataFilePath(filename); + if (portablePath) { + return portablePath; + } + return path.join(__dirname, filename); +} + +/** + * Get the directory path for user data files + * Uses portable path on Linux when in portable mode, otherwise uses __dirname + * @returns {string} The directory path + */ +function getDataDirPath() { + if (portableData.isPortableMode()) { + const portablePath = portableData.getPortableDataPath(); + if (portablePath) { + return portablePath; + } + } + return __dirname; +} + // Try to enable WebAuthn/platform authenticator features early. // This helps Chromium expose platform authenticators (Touch ID / built-in) where supported. try { @@ -733,7 +767,7 @@ ipcMain.handle('window-close', event => { // Site history is now handled via localStorage in the renderer // But keep these handlers for compatibility and potential future use ipcMain.handle('load-site-history', async () => { - const filePath = path.join(__dirname, 'site-history.json'); + const filePath = getDataFilePath('site-history.json'); try { const data = await fs.promises.readFile(filePath, 'utf-8'); return JSON.parse(data); @@ -743,9 +777,13 @@ ipcMain.handle('load-site-history', async () => { }); ipcMain.handle('save-site-history', async (event, history) => { - const filePath = path.join(__dirname, 'site-history.json'); + const filePath = getDataFilePath('site-history.json'); try { - await fs.promises.writeFile(filePath, JSON.stringify(history, null, 2)); + if (portableData.isPortableMode()) { + await portableData.writeSecureFileAsync(filePath, JSON.stringify(history, null, 2)); + } else { + await fs.promises.writeFile(filePath, JSON.stringify(history, null, 2)); + } return true; } catch (err) { return false; @@ -753,7 +791,7 @@ ipcMain.handle('save-site-history', async (event, history) => { }); ipcMain.handle('clear-site-history', async () => { - const filePath = path.join(__dirname, 'site-history.json'); + const filePath = getDataFilePath('site-history.json'); try { await fs.promises.writeFile(filePath, JSON.stringify([], null, 2)); return true; @@ -763,7 +801,7 @@ ipcMain.handle('clear-site-history', async () => { }); ipcMain.handle('load-search-history', async () => { - const filePath = path.join(__dirname, 'search-history.json'); + const filePath = getDataFilePath('search-history.json'); try { const data = await fs.promises.readFile(filePath, 'utf-8'); return JSON.parse(data); @@ -773,9 +811,13 @@ ipcMain.handle('load-search-history', async () => { }); ipcMain.handle('save-search-history', async (event, history) => { - const filePath = path.join(__dirname, 'search-history.json'); + const filePath = getDataFilePath('search-history.json'); try { - await fs.promises.writeFile(filePath, JSON.stringify(history, null, 2)); + if (portableData.isPortableMode()) { + await portableData.writeSecureFileAsync(filePath, JSON.stringify(history, null, 2)); + } else { + await fs.promises.writeFile(filePath, JSON.stringify(history, null, 2)); + } return true; } catch (err) { return false; @@ -817,7 +859,7 @@ ipcMain.on('set-display-scale', (event, scale) => { // Bookmark management ipcMain.handle('load-bookmarks', async () => { try { - const bookmarksPath = path.join(__dirname, 'bookmarks.json'); + const bookmarksPath = getDataFilePath('bookmarks.json'); try { await fs.promises.access(bookmarksPath); } catch { @@ -831,8 +873,8 @@ ipcMain.handle('load-bookmarks', async () => { } catch (error) { console.error('Error loading bookmarks:', error); // Try to create a backup if the file is corrupted - const bookmarksPath = path.join(__dirname, 'bookmarks.json'); - const backupPath = path.join(__dirname, `bookmarks.backup.${Date.now()}.json`); + const bookmarksPath = getDataFilePath('bookmarks.json'); + const backupPath = getDataFilePath(`bookmarks.backup.${Date.now()}.json`); try { await fs.promises.copyFile(bookmarksPath, backupPath); console.log(`Corrupted bookmarks file backed up to: ${backupPath}`); @@ -845,13 +887,18 @@ ipcMain.handle('load-bookmarks', async () => { ipcMain.handle('save-bookmarks', async (event, bookmarks) => { try { - const bookmarksPath = path.join(__dirname, 'bookmarks.json'); + const bookmarksPath = getDataFilePath('bookmarks.json'); try { await fs.promises.access(bookmarksPath); - const backupPath = path.join(__dirname, 'bookmarks.backup.json'); + const backupPath = getDataFilePath('bookmarks.backup.json'); await fs.promises.copyFile(bookmarksPath, backupPath); } catch {} - await fs.promises.writeFile(bookmarksPath, JSON.stringify(bookmarks, null, 2)); + // Use secure file writing in portable mode + if (portableData.isPortableMode()) { + await portableData.writeSecureFileAsync(bookmarksPath, JSON.stringify(bookmarks, null, 2)); + } else { + await fs.promises.writeFile(bookmarksPath, JSON.stringify(bookmarks, null, 2)); + } console.log(`Saved ${bookmarks.length} bookmarks to file`); return true; } catch (error) { @@ -886,8 +933,8 @@ ipcMain.handle('clear-browser-data', async () => { } // Also reset on-disk history JSON files managed by the app - const siteHistoryPath = path.join(__dirname, 'site-history.json'); - const searchHistoryPath = path.join(__dirname, 'search-history.json'); + const siteHistoryPath = getDataFilePath('site-history.json'); + const searchHistoryPath = getDataFilePath('search-history.json'); try { await fs.promises.writeFile(siteHistoryPath, JSON.stringify([], null, 2)); } catch {} try { await fs.promises.writeFile(searchHistoryPath, JSON.stringify([], null, 2)); } catch {} @@ -900,7 +947,7 @@ ipcMain.handle('clear-browser-data', async () => { // Optional: standalone clear for search history JSON ipcMain.handle('clear-search-history', async () => { - const filePath = path.join(__dirname, 'search-history.json'); + const filePath = getDataFilePath('search-history.json'); try { await fs.promises.writeFile(filePath, JSON.stringify([], null, 2)); return true; diff --git a/portable-data.js b/portable-data.js new file mode 100644 index 0000000..b0cf6c3 --- /dev/null +++ b/portable-data.js @@ -0,0 +1,270 @@ +/** + * Portable Data Manager for Nebula Browser + * + * Handles portable user data storage on Linux when running from extracted AppImage + * or portable installations. Does not affect Windows or macOS behavior. + * + * Security considerations: + * - Data is stored with restricted permissions (0700 for directories, 0600 for files) + * - Path validation prevents directory traversal attacks + * - Only enabled when explicitly set via NEBULA_PORTABLE environment variable + */ + +const { app } = require('electron'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +class PortableDataManager { + constructor() { + this._isPortable = null; + this._portableDataPath = null; + this._initialized = false; + } + + /** + * Check if we're running in portable mode on Linux + * Portable mode is determined by: + * 1. NEBULA_PORTABLE environment variable is set and truthy + * 2. NEBULA_PORTABLE_PATH environment variable provides the data path + * 3. Platform must be Linux (does not affect Windows or macOS) + */ + isPortableMode() { + if (this._isPortable !== null) { + return this._isPortable; + } + + // Only portable mode on Linux + if (process.platform !== 'linux') { + this._isPortable = false; + return false; + } + + // Check if NEBULA_PORTABLE is set and truthy + const portableEnv = process.env.NEBULA_PORTABLE; + const isTruthy = portableEnv && + (portableEnv === '1' || + portableEnv.toLowerCase() === 'true' || + portableEnv.toLowerCase() === 'yes'); + + this._isPortable = isTruthy; + return this._isPortable; + } + + /** + * Get the portable data directory path + * Uses NEBULA_PORTABLE_PATH if set, otherwise returns null + */ + getPortableDataPath() { + if (this._portableDataPath !== null) { + return this._portableDataPath; + } + + if (!this.isPortableMode()) { + this._portableDataPath = ''; + return ''; + } + + const portablePath = process.env.NEBULA_PORTABLE_PATH; + if (!portablePath) { + console.warn('[Portable] NEBULA_PORTABLE is set but NEBULA_PORTABLE_PATH is missing'); + this._portableDataPath = ''; + return ''; + } + + // Validate and resolve the path + const resolvedPath = path.resolve(portablePath); + + // Security: ensure path doesn't contain suspicious patterns + if (this._isPathSafe(resolvedPath)) { + this._portableDataPath = resolvedPath; + } else { + console.error('[Portable] Unsafe path detected, falling back to default'); + this._portableDataPath = ''; + } + + return this._portableDataPath; + } + + /** + * Initialize portable data directory with secure permissions + * Must be called before app.ready event + */ + initialize() { + if (this._initialized) { + return true; + } + + if (!this.isPortableMode()) { + console.log('[Portable] Not in portable mode, using default paths'); + this._initialized = true; + return true; + } + + const dataPath = this.getPortableDataPath(); + if (!dataPath) { + console.warn('[Portable] No valid portable path, using default paths'); + this._initialized = true; + return false; + } + + try { + // Create the data directory with secure permissions (owner only: rwx) + this._ensureSecureDirectory(dataPath); + + // Create subdirectories for organized storage + const subdirs = ['Cache', 'Cookies', 'Local Storage', 'Session Storage', 'IndexedDB']; + for (const subdir of subdirs) { + this._ensureSecureDirectory(path.join(dataPath, subdir)); + } + + // Set Electron's user data path to our portable location + // This must be done BEFORE app.ready event + app.setPath('userData', dataPath); + app.setPath('sessionData', dataPath); + + // Also redirect cache to be portable + const cachePath = path.join(dataPath, 'Cache'); + app.setPath('cache', cachePath); + + console.log(`[Portable] User data path set to: ${dataPath}`); + console.log(`[Portable] Cache path set to: ${cachePath}`); + + this._initialized = true; + return true; + } catch (err) { + console.error('[Portable] Failed to initialize portable data:', err); + this._initialized = true; + return false; + } + } + + /** + * Get the path for a data file (bookmarks, history, etc.) + * Returns portable path if in portable mode, otherwise returns __dirname path + */ + getDataFilePath(filename) { + // Validate filename to prevent directory traversal + if (!this._isFilenameSafe(filename)) { + throw new Error(`Invalid filename: ${filename}`); + } + + if (this.isPortableMode()) { + const portablePath = this.getPortableDataPath(); + if (portablePath) { + return path.join(portablePath, filename); + } + } + + // Fallback to __dirname (project directory) for non-portable or if portable not configured + // Note: In production, you might want to use app.getPath('userData') as fallback + return null; // Return null to indicate caller should use their default path + } + + /** + * Ensure a directory exists with secure permissions + */ + _ensureSecureDirectory(dirPath) { + if (!fs.existsSync(dirPath)) { + // Create with restricted permissions (owner only: rwx------) + fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 }); + console.log(`[Portable] Created secure directory: ${dirPath}`); + } else { + // Verify and fix permissions on existing directory + try { + const stats = fs.statSync(dirPath); + if (stats.isDirectory()) { + // Set secure permissions + fs.chmodSync(dirPath, 0o700); + } + } catch (err) { + console.warn(`[Portable] Could not verify permissions for ${dirPath}:`, err.message); + } + } + } + + /** + * Write a file with secure permissions + */ + writeSecureFile(filePath, data) { + // Ensure parent directory exists with secure permissions + const dir = path.dirname(filePath); + this._ensureSecureDirectory(dir); + + // Write file + fs.writeFileSync(filePath, data, { mode: 0o600 }); + } + + /** + * Async version of secure file write + */ + async writeSecureFileAsync(filePath, data) { + // Ensure parent directory exists with secure permissions + const dir = path.dirname(filePath); + this._ensureSecureDirectory(dir); + + // Write file with restricted permissions (owner only: rw-------) + await fs.promises.writeFile(filePath, data, { mode: 0o600 }); + } + + /** + * Validate path safety (prevent directory traversal) + */ + _isPathSafe(testPath) { + // Resolve to absolute path + const resolved = path.resolve(testPath); + + // Check for suspicious patterns + const dangerous = ['..', '~root', '/etc', '/var/run', '/proc', '/sys', '/dev']; + for (const pattern of dangerous) { + if (resolved.includes(pattern)) { + return false; + } + } + + // Ensure it's not trying to write to system directories + const systemPaths = ['/bin', '/sbin', '/usr/bin', '/usr/sbin', '/boot', '/lib', '/lib64']; + for (const sysPath of systemPaths) { + if (resolved.startsWith(sysPath)) { + return false; + } + } + + return true; + } + + /** + * Validate filename safety + */ + _isFilenameSafe(filename) { + // Check for directory traversal + if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) { + return false; + } + + // Check for hidden files that might be system files + if (filename.startsWith('.') && !filename.endsWith('.json')) { + return false; + } + + return true; + } + + /** + * Get status information for debugging + */ + getStatus() { + return { + isPortable: this.isPortableMode(), + portablePath: this.getPortableDataPath(), + initialized: this._initialized, + platform: process.platform, + envPortable: process.env.NEBULA_PORTABLE, + envPath: process.env.NEBULA_PORTABLE_PATH + }; + } +} + +// Export singleton instance +const portableDataManager = new PortableDataManager(); +module.exports = portableDataManager;