Enable portable user data on all platforms

Portable mode now works on Windows, macOS, and Linux, storing user data in a 'user-data' folder within the app directory by default. The mode is enabled by default and can be disabled via NEBULA_PORTABLE=0. All file operations for history and search data now respect portable mode, using secure file writes where appropriate. Updated .gitignore and package.json to exclude portable data from versioning and packaging. Also fixed Bing icon filename case in home.html.
This commit is contained in:
2026-01-02 17:36:43 +13:00
parent 9c8b334133
commit 52a4d877a1
5 changed files with 198 additions and 68 deletions
+153 -58
View File
@@ -1,13 +1,15 @@
/**
* 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.
* Handles portable user data storage for all platforms (Windows, macOS, Linux).
* Data is stored in a 'user-data' folder within the application directory,
* keeping all user data local to the compiled project.
*
* Security considerations:
* - Data is stored with restricted permissions (0700 for directories, 0600 for files)
* - Data is stored with restricted permissions (0700 for directories, 0600 for files on Unix)
* - Path validation prevents directory traversal attacks
* - Only enabled when explicitly set via NEBULA_PORTABLE environment variable
* - Portable mode is enabled by default on all platforms
* - Can be disabled via NEBULA_PORTABLE=0 environment variable
*/
const { app } = require('electron');
@@ -23,37 +25,67 @@ class PortableDataManager {
}
/**
* 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)
* Get the application's root directory
* Works for both development and packaged builds
*/
_getAppRootDir() {
// In packaged app, process.resourcesPath points to resources folder
// We want the parent directory (the app folder itself)
if (app.isPackaged) {
// For packaged apps:
// - Windows: path\to\app\resources -> path\to\app
// - macOS: path/to/App.app/Contents/Resources -> path/to/App.app/Contents
// - Linux: path/to/app/resources -> path/to/app
const resourcesPath = process.resourcesPath;
if (process.platform === 'darwin') {
// On macOS, go up two levels from Resources to get to App.app parent folder
// But we want to store data inside the .app bundle's Contents folder for portability
return path.dirname(resourcesPath); // Contents folder
} else {
// Windows and Linux: go up one level from resources
return path.dirname(resourcesPath);
}
} else {
// Development mode: use __dirname (the directory containing main.js)
return __dirname;
}
}
/**
* Check if we're running in portable mode
* Portable mode is enabled by default on all platforms.
*
* Can be disabled by setting NEBULA_PORTABLE=0 or NEBULA_PORTABLE=false
* Can specify custom path via NEBULA_PORTABLE_PATH environment variable
*/
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 explicitly set to disable
const portableEnv = process.env.NEBULA_PORTABLE;
if (portableEnv !== undefined) {
const isDisabled = portableEnv === '0' ||
portableEnv.toLowerCase() === 'false' ||
portableEnv.toLowerCase() === 'no';
if (isDisabled) {
this._isPortable = false;
console.log('[Portable] Portable mode disabled via NEBULA_PORTABLE environment variable');
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;
// Portable mode is enabled by default on all platforms
this._isPortable = true;
return this._isPortable;
}
/**
* Get the portable data directory path
* Uses NEBULA_PORTABLE_PATH if set, otherwise returns null
* Uses NEBULA_PORTABLE_PATH if set, otherwise creates 'user-data' folder in app directory
*/
getPortableDataPath() {
if (this._portableDataPath !== null) {
@@ -65,21 +97,29 @@ class PortableDataManager {
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 '';
// First, check if custom path is provided via environment variable
const customPath = process.env.NEBULA_PORTABLE_PATH;
if (customPath) {
const resolvedPath = path.resolve(customPath);
if (this._isPathSafe(resolvedPath)) {
this._portableDataPath = resolvedPath;
console.log(`[Portable] Using custom portable path: ${resolvedPath}`);
return this._portableDataPath;
} else {
console.warn('[Portable] Custom path is unsafe, using default location');
}
}
// Validate and resolve the path
const resolvedPath = path.resolve(portablePath);
// Default: create 'user-data' folder in the application directory
const appRoot = this._getAppRootDir();
const dataPath = path.join(appRoot, 'user-data');
// Security: ensure path doesn't contain suspicious patterns
if (this._isPathSafe(resolvedPath)) {
this._portableDataPath = resolvedPath;
// Validate the path
if (this._isPathSafe(dataPath)) {
this._portableDataPath = dataPath;
console.log(`[Portable] Using portable data path: ${dataPath}`);
} else {
console.error('[Portable] Unsafe path detected, falling back to default');
console.error('[Portable] Default path is unsafe, falling back to system default');
this._portableDataPath = '';
}
@@ -113,7 +153,8 @@ class PortableDataManager {
this._ensureSecureDirectory(dataPath);
// Create subdirectories for organized storage
const subdirs = ['Cache', 'Cookies', 'Local Storage', 'Session Storage', 'IndexedDB'];
// Note: Don't create 'Cache', 'Cookies', 'Network' - Electron manages these internally
const subdirs = ['Local Storage', 'Session Storage', 'IndexedDB'];
for (const subdir of subdirs) {
this._ensureSecureDirectory(path.join(dataPath, subdir));
}
@@ -163,28 +204,40 @@ class PortableDataManager {
/**
* Ensure a directory exists with secure permissions
* On Unix systems (macOS, Linux), applies restricted permissions (0700)
* On Windows, creates directory with default permissions (ACLs handle security)
*/
_ensureSecureDirectory(dirPath) {
if (!fs.existsSync(dirPath)) {
// Create with restricted permissions (owner only: rwx------)
fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 });
if (process.platform === 'win32') {
// Windows: create directory with default permissions
// Windows ACLs handle security through inheritance
fs.mkdirSync(dirPath, { recursive: true });
} else {
// Unix (macOS, Linux): 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);
// Verify and fix permissions on existing directory (Unix only)
if (process.platform !== 'win32') {
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);
}
} catch (err) {
console.warn(`[Portable] Could not verify permissions for ${dirPath}:`, err.message);
}
}
}
/**
* Write a file with secure permissions
* On Unix systems, applies restricted permissions (0600)
* On Windows, writes with default permissions
*/
writeSecureFile(filePath, data) {
// Ensure parent directory exists with secure permissions
@@ -192,41 +245,81 @@ class PortableDataManager {
this._ensureSecureDirectory(dir);
// Write file
fs.writeFileSync(filePath, data, { mode: 0o600 });
if (process.platform === 'win32') {
fs.writeFileSync(filePath, data);
} else {
fs.writeFileSync(filePath, data, { mode: 0o600 });
}
}
/**
* Async version of secure file write
* On Unix systems, applies restricted permissions (0600)
* On Windows, writes with default permissions
*/
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 });
// Write file with restricted permissions (owner only: rw------- on Unix)
if (process.platform === 'win32') {
await fs.promises.writeFile(filePath, data);
} else {
await fs.promises.writeFile(filePath, data, { mode: 0o600 });
}
}
/**
* Validate path safety (prevent directory traversal)
* Works across Windows, macOS, and Linux
*/
_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;
}
// Check for directory traversal patterns
if (resolved.includes('..')) {
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;
// Platform-specific system path checks
if (process.platform === 'win32') {
// Windows: block system directories
const dangerousWin = [
'C:\\Windows',
'C:\\Program Files',
'C:\\Program Files (x86)',
'C:\\ProgramData'
];
const resolvedLower = resolved.toLowerCase();
for (const pattern of dangerousWin) {
if (resolvedLower.startsWith(pattern.toLowerCase())) {
return false;
}
}
} else if (process.platform === 'darwin') {
// macOS: block system directories
const dangerousMac = ['/System', '/Library', '/usr', '/bin', '/sbin', '/etc', '/var'];
for (const pattern of dangerousMac) {
if (resolved.startsWith(pattern) && !resolved.includes('.app')) {
return false;
}
}
} else {
// Linux: block system directories
const dangerousLinux = ['~root', '/etc', '/var/run', '/proc', '/sys', '/dev'];
for (const pattern of dangerousLinux) {
if (resolved.includes(pattern)) {
return false;
}
}
const systemPaths = ['/bin', '/sbin', '/usr/bin', '/usr/sbin', '/boot', '/lib', '/lib64'];
for (const sysPath of systemPaths) {
if (resolved.startsWith(sysPath)) {
return false;
}
}
}
@@ -257,8 +350,10 @@ class PortableDataManager {
return {
isPortable: this.isPortableMode(),
portablePath: this.getPortableDataPath(),
appRootDir: this._getAppRootDir(),
initialized: this._initialized,
platform: process.platform,
isPackaged: app.isPackaged,
envPortable: process.env.NEBULA_PORTABLE,
envPath: process.env.NEBULA_PORTABLE_PATH
};