Skip to content

Commit 0306741

Browse files
author
bittoby
committed
refactor: async credential I/O, dropdown filtering, remove email masking
1 parent cb94696 commit 0306741

4 files changed

Lines changed: 209 additions & 87 deletions

File tree

electron/main/index.ts

Lines changed: 127 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2310,11 +2310,14 @@ function registerIpcHandlers() {
23102310
});
23112311

23122312
// ==================== saved accounts handler ====================
2313+
// Security: use OS-backed encryption (safeStorage), strict validation, size limits,
2314+
// and generic error messages to avoid leaking paths or internal details.
23132315
type SavedAccount = {
23142316
email: string;
23152317
token: string;
23162318
username: string;
23172319
user_id: number;
2320+
password: string;
23182321
};
23192322

23202323
const ACCOUNTS_FILE = path.join(
@@ -2323,25 +2326,89 @@ function registerIpcHandlers() {
23232326
'saved_accounts.enc'
23242327
);
23252328

2329+
const SAVED_ACCOUNTS_MAX = 20;
2330+
const SAVED_ACCOUNTS_MAX_FILE_BYTES = 1024 * 1024; // 1 MB max encrypted file
2331+
const SAVED_ACCOUNTS_MAX_DECRYPTED_BYTES = 512 * 1024; // 512 KB max decrypted JSON
2332+
const EMAIL_MAX_LENGTH = 320;
2333+
const PASSWORD_MAX_LENGTH = 2048;
2334+
const TOKEN_MAX_LENGTH = 4096;
2335+
const USERNAME_MAX_LENGTH = 256;
2336+
const CREDENTIALS_GENERIC_ERROR = 'Operation failed';
2337+
2338+
const trimEmail = (value: unknown): string => {
2339+
if (typeof value !== 'string') return '';
2340+
return value.trim().slice(0, EMAIL_MAX_LENGTH);
2341+
};
2342+
2343+
/** Normalize email for storage and comparison (lowercase, trimmed). */
2344+
const normalizeEmail = (value: unknown): string =>
2345+
trimEmail(value).toLowerCase();
2346+
2347+
const isValidEmailFormat = (email: string): boolean =>
2348+
email.length > 0 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
2349+
2350+
const sanitizeString = (value: unknown, maxLen: number): string =>
2351+
typeof value === 'string' ? value.slice(0, maxLen) : '';
2352+
23262353
const readSavedAccounts = async (): Promise<SavedAccount[]> => {
23272354
try {
23282355
if (!safeStorage.isEncryptionAvailable()) {
23292356
return [];
23302357
}
23312358
const encrypted = await fsp.readFile(ACCOUNTS_FILE).catch(() => null);
2332-
if (!encrypted) return [];
2359+
if (!encrypted || encrypted.length === 0) return [];
2360+
if (encrypted.length > SAVED_ACCOUNTS_MAX_FILE_BYTES) {
2361+
return [];
2362+
}
23332363
const decrypted = safeStorage.decryptString(encrypted);
2334-
const parsed = JSON.parse(decrypted);
2335-
// Filter out any entries from the old password-based format.
2336-
// Only email and token are required; user_id may be string or number
2337-
// depending on what the login API actually returns at runtime.
2338-
return (Array.isArray(parsed) ? parsed : []).filter(
2339-
(a) =>
2340-
typeof a.email === 'string' &&
2341-
a.email.length > 0 &&
2342-
typeof a.token === 'string' &&
2343-
a.token.length > 0
2344-
);
2364+
if (
2365+
typeof decrypted !== 'string' ||
2366+
decrypted.length > SAVED_ACCOUNTS_MAX_DECRYPTED_BYTES
2367+
) {
2368+
return [];
2369+
}
2370+
const parsed: unknown = JSON.parse(decrypted);
2371+
if (!Array.isArray(parsed)) return [];
2372+
const result: SavedAccount[] = [];
2373+
const seenEmails = new Set<string>();
2374+
for (const a of parsed) {
2375+
if (!a || typeof a !== 'object') continue;
2376+
const rawEmail = trimEmail((a as Record<string, unknown>).email);
2377+
if (!rawEmail || !isValidEmailFormat(rawEmail)) continue;
2378+
const email = rawEmail.toLowerCase();
2379+
if (seenEmails.has(email)) continue;
2380+
seenEmails.add(email);
2381+
const token = sanitizeString(
2382+
(a as Record<string, unknown>).token,
2383+
TOKEN_MAX_LENGTH
2384+
);
2385+
const password = sanitizeString(
2386+
(a as Record<string, unknown>).password,
2387+
PASSWORD_MAX_LENGTH
2388+
);
2389+
if (token.length === 0 && password.length === 0) continue;
2390+
const username = sanitizeString(
2391+
(a as Record<string, unknown>).username,
2392+
USERNAME_MAX_LENGTH
2393+
);
2394+
const rawId = (a as Record<string, unknown>).user_id;
2395+
const user_id =
2396+
typeof rawId === 'number' && Number.isFinite(rawId)
2397+
? rawId
2398+
: typeof rawId === 'string'
2399+
? parseInt(rawId, 10)
2400+
: 0;
2401+
if (!Number.isFinite(user_id) || user_id < 0) continue;
2402+
result.push({
2403+
email,
2404+
token,
2405+
username,
2406+
user_id: user_id as number,
2407+
password,
2408+
});
2409+
if (result.length >= SAVED_ACCOUNTS_MAX) break;
2410+
}
2411+
return result;
23452412
} catch {
23462413
return [];
23472414
}
@@ -2351,9 +2418,10 @@ function registerIpcHandlers() {
23512418
if (!safeStorage.isEncryptionAvailable()) {
23522419
throw new Error('Encryption not available');
23532420
}
2421+
const toWrite = accounts.slice(0, SAVED_ACCOUNTS_MAX);
23542422
const dir = path.dirname(ACCOUNTS_FILE);
2355-
await fsp.mkdir(dir, { recursive: true });
2356-
const encrypted = safeStorage.encryptString(JSON.stringify(accounts));
2423+
await fsp.mkdir(dir, { recursive: true, mode: 0o700 });
2424+
const encrypted = safeStorage.encryptString(JSON.stringify(toWrite));
23572425
await fsp.writeFile(ACCOUNTS_FILE, encrypted);
23582426
await fsp.chmod(ACCOUNTS_FILE, 0o600);
23592427
};
@@ -2362,23 +2430,50 @@ function registerIpcHandlers() {
23622430
'credentials-save',
23632431
async (
23642432
_event,
2365-
email: string,
2366-
token: string,
2367-
username: string,
2368-
user_id: number
2433+
email: unknown,
2434+
token: unknown,
2435+
username: unknown,
2436+
user_id: unknown,
2437+
password: unknown
23692438
) => {
23702439
try {
2440+
const emailStr = normalizeEmail(email);
2441+
if (!emailStr || !isValidEmailFormat(emailStr)) {
2442+
return { success: false, error: CREDENTIALS_GENERIC_ERROR };
2443+
}
2444+
const tokenStr = sanitizeString(token, TOKEN_MAX_LENGTH);
2445+
const passwordStr = sanitizeString(password, PASSWORD_MAX_LENGTH);
2446+
if (passwordStr.length === 0) {
2447+
return { success: false, error: CREDENTIALS_GENERIC_ERROR };
2448+
}
2449+
const usernameStr = sanitizeString(username, USERNAME_MAX_LENGTH);
2450+
const id =
2451+
typeof user_id === 'number' && Number.isFinite(user_id)
2452+
? user_id
2453+
: typeof user_id === 'string'
2454+
? parseInt(user_id, 10)
2455+
: 0;
2456+
if (!Number.isFinite(id) || id < 0) {
2457+
return { success: false, error: CREDENTIALS_GENERIC_ERROR };
2458+
}
23712459
const accounts = await readSavedAccounts();
2372-
const index = accounts.findIndex((a) => a.email === email);
2460+
const index = accounts.findIndex((a) => a.email === emailStr);
2461+
const entry: SavedAccount = {
2462+
email: emailStr,
2463+
token: tokenStr,
2464+
username: usernameStr,
2465+
user_id: id as number,
2466+
password: passwordStr,
2467+
};
23732468
if (index >= 0) {
2374-
accounts[index] = { email, token, username, user_id };
2469+
accounts[index] = entry;
23752470
} else {
2376-
accounts.push({ email, token, username, user_id });
2471+
accounts.push(entry);
23772472
}
23782473
await writeSavedAccounts(accounts);
23792474
return { success: true };
2380-
} catch (error) {
2381-
return { success: false, error: (error as Error).message };
2475+
} catch {
2476+
return { success: false, error: CREDENTIALS_GENERIC_ERROR };
23822477
}
23832478
}
23842479
);
@@ -2387,20 +2482,24 @@ function registerIpcHandlers() {
23872482
try {
23882483
const accounts = await readSavedAccounts();
23892484
return { success: true, accounts };
2390-
} catch (error) {
2485+
} catch {
23912486
return { success: false, accounts: [] };
23922487
}
23932488
});
23942489

2395-
ipcMain.handle('credentials-remove', async (_event, email: string) => {
2490+
ipcMain.handle('credentials-remove', async (_event, email: unknown) => {
23962491
try {
2492+
const emailStr = normalizeEmail(email);
2493+
if (!emailStr) {
2494+
return { success: true };
2495+
}
23972496
const accounts = (await readSavedAccounts()).filter(
2398-
(a) => a.email !== email
2497+
(a) => a.email !== emailStr
23992498
);
24002499
await writeSavedAccounts(accounts);
24012500
return { success: true };
2402-
} catch (error) {
2403-
return { success: false, error: (error as Error).message };
2501+
} catch {
2502+
return { success: false, error: CREDENTIALS_GENERIC_ERROR };
24042503
}
24052504
});
24062505

electron/preload/index.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -206,8 +206,17 @@ contextBridge.exposeInMainWorld('electronAPI', {
206206
email: string,
207207
token: string,
208208
username: string,
209-
userId: number
210-
) => ipcRenderer.invoke('credentials-save', email, token, username, userId),
209+
userId: number,
210+
password: string
211+
) =>
212+
ipcRenderer.invoke(
213+
'credentials-save',
214+
email,
215+
token,
216+
username,
217+
userId,
218+
password
219+
),
211220
credentialsLoad: () => ipcRenderer.invoke('credentials-load'),
212221
credentialsRemove: (email: string) =>
213222
ipcRenderer.invoke('credentials-remove', email),

0 commit comments

Comments
 (0)