From d3bac698c196a731cb50b6e37538fe14e759f68a Mon Sep 17 00:00:00 2001
From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com>
Date: Wed, 12 Aug 2026 00:25:19 +0200
Subject: [PATCH] fix(security): remove plaintext credential storage fallback
Credential writes now require Electron safeStorage encryption. Legacy plaintext values remain readable for migration and are encrypted during the next successful save. The UI override and status IPC are removed.
---
README.md | 2 +-
lib/config-store.js | 13 ++++++------
lib/secret-store.js | 13 +++---------
main.js | 3 ---
package-lock.json | 4 ++--
package.json | 2 +-
preload.js | 1 -
renderer/app.js | 25 ----------------------
renderer/i18n.js | 7 -------
renderer/styles.css | 10 ---------
tests/config-store.test.js | 42 ++++++++++++++++++++++++++++++++++---
tests/remote-config.test.js | 22 +++++++++++++++++--
tests/secret-store.test.js | 31 ++++++++++++++-------------
tests/ui-smoke.js | 2 ++
14 files changed, 90 insertions(+), 87 deletions(-)
diff --git a/README.md b/README.md
index 3b446ce..248f092 100644
--- a/README.md
+++ b/README.md
@@ -159,7 +159,7 @@ English is the default language for new profiles. Open **Settings > General** to
### Local data and credentials
-Settings, pending queue state, and upload history are stored in Electron's user-data directory. Passwords and API keys are encrypted with Electron `safeStorage` before they are written when operating-system encryption is available and encryption succeeds. On Windows, `safeStorage` uses DPAPI and ties encrypted values to the current Windows user profile. If operating-system encryption is unavailable or encryption fails, passwords and API keys are stored as plaintext on disk.
+Settings, pending queue state, and upload history are stored in Electron's user-data directory. Passwords and API keys must be encrypted with Electron `safeStorage` before they are written. On Windows, `safeStorage` uses DPAPI and ties encrypted values to the current Windows user profile. If operating-system encryption is unavailable or encryption fails, the write is rejected and credentials are never written as plaintext. Legacy plaintext credentials remain readable for migration and are encrypted during the next successful save.
Credentials are decrypted when required for account validation or upload to the selected host. Do not share application data files, backup files, screenshots containing credentials, or generated backup keys.
diff --git a/lib/config-store.js b/lib/config-store.js
index 6cf54cf..fbd4b2d 100644
--- a/lib/config-store.js
+++ b/lib/config-store.js
@@ -73,7 +73,6 @@ const DEFAULTS = {
// load() sets logMode after the merge, looking at the saved-only data.
resumeQueueOnLaunch: true,
autoStartRestoredQueue: false,
- allowPlaintextCredentialStorage: false,
parallelUploadCount: 0, // 0 = use per-hoster limits only
scaleParallelUploads: false,
lastBrowseDirectory: '',
@@ -182,7 +181,7 @@ function applyHistoryRetention(history, retention, nowMs) {
}
class ConfigStore {
- constructor(app, options = {}) {
+ constructor(app) {
const useUserDataDir = app && (
app.isPackaged ||
(app.commandLine && typeof app.commandLine.hasSwitch === 'function' && app.commandLine.hasSwitch('user-data-dir'))
@@ -201,7 +200,6 @@ class ConfigStore {
this._cacheKey = '';
this._perfLog = null;
this._wqDepth = 0;
- this._allowPlaintextCredentialStorage = options.allowPlaintextCredentialStorage === true;
// Migrate config from old location if current doesn't exist
if (!fs.existsSync(this.filePath) && app && app.isPackaged) {
@@ -448,6 +446,7 @@ class ConfigStore {
...DEFAULTS.globalSettings,
...savedGlobal
};
+ delete globalSettings.allowPlaintextCredentialStorage;
// Deep-merge nested objects so new keys are always present
for (const key of Object.keys(DEFAULTS.globalSettings)) {
const def = DEFAULTS.globalSettings[key];
@@ -487,10 +486,10 @@ class ConfigStore {
// on every write was a primary long-running main-thread stall.
_serializeForDisk(config) {
const hosters = this._clone(config.hosters || {});
- secretStore.encryptCredentials({ hosters }, {
- allowPlaintext: this._allowPlaintextCredentialStorage || config.globalSettings?.allowPlaintextCredentialStorage === true
- });
- return JSON.stringify({ ...config, hosters }, null, 2);
+ const globalSettings = this._clone(config.globalSettings || {});
+ delete globalSettings.allowPlaintextCredentialStorage;
+ secretStore.encryptCredentials({ hosters });
+ return JSON.stringify({ ...config, globalSettings, hosters }, null, 2);
}
_commit(config) {
diff --git a/lib/secret-store.js b/lib/secret-store.js
index 0393431..6179cad 100644
--- a/lib/secret-store.js
+++ b/lib/secret-store.js
@@ -38,23 +38,17 @@ function isEncrypted(value) {
return typeof value === 'string' && value.startsWith(SENTINEL);
}
-function getAvailabilityStatus() {
- return getSafeStorage() ? 'available' : 'unavailable';
-}
-
-function encryptField(value, options = {}) {
+function encryptField(value) {
if (!value || typeof value !== 'string') return value;
if (isEncrypted(value)) return value;
const ss = getSafeStorage();
if (!ss) {
- if (options.allowPlaintext === true) return value;
throw new SecretStoreError('SECRET_STORE_UNAVAILABLE', 'Sicherer Zugangsdaten-Speicher ist nicht verfügbar');
}
try {
const buf = ss.encryptString(value);
return SENTINEL + buf.toString('base64');
} catch (cause) {
- if (options.allowPlaintext === true) return value;
throw new SecretStoreError('SECRET_STORE_ENCRYPT_FAILED', 'Zugangsdaten konnten nicht sicher verschlüsselt werden', cause);
}
}
@@ -88,14 +82,13 @@ function mapHosterAccounts(config, fn) {
return config;
}
-function encryptCredentials(config, options = {}) {
- return mapHosterAccounts(config, value => encryptField(value, options));
+function encryptCredentials(config) {
+ return mapHosterAccounts(config, encryptField);
}
function decryptCredentials(config) { return mapHosterAccounts(config, decryptField); }
module.exports = {
SecretStoreError,
- getAvailabilityStatus,
encryptField,
decryptField,
encryptCredentials,
diff --git a/main.js b/main.js
index bbea7bb..8d4ebe5 100644
--- a/main.js
+++ b/main.js
@@ -7,7 +7,6 @@ nativeTheme.themeSource = 'dark';
const path = require('path');
const fs = require('fs');
const ConfigStore = require('./lib/config-store');
-const secretStore = require('./lib/secret-store');
const UploadManager = require('./lib/upload-manager');
const { createSourceFileCleanup } = require('./lib/source-file-cleanup');
const SourceDeleteJournal = require('./lib/source-delete-journal');
@@ -1699,8 +1698,6 @@ ipcMain.handle('get-config', () => {
return configStore.load();
});
-ipcMain.handle('secret-store:status', () => ({ status: secretStore.getAvailabilityStatus() }));
-
ipcMain.handle('save-config', async (_event, config) => {
assertConfigWriteAllowed();
await configStore.save(config);
diff --git a/package-lock.json b/package-lock.json
index 96419de..47b0383 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "multi-hoster-uploader",
- "version": "2.1.12",
+ "version": "2.1.13",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "multi-hoster-uploader",
- "version": "2.1.12",
+ "version": "2.1.13",
"dependencies": {
"chokidar": "^3.6.0",
"undici": "^7.29.0",
diff --git a/package.json b/package.json
index 3fa1bfc..37bc1ab 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "multi-hoster-uploader",
- "version": "2.1.12",
+ "version": "2.1.13",
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
"main": "main.js",
"scripts": {
diff --git a/preload.js b/preload.js
index f4d1ca8..900f483 100644
--- a/preload.js
+++ b/preload.js
@@ -4,7 +4,6 @@ contextBridge.exposeInMainWorld('api', {
// Config
getConfig: () => ipcRenderer.invoke('get-config'),
saveConfig: (config) => ipcRenderer.invoke('save-config', config),
- getSecretStoreStatus: () => ipcRenderer.invoke('secret-store:status'),
getHistory: () => ipcRenderer.invoke('get-history'),
clearHistory: () => ipcRenderer.invoke('clear-history'),
pruneHistory: (retention, opts) => ipcRenderer.invoke('prune-history', { retention, dryRun: !!(opts && opts.dryRun) }),
diff --git a/renderer/app.js b/renderer/app.js
index c37df32..c6afb00 100644
--- a/renderer/app.js
+++ b/renderer/app.js
@@ -4255,14 +4255,6 @@ function renderSettings() {
-
-
-
- Nur verwenden, wenn der sichere Betriebssystem-Speicher dauerhaft nicht verfügbar ist. Passwörter und API-Keys liegen dann lesbar in der Konfigurationsdatei.
- Prüfe…
-
-
-
Programmupdate
@@ -4870,13 +4862,6 @@ function renderSettings() {
document.getElementById('chooseLogFilePathBtn')?.addEventListener('click', chooseLogFilePath);
document.getElementById('openLogFolderBtn')?.addEventListener('click', () => window.api.openLogFolder());
document.getElementById('manualUpdateCheckBtn')?.addEventListener('click', requestUpdateCheck);
- window.api.getSecretStoreStatus().then(result => {
- const badge = document.getElementById('secretStoreStatus');
- if (!badge) return;
- const available = result?.status === 'available';
- badge.textContent = available ? 'Sicher verfügbar' : 'Nicht verfügbar';
- badge.classList.toggle('active', available);
- }).catch(() => {});
_syncHeaderUpdateState();
container.querySelectorAll('.settings-autosave').forEach((input) => {
const eventName = input.type === 'checkbox' || input.tagName === 'SELECT' ? 'change' : 'input';
@@ -4894,15 +4879,6 @@ function renderSettings() {
});
if (!confirmed) input.checked = false;
}
- if (input.id === 'allowPlaintextCredentialStorageInput' && input.checked) {
- const confirmed = await showAppConfirm({
- title: 'Zugangsdaten unverschlüsselt speichern?',
- message: 'Passwörter und API-Keys werden dann im Klartext auf diesem PC gespeichert. Andere Benutzer oder Programme mit Dateizugriff können sie lesen.',
- confirmText: 'Klartext-Speicherung erlauben',
- danger: true
- });
- if (!confirmed) input.checked = false;
- }
markSettingsDirty();
});
});
@@ -5028,7 +5004,6 @@ async function performSaveSettings(options = {}) {
})(),
resumeQueueOnLaunch: elChk('resumeQueueOnLaunchInput', cur.resumeQueueOnLaunch !== false),
autoStartRestoredQueue: elChk('autoStartRestoredQueueInput', !!cur.autoStartRestoredQueue),
- allowPlaintextCredentialStorage: elChk('allowPlaintextCredentialStorageInput', !!cur.allowPlaintextCredentialStorage),
parallelUploadCount: elInt('parallelUploadCountInput', cur.parallelUploadCount ?? 0, 0, 0, 100),
scaleParallelUploads: elChk('scaleParallelUploadsInput', !!cur.scaleParallelUploads),
removeFromQueueOnDone: elChk('removeFromQueueOnDoneInput', !!cur.removeFromQueueOnDone),
diff --git a/renderer/i18n.js b/renderer/i18n.js
index 66f4a4e..cdc0d4e 100644
--- a/renderer/i18n.js
+++ b/renderer/i18n.js
@@ -474,13 +474,6 @@
['Zugangsdaten konnten nicht sicher verschlüsselt werden', 'Credentials could not be encrypted securely'],
['Gespeicherte Zugangsdaten konnten nicht entschlüsselt werden', 'Stored credentials could not be decrypted'],
['Zugangsdaten gesperrt', 'Credentials locked'],
- ['Unsichere Klartext-Speicherung erlauben', 'Allow insecure plaintext storage'],
- ['Nur verwenden, wenn der sichere Betriebssystem-Speicher dauerhaft nicht verfügbar ist. Passwörter und API-Keys liegen dann lesbar in der Konfigurationsdatei.', 'Use only when secure operating system storage is permanently unavailable. Passwords and API keys will then be readable in the configuration file.'],
- ['Sicher verfügbar', 'Securely available'],
- ['Nicht verfügbar', 'Unavailable'],
- ['Zugangsdaten unverschlüsselt speichern?', 'Store credentials without encryption?'],
- ['Passwörter und API-Keys werden dann im Klartext auf diesem PC gespeichert. Andere Benutzer oder Programme mit Dateizugriff können sie lesen.', 'Passwords and API keys will then be stored as plaintext on this PC. Other users or programs with file access can read them.'],
- ['Klartext-Speicherung erlauben', 'Allow plaintext storage'],
['Die Verlaufsdatei ist beschädigt und wurde nicht verändert', 'The history file is damaged and was not changed'],
['Verlauf und Aufbewahrung konnten nicht konsistent gespeichert werden', 'History and retention could not be saved consistently'],
['Kein Ordnerpfad angegeben', 'No folder path was provided'],
diff --git a/renderer/styles.css b/renderer/styles.css
index 21066e3..d1f45dd 100644
--- a/renderer/styles.css
+++ b/renderer/styles.css
@@ -3318,16 +3318,6 @@ input[type="checkbox"] {
border-color: var(--danger);
}
-.settings-option.credential-fallback-option {
- margin-top: 12px;
- border-color: color-mix(in srgb, var(--danger) 72%, var(--border));
- box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--danger) 10%, transparent);
-}
-
-.settings-option.credential-fallback-option:focus-within {
- border-color: var(--danger);
-}
-
.settings-option-description,
.hint {
color: var(--text-dim);
diff --git a/tests/config-store.test.js b/tests/config-store.test.js
index 0db1f42..0f4c97d 100644
--- a/tests/config-store.test.js
+++ b/tests/config-store.test.js
@@ -3,7 +3,20 @@ const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
+const Module = require('node:module');
+const originalLoad = Module._load;
+const safeStorage = {
+ isEncryptionAvailable: () => true,
+ encryptString: value => Buffer.from(`test-protected:${value}`),
+ decryptString: value => value.toString().replace(/^test-protected:/, '')
+};
+Module._load = function load(request, parent, isMain) {
+ if (request === 'electron') return { safeStorage };
+ return originalLoad.call(this, request, parent, isMain);
+};
const ConfigStore = require('../lib/config-store');
+require('../lib/secret-store').encryptField('test-initialization');
+Module._load = originalLoad;
let tmpDir;
let store;
@@ -15,7 +28,7 @@ function createStore() {
};
// ConfigStore uses path.join(__dirname, '..') for non-packaged
// We override by setting filePath directly
- store = new ConfigStore(fakeApp, { allowPlaintextCredentialStorage: true });
+ store = new ConfigStore(fakeApp);
store.filePath = path.join(tmpDir, 'electron-config.json');
store.historyPath = path.join(tmpDir, 'electron-history.json');
return store;
@@ -46,7 +59,7 @@ describe('ConfigStore', () => {
if (name === 'exe') return path.join(isolatedDir, 'Multi-Hoster-Upload.exe');
throw new Error(`Unexpected app path: ${name}`);
}
- }, { allowPlaintextCredentialStorage: true });
+ });
try {
assert.equal(explicitStore.filePath, path.join(isolatedDir, 'electron-config.json'));
@@ -76,6 +89,7 @@ describe('ConfigStore', () => {
assert.equal(config.globalSettings.shutdownAfterFinish, 'nothing');
assert.equal(config.globalSettings.logFilePath, '');
assert.equal(config.globalSettings.resumeQueueOnLaunch, true);
+ assert.equal(Object.hasOwn(config.globalSettings, 'allowPlaintextCredentialStorage'), false);
assert.equal(config.globalSettings.parallelUploadCount, 0);
assert.equal(config.globalSettings.scaleParallelUploads, false);
assert.equal(config.globalSettings.lastBrowseDirectory, '');
@@ -83,6 +97,28 @@ describe('ConfigStore', () => {
assert.deepEqual(config.history, []);
});
+ it('drops the retired plaintext credential setting from legacy configurations', () => {
+ fs.writeFileSync(store.filePath, JSON.stringify({
+ hosters: {},
+ hosterSettings: {},
+ globalSettings: { allowPlaintextCredentialStorage: true }
+ }), 'utf-8');
+
+ const config = store.load();
+
+ assert.equal(Object.hasOwn(config.globalSettings, 'allowPlaintextCredentialStorage'), false);
+ });
+
+ it('never writes the retired plaintext credential setting back to disk', async () => {
+ await store.save({
+ globalSettings: { allowPlaintextCredentialStorage: true }
+ });
+
+ const saved = JSON.parse(fs.readFileSync(store.filePath, 'utf-8'));
+
+ assert.equal(Object.hasOwn(saved.globalSettings, 'allowPlaintextCredentialStorage'), false);
+ });
+
it('keeps permanent source deletion disabled by default', () => {
const config = store.load();
assert.equal(config.globalSettings.deleteSourceAfterSuccessfulUpload, false);
@@ -579,7 +615,7 @@ describe('ConfigStore history split (electron-history.json)', () => {
let s;
function makeStore() {
- const st = new ConfigStore({ isPackaged: false, getPath: () => dir }, { allowPlaintextCredentialStorage: true });
+ const st = new ConfigStore({ isPackaged: false, getPath: () => dir });
st.filePath = path.join(dir, 'electron-config.json');
st.historyPath = path.join(dir, 'electron-history.json');
return st;
diff --git a/tests/remote-config.test.js b/tests/remote-config.test.js
index efaabf9..e626a8f 100644
--- a/tests/remote-config.test.js
+++ b/tests/remote-config.test.js
@@ -3,6 +3,25 @@ const assert = require('node:assert');
const path = require('path');
const fs = require('fs');
const os = require('os');
+const Module = require('node:module');
+
+const originalLoad = Module._load;
+Module._load = function load(request, parent, isMain) {
+ if (request === 'electron') {
+ return {
+ safeStorage: {
+ isEncryptionAvailable: () => true,
+ encryptString: value => Buffer.from(`test-protected:${value}`),
+ decryptString: value => value.toString().replace(/^test-protected:/, '')
+ }
+ };
+ }
+ return originalLoad.call(this, request, parent, isMain);
+};
+
+const ConfigStore = require('../lib/config-store');
+require('../lib/secret-store').encryptField('test-initialization');
+Module._load = originalLoad;
// Minimal app mock for ConfigStore
function createTestConfigStore() {
@@ -12,8 +31,7 @@ function createTestConfigStore() {
getPath: (name) => tmpDir,
getPath: () => tmpDir
};
- const ConfigStore = require('../lib/config-store');
- const store = new ConfigStore(mockApp, { allowPlaintextCredentialStorage: true });
+ const store = new ConfigStore(mockApp);
store.filePath = path.join(tmpDir, 'test-config.json');
return { store, tmpDir };
}
diff --git a/tests/secret-store.test.js b/tests/secret-store.test.js
index 3749bc8..5bf802b 100644
--- a/tests/secret-store.test.js
+++ b/tests/secret-store.test.js
@@ -28,15 +28,6 @@ function availableSafeStorage(overrides = {}) {
};
}
-test('reports whether secure credential storage is available', () => {
- withSecretStore(availableSafeStorage(), secretStore => {
- assert.equal(secretStore.getAvailabilityStatus(), 'available');
- });
- withSecretStore(null, secretStore => {
- assert.equal(secretStore.getAvailabilityStatus(), 'unavailable');
- });
-});
-
test('encrypts and decrypts fields when secure storage is available', () => {
withSecretStore(availableSafeStorage(), secretStore => {
const encrypted = secretStore.encryptField('secret');
@@ -55,13 +46,18 @@ test('refuses plaintext storage by default when secure storage is unavailable',
});
});
-test('allows plaintext storage only through an explicit opt-in', () => {
+test('never allows plaintext storage when secure storage is unavailable', () => {
withSecretStore(null, secretStore => {
- assert.equal(secretStore.encryptField('secret', { allowPlaintext: true }), 'secret');
+ assert.throws(
+ () => secretStore.encryptField('secret', { allowPlaintext: true }),
+ error => error instanceof secretStore.SecretStoreError
+ && error.code === 'SECRET_STORE_UNAVAILABLE'
+ );
const config = { hosters: { example: [{ password: 'secret' }] } };
- assert.equal(
- secretStore.encryptCredentials(config, { allowPlaintext: true }).hosters.example[0].password,
- 'secret'
+ assert.throws(
+ () => secretStore.encryptCredentials(config, { allowPlaintext: true }),
+ error => error instanceof secretStore.SecretStoreError
+ && error.code === 'SECRET_STORE_UNAVAILABLE'
);
});
});
@@ -75,7 +71,12 @@ test('refuses plaintext storage by default when encryption fails', () => {
&& error.code === 'SECRET_STORE_ENCRYPT_FAILED'
&& error.cause === failure
);
- assert.equal(secretStore.encryptField('secret', { allowPlaintext: true }), 'secret');
+ assert.throws(
+ () => secretStore.encryptField('secret', { allowPlaintext: true }),
+ error => error instanceof secretStore.SecretStoreError
+ && error.code === 'SECRET_STORE_ENCRYPT_FAILED'
+ && error.cause === failure
+ );
});
});
diff --git a/tests/ui-smoke.js b/tests/ui-smoke.js
index 613d4c7..9c83c9c 100644
--- a/tests/ui-smoke.js
+++ b/tests/ui-smoke.js
@@ -1031,6 +1031,8 @@ setTimeout(async () => {
await wc.executeJavaScript('document.querySelector("[data-settings-page=\\'uploads\\']")?.click()');
const uploadSettingsState = await wc.executeJavaScript('(() => { const activePage = document.querySelector(".settings-subpage.active"); return [activePage?.dataset.subpage, activePage?.querySelector("h3")?.textContent.trim(), document.querySelector("label[for=removeFromQueueOnDoneInput]")?.textContent.trim(), document.getElementById("removeFromQueueOnDoneInput")?.closest(".settings-option")?.querySelector(".settings-option-description")?.textContent.trim()].join("|"); })()');
check('Upload completion behavior is immediately findable', uploadSettingsState === 'uploads|Upload-Verhalten|Nach Abschluss aus der Liste entfernen|Erfolgreich hochgeladene Dateien verschwinden automatisch aus der Upload-Liste.');
+ const plaintextCredentialOverride = await wc.executeJavaScript('(() => ({ control: document.getElementById("allowPlaintextCredentialStorageInput"), copy: document.body.textContent.includes("Unsichere Klartext-Speicherung"), bridge: typeof window.api.getSecretStoreStatus }))()');
+ check('Settings expose no plaintext credential storage override', plaintextCredentialOverride.control === null && plaintextCredentialOverride.copy === false && plaintextCredentialOverride.bridge === 'undefined');
const settingsTypography = await wc.executeJavaScript('(() => { const size = selector => parseFloat(getComputedStyle(document.querySelector(selector)).fontSize); return { heading: size(".settings-subpage.active .settings-page-header h3"), intro: size(".settings-subpage.active .settings-page-header p"), section: size(".settings-subpage.active .settings-section-label"), rowLabel: size(".settings-subpage.active .settings-row > label"), hint: size(".settings-subpage.active .hint"), optionLabel: size(".settings-subpage.active .settings-option-copy label"), optionDescription: size(".settings-subpage.active .settings-option-description"), navigation: size(".settings-nav-button"), search: size("#settingsSearchInput") }; })()');
check('Settings use the enlarged readable typography scale', settingsTypography.heading >= 22 && settingsTypography.intro >= 14 && settingsTypography.section >= 12 && settingsTypography.rowLabel >= 14 && settingsTypography.hint >= 12 && settingsTypography.optionLabel >= 14 && settingsTypography.optionDescription >= 12 && settingsTypography.navigation >= 13 && settingsTypography.search >= 13);
const settingsSelection = await wc.executeJavaScript('(() => ({ heading: getComputedStyle(document.querySelector(".settings-subpage.active .settings-page-header h3")).userSelect, hint: getComputedStyle(document.querySelector(".settings-subpage.active .hint")).userSelect, input: getComputedStyle(document.getElementById("globalMaxSpeedMbsInput")).userSelect }))()');