A live smoke test of buildArgs' argv against the bundled yt-dlp + ffmpeg found that ffprobe.exe was never bundled (resources/bin/ shipped only ffmpeg.exe). yt-dlp resolves both from --ffmpeg-location, so duration-aware post-processing failed at runtime for every user with "ffprobe not found": --sponsorblock-remove, --force-keyframes-at-cuts, and --split-chapters (CODE-AUDIT C1). - Bundle ffprobe.exe (matching n8.1.2 LGPL build, sha256-verified against the already-bundled ffmpeg.exe) and document it in resources/bin/README.md as a required binary; correct the stale "not committed to git" note. - Guard startDownload against a missing ffmpeg.exe/ffprobe.exe up front so the failure is a clear AeroFetch error, not a cryptic yt-dlp postprocessing one (new getFfprobePath() in binaries.ts). - Expand test/real-download.integration.test.ts from 2 to 8 live cases: crop, sponsorblock-remove, audio opus re-encode, mkv+vp9 merge, subtitle+chapter embed, restrict-filenames, download-archive skip, and Phase C extra-args. All 8 pass against live yt-dlp + ffmpeg. - ROADMAP: mark the Phase A/B/C smoke-test caveats done. CODE-AUDIT: add C1. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
17 KiB
AeroFetch Code Audit
Date: 2026-06-23
Scope: Full security & correctness review of main process, preload, IPC contract, and renderer stores
Overall: Strong security fundamentals (context isolation, sandboxing, argument-injection defense); findings are refinements, not foundational gaps
Executive Summary
AeroFetch's security posture is notably thoughtful:
- Layered argument-injection defense:
assertHttpUrlvalidators +--terminator before the URL, preventing yt-dlp flag injection - Tight process model:
contextIsolation: true,sandbox: true,nodeIntegration: false; thin preload forwarding only typed IPC - File opening safety: reveal.ts allowlist restricts to media extensions, blocks
.exe/.bat/.ps1 - Settings validation: Type-checked before persistence;
spawnused withoutshell: true - Good test coverage of risky logic (especially buildArgs.test.ts)
The findings below are opportunities to tighten existing defenses and fix performance gaps. None are foundational breaks — with one exception added later: C1, a missing bundled ffprobe.exe that broke duration-dependent post-processing, surfaced by the real-download smoke test and now fixed.
Findings
Security
S1 — Backup import enables arbitrary custom-command templates silently
File: backup.ts:54-55
Severity: Medium
Status: Fixed — 2026-06-23
Description:
importBackup restores settings + templates from a user-chosen JSON file. The code validates only that CommandTemplate fields have the right shape (id, name, args), then immediately applies them via setSettings. If the backup contains customCommandEnabled: true + defaultTemplateId pointing to a template with args: "--exec 'cmd'", the next download will spawn arbitrary code with no user confirmation that the file carried custom commands.
Why it matters:
An attacker-supplied backup file smuggles code execution into an otherwise innocuous "import my settings" gesture. Unlike direct UI creation of templates, there's no moment where the user sees what's being enabled.
Fix:
Surface the count of imported custom commands and require explicit confirmation before applying. Consider importing templates in a disabled state. Alternative: show a preview of template names/args (first 100 chars) before committing.
S2 — Cookie login window allows popups of arbitrary schemes
File: cookies.ts:119-125
Severity: Low
Status: Fixed — 2026-06-23
Description:
The login window's setWindowOpenHandler returns action: 'allow' for every popup without protocol filtering. A logged-in webpage could spawn windows with file://, custom protocols, or other non-HTTP schemes in the shared session partition. The main window index.ts:118-126 correctly restricts to http:/https: only.
Why it matters:
Defense-in-depth: the sandbox limits damage, but an attacker page could open a file:// popup to exfil cookies to disk, or use a custom-protocol popup as a pivot.
Fix:
Apply the same protocol check to login popups:
win.webContents.setWindowOpenHandler((details) => {
try {
const { protocol } = new URL(details.url)
if (protocol !== 'http:' && protocol !== 'https:') return { action: 'deny' }
} catch { return { action: 'deny' } }
return { action: 'allow', ... }
})
S3 — Concurrency cap is renderer-only (defense-in-depth gap)
File: downloads.ts:249-265
Severity: Low
Status: Fixed — 2026-06-23
Description:
maxConcurrent is enforced solely by the renderer's pump() function. startDownload in main (download.ts:214) only checks for duplicate IDs, not active count. A buggy or compromised renderer could spawn unbounded yt-dlp processes.
Why it matters:
Defense-in-depth: the current design assumes the renderer is trusted. If it ever leaks (XSS in an injected thumbnail URL, future bundled library), the main process should still cap spawns.
Fix:
Add a main-process active-download count check in startDownload:
if (active.size >= settings.maxConcurrent) {
return { ok: false, error: 'Max concurrent downloads reached. Wait for a slot.' }
}
S4 — filenameTemplate and outputDir have no path-traversal checks
File: settings.ts:150-157
Severity: Low
Status: Fixed — 2026-06-23
Description:
These fields are validated as typeof === 'string' only. A template like %(title)s\..\..\..\win32.exe.%(ext)s is joined into -o (download.ts:195) and permits yt-dlp to write outside the intended folder. Also, proxy may carry plaintext credentials (user:pass@...) that get serialized by exportBackup.
Why it matters:
On a single-user machine, self-inflicted. But on a shared PC (the app's stated target use case), directory traversal lets one user overwrite another's files.
Fix:
Sanitize outputDir (must be absolute, within a reasonable parent) and filenameTemplate (reject .. and absolute paths). For proxy, either mask credentials in backups or document that they're stored plaintext.
S5 — Persisted JSON (history, templates, errorlog) read without per-field validation
Files:
Severity: Low
Status: Fixed — 2026-06-23
Description:
All three do JSON.parse(...) as T[] with only an Array.isArray gate and no per-field validation. A hand-edited or corrupted file yields entries the UI blindly trusts (e.g., a HistoryEntry with filePath: "C:\\bad" that passes through to openPath). Inconsistent with the rigorous validation elsewhere.
Why it matters:
Low immediate risk (malformed entries mostly degrade gracefully). But it's an untrusted-input boundary that doesn't match the codebase's defensive posture.
Fix:
Add schema validators (ts-json-validator, zod, or lightweight custom checks) for each type:
function isValidHistoryEntry(obj: unknown): obj is HistoryEntry {
return obj && typeof obj === 'object' && 'id' in obj && typeof obj.id === 'string' && ...
}
Correctness
C1 — Bundled ffprobe.exe was missing — duration-dependent post-processing failed for every user
Files: resources/bin/README.md, binaries.ts, download.ts:235
Severity: High
Status: Fixed — 2026-06-23
Description:
resources/bin/ shipped ffmpeg.exe but not ffprobe.exe, and the README only documented copying ffmpeg.exe. yt-dlp resolves both binaries from --ffmpeg-location <dir>; without ffprobe.exe it cannot read media durations, so any post-processor that needs one fails at runtime with ERROR: Postprocessing: Unable to determine video duration: ffprobe not found. That breaks --sponsorblock-remove, --force-keyframes-at-cuts, and --split-chapters for every user — end-user machines have no system ffprobe either. It slipped past review because thumbnail/crop/metadata post-processing only uses ffmpeg, and typecheck can't see a missing binary.
Found by:
The real-download smoke test (real-download.integration.test.ts) — the SponsorBlock-remove case failed with exit 1 (ffprobe not found) until ffprobe was bundled. Everything else (crop, audio re-encode, container/codec, subs, chapters, restrict-filenames, archive, extra-args) passed.
Fix:
Copied the matching ffprobe.exe (same n8.1.2 LGPL build, verified by SHA-256 against the already-bundled ffmpeg.exe) into resources/bin/, and updated the README to list it as a required binary alongside ffmpeg.exe. The integration suite now also asserts ffprobe.exe is present in beforeAll.
Hardening (done — 2026-06-23):
startDownload now asserts ffmpeg.exe and ffprobe.exe presence up front (download.ts), alongside the existing yt-dlp.exe check — a future missing binary returns a clear AeroFetch error naming the file, instead of a cryptic mid-download yt-dlp postprocessing failure. (getFfprobePath() added to binaries.ts.)
Performance
P1 — getSettings() writes to disk on every read
File: settings.ts:91-98
Severity: Medium
Status: Fixed — 2026-06-23
Description:
Every call to getSettings() unconditionally runs s.set('downloadOptions', sanitizeOptions(...)) and (on first run) s.set('outputDir', ...). getSettings() is on hot paths: buildCommand, notification checks, system-theme bridge updates, several IPC handlers. Since electron-store serializes to disk on each set, this means synchronous file I/O per settings read.
Why it matters:
Unnecessary disk churn. Settings reads are frequent (before every download, on IPC calls). Visible on slower machines or filesystems.
Fix:
Sanitize once at store init or wrap set in a dirty-flag check:
export function getSettings(): Settings {
const s = getStore()
const cur = s.store
const sanitized = sanitizeOptions(cur.downloadOptions)
if (sanitized !== cur.downloadOptions) s.set('downloadOptions', sanitized)
// Only write outputDir once, on first launch
if (!cur.outputDir) s.set('outputDir', app.getPath('downloads'))
return s.store
}
P2 — Every download spawns a redundant metadata probe
File: download.ts:249
Severity: Medium
Status: Fixed — 2026-06-23
Description:
startDownload always calls probeMeta (a second yt-dlp process with --skip-download) in parallel with the real download to fetch title/channel/duration. However, the renderer typically already probed and passes this metadata via addFromUrl's optional meta parameter (downloads.ts:60-65). That metadata is never forwarded to startDownload, so the main process re-fetches it.
Result: doubled network traffic and process spawns; the async metadata completion can overwrite good titles the renderer already provided.
Why it matters:
Wasted I/O on every download, especially for large playlists (where the renderer pre-probed each entry). On slow connections, observable slowdown.
Fix:
Add optional meta fields to StartDownloadOptions and use them if present:
export interface StartDownloadOptions {
// ... existing fields ...
meta?: DownloadMeta // optional pre-probed metadata
}
// In download.ts:
let resolvedTitle = opts.meta?.title ?? (await probeMeta(...))
if (opts.meta) {
send(wc, { type: 'meta', id: opts.id, meta: opts.meta })
} else {
probeMeta(ytdlp, opts.url).then(...)
}
P3 — Lowering maxConcurrent mid-flight doesn't pause overflow
File: downloads.ts:249-265
Severity: Low (UX)
Status: Fixed — 2026-06-23 (documented in code; intentional behaviour)
Description:
pump() only gates future promotions of queued items. Reducing maxConcurrent while N downloads are active leaves all N running (no immediate pause). Acceptable behavior, but not obvious.
Why it matters:
Users may expect "set max to 1" to pause other downloads immediately. Instead, ongoing ones keep running until they finish.
Fix:
Document the behavior, or add a cancel-overflow handler if stricter semantics are desired. For now, a comment suffices.
Maintainability
M1 — cleanError function duplicated
Files:
Severity: Trivial
Status: Fixed — 2026-06-23 (extracted to log.ts)
Description:
Identical 8-line error-log parsing function in two places. If the format changes, both need updating.
Fix:
Extract to a shared utility, e.g., src/main/log.ts or add to download.ts and import in probe.ts.
M2 — parseExtraArgs has no escape-sequence handling
File: buildArgs.ts:112-120
Severity: Trivial
Status: Fixed — 2026-06-23 (limitation documented in code)
Description:
The shell-like split supports single and double quotes but no escape sequences (\" or \'). A value containing a literal quote can't be expressed, and an unterminated quote falls through to \S+ and captures the quote itself. The tests cover the happy path; edge cases aren't reachable in practice (the UI doesn't expose raw quote entry).
Why it matters:
Limitation of the current design, documented nowhere.
Fix:
Add a note near parseExtraArgs: "No escape-sequence support; quotes cannot nest. For most yt-dlp use cases (proxy URLs, filename patterns), this is sufficient." If a future template needs quotes within quotes, redesign to a proper shlex or JSON-based format.
M3 — Release polish: app icon commented out
File: electron-builder.yml:45
Severity: Cosmetic
Status: Deferred — TODO(release) comment added; requires a designed icon asset before v1.0
Description:
The icon: line is commented out pending final icon asset. Bundled binaries are correctly handled (graceful "not found" errors everywhere).
Fix:
Add a build/icon.ico before shipping v1.0.
Preserved Strengths (Continue)
The following approaches are correct and should be maintained:
assertHttpUrlat every entry (url.ts, probe.ts, download.ts, cookies.ts, deeplink.ts): consistent, belt-and-suspenders defense against argument injection--terminator before the URL in all spawned commands: ensures URL is never read as a flagreveal.tsextension allowlist (media + subtitle sidecars only): prevents arbitrary-file execution via compromised renderer- Process-tree kill on cancel (
taskkill /T): correctly cleans up spawned ffmpeg child - Separate cookie partition for login: prevents the login page from accessing app session data
spawn/execFilewithoutshell: true: avoids shell-injection vulns like%COMSPEC%CVEs- Type-validated settings writes before persistence: prevents malformed values reaching the store
- Comprehensive unit tests for
buildArgslogic, including the gnarly ffmpeg crop-quoting case
Test Coverage Gaps
The following security-critical functions lack tests:
sanitizeOptions(settings.ts): all validation rulesimportBackup(backup.ts): malformed JSON, missing fields, custom-command ingestionsafeOpenPath/safeShowInFolder(reveal.ts): extension allowlist enforcementassertHttpUrl(url.ts): protocol validation (had minor cases, now fixed)
Action: Add integration tests or snapshot tests for these before shipping.
Remediation Roadmap
| ID | Category | Effort | Priority | Notes | Status |
|---|---|---|---|---|---|
| S1 | Security | 2h | High | UX confirmation on custom-command import | Fixed 2026-06-23 |
| S2 | Security | 15m | Medium | Copy protocol check from main window | Fixed 2026-06-23 |
| S3 | Security | 30m | Medium | Add active-count gate in main | Fixed 2026-06-23 |
| S4 | Security | 1h | Medium | Path-traversal sanitization | Fixed 2026-06-23 |
| S5 | Security | 1.5h | Low | Add per-field validators to JSON readers | Fixed 2026-06-23 |
| P1 | Perf | 1h | Medium | Remove disk writes from getSettings hot paths |
Fixed 2026-06-23 |
| P2 | Perf | 1h | High | Forward renderer's pre-probed metadata to main | Fixed 2026-06-23 |
| P3 | UX | 15m | Low | Document or enforce concurrency semantics | Fixed 2026-06-23 (documented) |
| M1 | Maint | 15m | Trivial | Extract cleanError to shared module |
Fixed 2026-06-23 |
| M2 | Maint | 15m | Trivial | Document parseExtraArgs limitations |
Fixed 2026-06-23 |
| M3 | Polish | 30m | Trivial | Add icon asset before release | Deferred (pre-release TODO) |
| C1 | Correctness | 30m | High | Bundle missing ffprobe.exe + document it (found by smoke test) |
Fixed 2026-06-23 |
Notes
- Deferred: No findings warrant an emergency patch. S1 is the highest-impact (social engineering), but requires UI changes. S3/S4 are defense-in-depth; production use is low-risk on single-user machines.
- Testing: All changes should be accompanied by test additions (especially security validators).
- DashMail parallel: This mirrors DashMail's approach to staged remediation (high-impact first, polish last).