Files
AeroFetch/CODE-AUDIT.md
T
debont80 81b97ea429 Add placeholder app icon and wire it into the Windows build
Generate build/icon.svg — a white download glyph on the teal brand square,
mirroring the in-app brand mark (Onboarding.tsx) — and rasterise it to a
multi-size build/icon.ico (256/128/64/48/32/16) via ImageMagick. Uncomment
win.icon in electron-builder.yml so installers stop falling back to the default
Electron icon. Marks CODE-AUDIT.md M3 placeholder-done; a designed asset is
still wanted before v1.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 13:10:54 -04:00

343 lines
17 KiB
Markdown

# 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**: `assertHttpUrl` validators + `--` 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](src/main/reveal.ts) allowlist restricts to media extensions, blocks `.exe`/`.bat`/`.ps1`
- **Settings validation**: Type-checked before persistence; `spawn` used without `shell: true`
- **Good test coverage** of risky logic (especially [buildArgs.test.ts](test/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](src/main/backup.ts)
**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](src/main/cookies.ts)
**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](src/main/index.ts) 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:
```typescript
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](src/renderer/src/store/downloads.ts)
**Severity:** Low
**Status:** Fixed — 2026-06-23
**Description:**
`maxConcurrent` is enforced solely by the renderer's `pump()` function. `startDownload` in main ([download.ts:214](src/main/download.ts)) 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`:
```typescript
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](src/main/settings.ts)
**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](src/main/download.ts)) 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:**
- [history.ts:17-18](src/main/history.ts)
- [templates.ts:16-17](src/main/templates.ts)
- [errorlog.ts:16-17](src/main/errorlog.ts)
**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:
```typescript
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](resources/bin/README.md), [binaries.ts](src/main/binaries.ts), [download.ts:235](src/main/download.ts)
**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](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](src/main/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](src/main/binaries.ts).)
---
### Performance
#### P1 — `getSettings()` writes to disk on every read
**File:** [settings.ts:91-98](src/main/settings.ts)
**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:
```typescript
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](src/main/download.ts)
**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](src/renderer/src/store/downloads.ts)). 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:
```typescript
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](src/renderer/src/store/downloads.ts)
**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:**
- [download.ts:81-88](src/main/download.ts)
- [probe.ts:139-146](src/main/probe.ts)
**Severity:** Trivial
**Status:** Fixed — 2026-06-23 (extracted to [log.ts](src/main/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](src/main/buildArgs.ts)
**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
**File:** [electron-builder.yml:48](electron-builder.yml)
**Severity:** Cosmetic
**Status:** Placeholder done — 2026-06-23; a *designed* asset is still wanted before v1.0
**Description:**
The `icon:` line was commented out pending a final icon asset, so builds fell back to the default Electron icon. Bundled binaries are correctly handled (graceful "not found" errors everywhere).
**Fix (done — placeholder):**
Added [build/icon.svg](build/icon.svg) — a white download glyph on the teal brand square, mirroring the in-app brand mark ([Onboarding.tsx](src/renderer/src/components/Onboarding.tsx)) — and rasterised it to a multi-size [build/icon.ico](build/icon.ico) (256/128/64/48/32/16) via ImageMagick. The `win.icon` line is now uncommented. Regenerate with:
```
magick -background none build/icon.svg -define icon:auto-resize=256,128,64,48,32,16 build/icon.ico
```
**Still open:** swap the placeholder for a professionally designed icon before cutting v1.0.
---
## Preserved Strengths (Continue)
The following approaches are correct and should be maintained:
- **`assertHttpUrl` at 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 flag
- **`reveal.ts` extension 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`/`execFile` without `shell: true`**: avoids shell-injection vulns like `%COMSPEC%` CVEs
- **Type-validated settings writes** before persistence: prevents malformed values reaching the store
- **Comprehensive unit tests** for `buildArgs` logic, including the gnarly ffmpeg crop-quoting case
---
## Test Coverage Gaps
The following security-critical functions lack tests:
- `sanitizeOptions` (settings.ts): all validation rules
- `importBackup` (backup.ts): malformed JSON, missing fields, custom-command ingestion
- `safeOpenPath`/`safeShowInFolder` (reveal.ts): extension allowlist enforcement
- `assertHttpUrl` (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 | **Placeholder done 2026-06-23**; designed asset still wanted pre-v1.0 |
| 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).