3536626a8a
Seven-tier security audit of the main process, each finding fixed with a
regression test. Typecheck (node + web) clean; unit tests 106 -> 140.
- Tier 1 (command exec/argv): allowlist the yt-dlp --update-to channel
(blocks arbitrary-binary-install RCE); gate per-download extraArgs behind
the customCommandEnabled consent flag in main (blocks --exec RCE); resolve
taskkill/schtasks by absolute System32 path; validate per-download
outputDir; normalize the URL in assertHttpUrl and use it at every spawn.
- Tier 2 (input validation): fix isSafeFilenameTemplate drive-relative
('C:foo') and Windows dotted-'..' traversal bypasses; percent-encode the
untrusted id in entryUrl; catch reserved device names with extensions in
sanitizeDirSegment.
- Tier 3 (fs/backup): drop malformed template rows in importBackup;
normalize deep-link URLs via assertHttpUrl.
- Tier 4 (cookies): confine the sign-in window's navigations/popups to web
URLs (recursively) and deny all web permissions on its session.
- Tier 5 (deep-link/argv): bound the .url file read to 64 KB; match the
aerofetch:// scheme case-insensitively.
- Tier 6 (Electron window): deny camera/mic/geolocation/USB/HID/serial/
Bluetooth permissions on the app window.
- Tier 7 (network/persistence): restrict the watched-source RSS fetch to
youtube.com feed URLs (SSRF guard); complete isValidSource validation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
123 lines
5.0 KiB
TypeScript
123 lines
5.0 KiB
TypeScript
/**
|
|
* 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, Source, MediaItem } 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 so a malicious
|
|
* backup/settings write can't traverse out of the chosen folder (e.g.
|
|
* '%(title)s\..\..\win32.exe'). Legitimate templates still contain yt-dlp
|
|
* `%(field)s` tokens and `/` or `\` for sub-folders, which are fine.
|
|
*
|
|
* Two Windows-specific bypasses are guarded beyond the obvious cases (audit T1):
|
|
* - drive-relative prefixes like 'C:foo' — `path.isAbsolute` returns FALSE for
|
|
* these, yet they escape the output dir, so a leading drive letter is rejected.
|
|
* - a '..' segment dressed up with trailing dots/spaces ('.. ', '.. .') —
|
|
* Windows silently trims those, so an exact `=== '..'` check would miss them.
|
|
*/
|
|
const TRAVERSAL_SEGMENT = /^[. ]*\.\.[. ]*$/
|
|
export function isSafeFilenameTemplate(template: string): boolean {
|
|
if (isAbsolute(template) || /^[a-zA-Z]:/.test(template)) return false
|
|
return !template.split(/[\\/]/).some((segment) => TRAVERSAL_SEGMENT.test(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'
|
|
}
|
|
|
|
/** A persisted sources.json row must have the right shape or it's dropped on read. */
|
|
export function isValidSource(o: unknown): o is Source {
|
|
if (!o || typeof o !== 'object') return false
|
|
const s = o as Record<string, unknown>
|
|
return (
|
|
typeof s.id === 'string' &&
|
|
typeof s.url === 'string' &&
|
|
(s.kind === 'channel' || s.kind === 'playlist') &&
|
|
typeof s.title === 'string' &&
|
|
typeof s.addedAt === 'number' &&
|
|
typeof s.itemCount === 'number' &&
|
|
isOptionalString(s.channel) &&
|
|
// feedUrl drives a network fetch in the sync, so validate its shape here too
|
|
// (audit T7); the host is additionally restricted at the fetch boundary.
|
|
isOptionalString(s.feedUrl) &&
|
|
(s.watched === undefined || typeof s.watched === 'boolean') &&
|
|
(s.lastIndexedAt === undefined || typeof s.lastIndexedAt === 'number')
|
|
)
|
|
}
|
|
|
|
/** A persisted media-items.json row must have the right shape or it's dropped on read. */
|
|
export function isValidMediaItem(o: unknown): o is MediaItem {
|
|
if (!o || typeof o !== 'object') return false
|
|
const m = o as Record<string, unknown>
|
|
return (
|
|
typeof m.id === 'string' &&
|
|
typeof m.sourceId === 'string' &&
|
|
typeof m.videoId === 'string' &&
|
|
typeof m.title === 'string' &&
|
|
typeof m.url === 'string' &&
|
|
typeof m.playlistTitle === 'string' &&
|
|
typeof m.playlistIndex === 'number' &&
|
|
typeof m.downloaded === 'boolean' &&
|
|
isOptionalString(m.durationLabel) &&
|
|
isOptionalString(m.filePath) &&
|
|
(m.downloadedAt === undefined || typeof m.downloadedAt === 'number')
|
|
)
|
|
}
|