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
+60 -2
View File
@@ -51,7 +51,65 @@ export async function importBackup(win: BrowserWindow | undefined): Promise<Back
// hand-edited or partial backup file degrades gracefully rather than
// corrupting the store.
const file = parsed as Partial<BackupFile>
if (file.settings && typeof file.settings === 'object') setSettings(file.settings)
if (Array.isArray(file.templates)) replaceTemplates(file.templates)
const incomingSettings =
file.settings && typeof file.settings === 'object'
? (file.settings as Partial<Settings>)
: undefined
const incomingTemplates = Array.isArray(file.templates)
? (file.templates as CommandTemplate[])
: []
// A custom-command template's `args` are extra yt-dlp flags that get spawned on
// the next download. A backup that arrives with customCommandEnabled + a default
// template carrying args would silently enable code execution the moment a
// download starts — so require an explicit, informed confirmation before
// honouring that, and import the templates in a *disabled* state if declined.
const commandTemplates = incomingTemplates.filter(
(t) => t && typeof t === 'object' && typeof t.args === 'string' && t.args.trim() !== ''
)
const wouldEnableCustomCommands =
incomingSettings?.customCommandEnabled === true && commandTemplates.length > 0
let applyCustomCommands = true
if (wouldEnableCustomCommands) {
const preview = commandTemplates
.slice(0, 10)
.map((t) => {
const name = (t.name ?? '').trim() || 'Untitled command'
const args = t.args.length > 100 ? `${t.args.slice(0, 100)}` : t.args
return `${name}: ${args}`
})
.join('\n')
const more =
commandTemplates.length > 10 ? `\n…and ${commandTemplates.length - 10} more.` : ''
const choice = await dialog.showMessageBox(win!, {
type: 'warning',
buttons: ['Enable custom commands', 'Import without enabling', 'Cancel'],
defaultId: 1,
cancelId: 2,
title: 'Backup contains custom commands',
message: `This backup enables ${commandTemplates.length} custom-command template${
commandTemplates.length === 1 ? '' : 's'
}.`,
detail:
'Custom commands run extra yt-dlp flags on every download. Only enable them if you trust this backup file.\n\n' +
preview +
more
})
if (choice.response === 2) return { ok: false } // Cancel — change nothing
applyCustomCommands = choice.response === 0
}
if (incomingSettings) {
// When the user declined to enable custom commands (or there were none to
// enable), force the toggle off so an imported defaultTemplateId can't auto-run.
const safeSettings = applyCustomCommands
? incomingSettings
: { ...incomingSettings, customCommandEnabled: false }
setSettings(safeSettings)
}
if (incomingTemplates.length > 0 || Array.isArray(file.templates)) {
replaceTemplates(incomingTemplates)
}
return { ok: true }
}
+8 -2
View File
@@ -106,8 +106,14 @@ export const ARIA2C_ARGS = 'aria2c:-x 16 -s 16 -k 1M'
/**
* Shell-like split of a raw "extra yt-dlp args" string (Phase C custom-command
* templates) into argv tokens. Supports single- and double-quoted spans so a
* flag value containing spaces (e.g. a --ppa recipe) can be typed as one
* token; no escape-character or nested-quote support beyond that.
* flag value containing spaces (e.g. a --ppa recipe) can be typed as one token.
*
* LIMITATIONS (audit M2): there is no escape-sequence support (`\"`, `\'`) and
* quotes cannot nest — a literal quote inside a value can't be expressed, and an
* unterminated quote falls through to the `\S+` branch and captures the quote
* char itself. For the yt-dlp use cases this targets (proxy URLs, filename
* patterns, ffmpeg recipes), that's sufficient. If a future template needs
* nested or escaped quotes, redesign this to a proper shlex or JSON-based format.
*/
export function parseExtraArgs(raw: string): string[] {
const args: string[] = []
+17 -6
View File
@@ -116,13 +116,24 @@ export function openCookieLoginWindow(url: string): Promise<CookiesLoginResult>
// Some sites log in via an OAuth/SSO popup. Let those open as real windows
// sharing the same partition, rather than silently swallowing the click.
win.webContents.setWindowOpenHandler(() => ({
action: 'allow',
overrideBrowserWindowOptions: {
autoHideMenuBar: true,
webPreferences: { partition: PARTITION, sandbox: true, contextIsolation: true, nodeIntegration: false }
// Restrict popups to http(s) only — the same defence the main window applies
// (index.ts) — so a logged-in page can't open a file:// popup to exfil cookies
// to disk or use a custom-protocol popup as a pivot.
win.webContents.setWindowOpenHandler((details) => {
try {
const { protocol } = new URL(details.url)
if (protocol !== 'http:' && protocol !== 'https:') return { action: 'deny' }
} catch {
return { action: 'deny' } // unparseable URL — never open it
}
}))
return {
action: 'allow',
overrideBrowserWindowOptions: {
autoHideMenuBar: true,
webPreferences: { partition: PARTITION, sandbox: true, contextIsolation: true, nodeIntegration: false }
}
}
})
win.on('closed', () => {
loginWindow = null
+23 -17
View File
@@ -8,6 +8,7 @@ import { getCookiesFilePath } from './cookies'
import { listTemplates } from './templates'
import { assertHttpUrl } from './url'
import { buildArgs, parseExtraArgs, formatCommandLine } from './buildArgs'
import { cleanError } from './log'
import { addErrorLog } from './errorlog'
import {
IpcChannels,
@@ -77,16 +78,6 @@ function parseProgress(rest: string): DownloadProgress | null {
}
}
/** Trim yt-dlp's noisy stderr down to the most useful trailing error line. */
function cleanError(stderr: string): string {
const lines = stderr
.split('\n')
.map((l) => l.trim())
.filter(Boolean)
const errLine = [...lines].reverse().find((l) => /^error/i.test(l))
return (errLine ?? lines[lines.length - 1] ?? '').replace(/^error:\s*/i, '').trim()
}
function send(wc: WebContents, ev: DownloadEvent): void {
if (!wc.isDestroyed()) wc.send(IpcChannels.downloadEvent, ev)
}
@@ -231,6 +222,13 @@ export function startDownload(
if (active.has(opts.id)) {
return { ok: false, error: 'A download with this id is already running.' }
}
// Defence-in-depth: the renderer's pump() already caps concurrency, but the main
// process shouldn't trust it — a buggy or compromised renderer could otherwise
// spawn unbounded yt-dlp processes. Enforce the same cap here on active spawns.
const maxConcurrent = getSettings().maxConcurrent
if (active.size >= maxConcurrent) {
return { ok: false, error: 'Max concurrent downloads reached. Wait for a slot to free up.' }
}
let child: ChildProcess
try {
@@ -242,14 +240,22 @@ export function startDownload(
const rec: ActiveDownload = { child, canceled: false }
active.set(opts.id, rec)
// Fetch title/channel/duration in parallel so the card fills in quickly.
// Also kept locally so the completion notification/error log can show a
// real title instead of just the raw URL.
// Title/channel/duration are kept locally so the completion notification and
// error log can show a real title instead of just the raw URL.
let resolvedTitle: string | undefined
probeMeta(ytdlp, opts.url).then((meta) => {
if (meta?.title) resolvedTitle = meta.title
if (meta && active.has(opts.id)) send(wc, { type: 'meta', id: opts.id, meta })
})
if (opts.meta && (opts.meta.title || opts.meta.channel || opts.meta.durationLabel)) {
// The renderer already probed this URL and passed the metadata along — reuse
// it instead of spawning a redundant second yt-dlp probe (audit P2).
resolvedTitle = opts.meta.title
send(wc, { type: 'meta', id: opts.id, meta: opts.meta })
} else {
// No pre-probed metadata (e.g. a direct paste that skipped the probe) — fetch
// it in parallel so the card fills in quickly.
probeMeta(ytdlp, opts.url).then((meta) => {
if (meta?.title) resolvedTitle = meta.title
if (meta && active.has(opts.id)) send(wc, { type: 'meta', id: opts.id, meta })
})
}
let stdoutBuf = ''
let stderrTail = ''
+5 -1
View File
@@ -2,6 +2,7 @@ import { app } from 'electron'
import { join } from 'path'
import { readFileSync, writeFileSync, existsSync } from 'fs'
import type { ErrorLogEntry } from '@shared/ipc'
import { isValidErrorLogEntry } from './validation'
// Plain JSON in userData, same shape as history.ts. Persisted so a failure
// report survives the queue item being cleared (Seal's "debug report").
@@ -11,11 +12,14 @@ function errorLogFile(): string {
return join(app.getPath('userData'), 'errorlog.json')
}
// Per-entry validation (isValidErrorLogEntry, in validation.ts) so a hand-edited
// or corrupted errorlog.json can't feed the UI entries with the wrong shape —
// invalid rows are dropped. (audit S5)
export function listErrorLog(): ErrorLogEntry[] {
try {
if (!existsSync(errorLogFile())) return []
const data = JSON.parse(readFileSync(errorLogFile(), 'utf8'))
return Array.isArray(data) ? (data as ErrorLogEntry[]) : []
return Array.isArray(data) ? data.filter(isValidErrorLogEntry) : []
} catch {
return []
}
+5 -1
View File
@@ -2,6 +2,7 @@ import { app } from 'electron'
import { join } from 'path'
import { readFileSync, writeFileSync, existsSync } from 'fs'
import type { HistoryEntry } from '@shared/ipc'
import { isValidHistoryEntry } from './validation'
// Plain JSON in userData (portable build redirects userData next to the exe).
// Kept simple per the build plan; can migrate to better-sqlite3 later.
@@ -11,11 +12,14 @@ function historyFile(): string {
return join(app.getPath('userData'), 'history.json')
}
// Per-entry validation (isValidHistoryEntry, in validation.ts) so a hand-edited
// or corrupted history.json can't feed the UI (or openPath) entries with the
// wrong shape — invalid rows are dropped rather than trusted. (audit S5)
export function listHistory(): HistoryEntry[] {
try {
if (!existsSync(historyFile())) return []
const data = JSON.parse(readFileSync(historyFile(), 'utf8'))
return Array.isArray(data) ? (data as HistoryEntry[]) : []
return Array.isArray(data) ? data.filter(isValidHistoryEntry) : []
} catch {
return []
}
+14
View File
@@ -0,0 +1,14 @@
/**
* Shared yt-dlp stderr parsing. Used by both download.ts and probe.ts so the
* error-extraction logic lives in one place (audit M1).
*/
/** Trim yt-dlp's noisy stderr down to the most useful trailing error line. */
export function cleanError(stderr: string): string {
const lines = stderr
.split('\n')
.map((l) => l.trim())
.filter(Boolean)
const errLine = [...lines].reverse().find((l) => /^error/i.test(l))
return (errLine ?? lines[lines.length - 1] ?? '').replace(/^error:\s*/i, '').trim()
}
+1 -9
View File
@@ -2,6 +2,7 @@ import { execFile } from 'child_process'
import { existsSync } from 'fs'
import { getYtdlpPath } from './binaries'
import { fmtBytes } from './download'
import { cleanError } from './log'
import { assertHttpUrl } from './url'
import {
BEST_FORMAT_ID,
@@ -136,15 +137,6 @@ function buildPlaylist(data: RawInfo): PlaylistInfo {
}
}
function cleanError(stderr: string): string {
const lines = stderr
.split('\n')
.map((l) => l.trim())
.filter(Boolean)
const errLine = [...lines].reverse().find((l) => /^error/i.test(l))
return (errLine ?? lines[lines.length - 1] ?? '').replace(/^error:\s*/i, '').trim()
}
/**
* Probe a URL with `yt-dlp -J --flat-playlist`. Resolves to a single video (with
* its format list) or, when the URL is a playlist, the flat list of its entries.
+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)
+8 -1
View File
@@ -2,6 +2,7 @@ import { app } from 'electron'
import { join } from 'path'
import { readFileSync, writeFileSync, existsSync } from 'fs'
import type { CommandTemplate } from '@shared/ipc'
import { isTemplateLike } from './validation'
// Plain JSON in userData, same shape as history.ts.
const MAX_TEMPLATES = 100
@@ -10,11 +11,17 @@ function templatesFile(): string {
return join(app.getPath('userData'), 'templates.json')
}
// A persisted template entry must at least be an object carrying an id (see
// isTemplateLike in validation.ts); everything else is coerced by sanitize().
// Drop anything that isn't, so a hand-edited templates.json can't inject
// malformed entries. (audit S5)
export function listTemplates(): CommandTemplate[] {
try {
if (!existsSync(templatesFile())) return []
const data = JSON.parse(readFileSync(templatesFile(), 'utf8'))
return Array.isArray(data) ? (data as CommandTemplate[]) : []
// Validate shape, then normalise each surviving entry through sanitize() so
// name/args are always well-formed strings regardless of what was on disk.
return Array.isArray(data) ? data.filter(isTemplateLike).map(sanitize) : []
} catch {
return []
}
+77
View File
@@ -0,0 +1,77 @@
/**
* Pure, dependency-free validators for untrusted input that crosses a trust
* boundary (renderer writes, backup files, hand-edited JSON on disk).
*
* Like buildArgs.ts, this module imports nothing from electron/node beyond the
* `path` builtin, so it can be unit-tested without spinning up Electron.
*/
import { isAbsolute } from 'path'
import type { HistoryEntry, ErrorLogEntry, CommandTemplate } from '@shared/ipc'
// --- Path-traversal sanitization (audit S4) ---------------------------------
/**
* A filenameTemplate is joined onto the output directory and handed to yt-dlp's
* `-o`. Reject anything that could write outside that directory — an absolute
* path, or any `..` path segment — so a malicious backup/settings write can't
* traverse out of the chosen folder (e.g. '%(title)s\..\..\win32.exe'). The
* template still legitimately contains yt-dlp `%(field)s` tokens and `/` or `\`
* for sub-folders, which are fine.
*/
export function isSafeFilenameTemplate(template: string): boolean {
if (isAbsolute(template)) return false
return !template.split(/[\\/]/).some((segment) => segment === '..')
}
/** An output directory must be an absolute path ('' resolves to OS Downloads). */
export function isSafeOutputDir(dir: string): boolean {
return dir === '' || isAbsolute(dir)
}
// --- Persisted-JSON entry validation (audit S5) -----------------------------
const isOptionalString = (v: unknown): boolean => v === undefined || typeof v === 'string'
/** A persisted history.json row must have the right shape or it's dropped on read. */
export function isValidHistoryEntry(o: unknown): o is HistoryEntry {
if (!o || typeof o !== 'object') return false
const e = o as Record<string, unknown>
return (
typeof e.id === 'string' &&
typeof e.title === 'string' &&
typeof e.url === 'string' &&
(e.kind === 'video' || e.kind === 'audio') &&
typeof e.quality === 'string' &&
typeof e.completedAt === 'number' &&
isOptionalString(e.filePath) &&
isOptionalString(e.channel) &&
isOptionalString(e.sizeLabel) &&
isOptionalString(e.thumbnail)
)
}
/** A persisted errorlog.json row must have the right shape or it's dropped on read. */
export function isValidErrorLogEntry(o: unknown): o is ErrorLogEntry {
if (!o || typeof o !== 'object') return false
const e = o as Record<string, unknown>
return (
typeof e.id === 'string' &&
typeof e.url === 'string' &&
(e.kind === 'video' || e.kind === 'audio') &&
typeof e.error === 'string' &&
typeof e.occurredAt === 'number' &&
isOptionalString(e.title)
)
}
/**
* A persisted templates.json row must at least be an object carrying an id we
* can stringify; name/args are then coerced by templates.ts's sanitize(), so a
* weak shape check here plus normalisation there is enough.
*/
export function isTemplateLike(o: unknown): o is CommandTemplate {
if (!o || typeof o !== 'object') return false
const t = o as Record<string, unknown>
return typeof t.id === 'string' || typeof t.id === 'number'
}