Apply all CODE-AUDIT.md findings (S1-S5, P1-P3, M1-M3)

Security:
- S1 (backup.ts): require explicit confirmation before enabling custom-command
  templates from a backup file; import templates in a disabled state if declined
- S2 (cookies.ts): deny non-http/https popups in cookie login window, matching
  the main window's setWindowOpenHandler defence
- S3 (download.ts): main-process concurrency cap — startDownload now rejects
  spawns when active.size >= maxConcurrent (defence-in-depth; renderer already
  enforces this but should not be the only gate)
- S4 (settings.ts, validation.ts): reject filenameTemplate values containing
  path traversal (.. segments or absolute paths); validate outputDir is absolute
- S5 (history.ts, errorlog.ts, templates.ts, validation.ts): per-field
  validation on JSON reads — invalid entries dropped rather than blindly trusted

Performance:
- P1 (settings.ts): getSettings() now only writes to disk when sanitization
  actually changed a value; removes synchronous file I/O on every hot-path read
- P2 (ipc.ts, download.ts, downloads.ts): renderer forwards its pre-probed
  metadata (title/channel/duration) via StartDownloadOptions.meta; main uses
  it directly instead of spawning a redundant second yt-dlp probe

Maintainability:
- M1 (log.ts, download.ts, probe.ts): extract duplicated cleanError() to
  src/main/log.ts; both callers import from the shared module
- M2 (buildArgs.ts): document parseExtraArgs escape-sequence limitations
- M3 (electron-builder.yml): clarify icon TODO as a pre-release action
- P3 (downloads.ts): document that lowering maxConcurrent mid-flight does
  not pause active downloads (intended behaviour, now written down)

Tests:
- Add test/validation.test.ts covering isSafeFilenameTemplate, isSafeOutputDir,
  isValidHistoryEntry, isValidErrorLogEntry, isTemplateLike (76 tests pass)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 10:22:43 -04:00
parent fa78b13cac
commit b08d76a986
16 changed files with 752 additions and 48 deletions
+43 -5
View File
@@ -1,6 +1,7 @@
import { app } from 'electron'
import { join } from 'path'
import Store from 'electron-store'
import { isSafeFilenameTemplate, isSafeOutputDir } from './validation'
import {
AUDIO_FORMATS,
VIDEO_CONTAINERS,
@@ -90,13 +91,42 @@ function getStore(): Store<Settings> {
export function getSettings(): Settings {
const s = getStore()
// Fill in the real Downloads path the first time, and persist it.
if (!s.get('outputDir')) s.set('outputDir', app.getPath('downloads'))
// Migrate settings files that predate downloadOptions (or hold a partial one).
s.set('downloadOptions', sanitizeOptions(s.get('downloadOptions')))
// getSettings() is on hot paths (buildCommand, notification checks, the system-
// theme bridge, several IPC handlers). electron-store writes to disk on every
// `set`, so only write when something actually changed — otherwise this churns
// the settings file on every read. (audit P1)
const cur = s.store
if (!cur.outputDir) {
// Fill in the real Downloads path the first time, and persist it once.
s.set('outputDir', app.getPath('downloads'))
}
// Migrate settings files that predate downloadOptions (or hold a partial one),
// but only persist when sanitizing actually altered the stored value.
const sanitized = sanitizeOptions(cur.downloadOptions)
if (!downloadOptionsEqual(sanitized, cur.downloadOptions)) {
s.set('downloadOptions', sanitized)
}
return s.store
}
/** Shallow structural equality for DownloadOptions (sponsorBlockCategories compared by value). */
function downloadOptionsEqual(a: DownloadOptions, b: unknown): boolean {
if (!b || typeof b !== 'object') return false
const o = b as Partial<DownloadOptions>
const keys = Object.keys(a) as (keyof DownloadOptions)[]
for (const k of keys) {
if (k === 'sponsorBlockCategories') {
const av = a[k]
const bv = o[k]
if (!Array.isArray(bv) || av.length !== bv.length) return false
for (let i = 0; i < av.length; i++) if (av[i] !== bv[i]) return false
} else if (a[k] !== o[k]) {
return false
}
}
return true
}
// Validate each key before persisting — the renderer is the only caller today,
// but settings flow into process spawning (maxConcurrent) and the native window
// background (theme), so an out-of-range or malformed value shouldn't get stored.
@@ -148,9 +178,17 @@ export function setSettings(partial: Partial<Settings>): Settings {
s.set('downloadOptions', sanitizeOptions(value))
break
case 'outputDir':
if (typeof value === 'string' && isSafeOutputDir(value.trim())) s.set('outputDir', value.trim())
break
case 'filenameTemplate':
if (typeof value === 'string' && isSafeFilenameTemplate(value.trim())) {
s.set('filenameTemplate', value.trim())
}
break
case 'defaultVideoQuality':
case 'defaultAudioQuality':
case 'filenameTemplate':
// proxy may carry plaintext credentials (user:pass@host); they are stored
// and exported by exportBackup as-is — documented, not masked.
case 'proxy':
case 'rateLimit':
if (typeof value === 'string') s.set(key, value)