@@ -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
0 commit comments