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>
332 lines
13 KiB
TypeScript
332 lines
13 KiB
TypeScript
/**
|
|
* Pure yt-dlp argv construction for a download.
|
|
*
|
|
* This module is deliberately free of any electron / node-runtime dependency
|
|
* (it only imports types + the BEST_FORMAT_ID const from the shared contract)
|
|
* so the argv it produces can be unit-tested without spinning up Electron.
|
|
* `buildArgs` takes the ffmpeg/yt-dlp `binDir` as a parameter rather than
|
|
* resolving it itself; the caller in download.ts passes `getBinDir()`.
|
|
*/
|
|
|
|
import { join } from 'path'
|
|
import {
|
|
BEST_FORMAT_ID,
|
|
type StartDownloadOptions,
|
|
type DownloadOptions,
|
|
type CollectionContext,
|
|
type CookieBrowser,
|
|
type CommandTemplate
|
|
} from '@shared/ipc'
|
|
|
|
// --- Quality → yt-dlp selector mapping --------------------------------------
|
|
|
|
function videoFormat(quality: string): string {
|
|
switch (quality) {
|
|
case '1080p':
|
|
return 'bv*[height<=1080]+ba/b[height<=1080]'
|
|
case '720p':
|
|
return 'bv*[height<=720]+ba/b[height<=720]'
|
|
case '480p':
|
|
return 'bv*[height<=480]+ba/b[height<=480]'
|
|
case '360p':
|
|
return 'bv*[height<=360]+ba/b[height<=360]'
|
|
default:
|
|
return 'bv*+ba/b' // Best available
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The video format selector. A specific probed format_id wins; otherwise fall
|
|
* back to the height-based preset. Video-only formats get +bestaudio so the
|
|
* merge produces a file with sound (with a video-only fallback if no audio).
|
|
*/
|
|
function videoSelector(opts: StartDownloadOptions): string {
|
|
if (opts.formatId && opts.formatId !== BEST_FORMAT_ID) {
|
|
return opts.formatHasAudio
|
|
? opts.formatId
|
|
: `${opts.formatId}+bestaudio/${opts.formatId}`
|
|
}
|
|
return videoFormat(opts.quality)
|
|
}
|
|
|
|
function audioQuality(quality: string): string {
|
|
switch (quality) {
|
|
case '320 kbps':
|
|
return '320K'
|
|
case '192 kbps':
|
|
return '192K'
|
|
case '128 kbps':
|
|
return '128K'
|
|
default:
|
|
return '0' // Best
|
|
}
|
|
}
|
|
|
|
// The progress line yt-dlp emits (one per --newline tick). Note the leading
|
|
// `download:` is the progress-template TYPE selector and is consumed by yt-dlp
|
|
// (it does NOT appear in the output). The literal `prog|` that follows is our
|
|
// own marker, so we can tell progress lines apart from the after-move filepath
|
|
// print on the same stdout stream.
|
|
export const PROGRESS_TEMPLATE =
|
|
'download:prog|%(progress.status)s|%(progress.downloaded_bytes)s|' +
|
|
'%(progress.total_bytes)s|%(progress.total_bytes_estimate)s|' +
|
|
'%(progress.speed)s|%(progress.eta)s'
|
|
|
|
// Crop embedded audio artwork to a centred square. The double-quote/single-quote
|
|
// dance is deliberate: yt-dlp shlex-splits the `--ppa` value, consuming the outer
|
|
// double quotes and leaving ffmpeg single-quoted crop expressions whose commas are
|
|
// protected from ffmpeg's filtergraph separator. Side = min(width, height).
|
|
export const CROP_SQUARE_PPA =
|
|
'EmbedThumbnail+ffmpeg_o:-c:v mjpeg -vf ' +
|
|
'crop="\'if(gt(ih,iw),iw,ih)\':\'if(gt(iw,ih),ih,iw)\'"'
|
|
|
|
/**
|
|
* Phase B "Access & networking" settings — not per-download options, always
|
|
* applied from the global settings (there's no per-download override for
|
|
* these, unlike DownloadOptions).
|
|
*/
|
|
export interface AccessOptions {
|
|
/** yt-dlp --proxy value; empty disables it */
|
|
proxy: string
|
|
/** yt-dlp --limit-rate value, e.g. '2M'; empty means unlimited */
|
|
rateLimit: string
|
|
/** absolute path to aria2c.exe; omit to use yt-dlp's built-in downloader */
|
|
aria2cPath?: string
|
|
/** read auth cookies from an installed browser's own cookie store */
|
|
cookiesFromBrowser?: CookieBrowser
|
|
/** absolute path to a Netscape-format cookie file (the sign-in window's export) */
|
|
cookiesFile?: string
|
|
/** sanitize output filenames to a portable ASCII-only subset */
|
|
restrictFilenames: boolean
|
|
/** absolute path to a --download-archive file; omit to disable archive tracking */
|
|
downloadArchivePath?: string
|
|
}
|
|
|
|
// Tuned for typical CDN-served video: 16 connections split across 16 segments,
|
|
// each at least 1MB so tiny files don't get split pointlessly.
|
|
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.
|
|
*
|
|
* 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[] = []
|
|
const re = /"([^"]*)"|'([^']*)'|(\S+)/g
|
|
let m: RegExpExecArray | null
|
|
while ((m = re.exec(raw)) !== null) {
|
|
args.push(m[1] ?? m[2] ?? m[3])
|
|
}
|
|
return args
|
|
}
|
|
|
|
/**
|
|
* Resolve the extra yt-dlp args for a download, enforcing the custom-command
|
|
* consent gate (audit F2).
|
|
*
|
|
* Extra args are powerful enough to run arbitrary code (e.g. `--exec`), so they
|
|
* are honoured ONLY when custom commands are explicitly enabled in settings —
|
|
* the same persisted flag the Settings UI and backup-import treat as consent. A
|
|
* per-download override wins over the persisted default template, but NEITHER is
|
|
* applied while the gate is off. This keeps a compromised renderer from smuggling
|
|
* code-exec flags through a lone `startDownload({ extraArgs })` call: it would
|
|
* first have to flip the visible `customCommandEnabled` setting, leaving a trace
|
|
* (the same defence-in-depth posture as the main-side maxConcurrent cap).
|
|
*
|
|
* - customCommandEnabled off → [] (always)
|
|
* - perDownloadExtraArgs defined (even '') → those args
|
|
* - else a matching defaultTemplateId → that template's args
|
|
* - else → []
|
|
*/
|
|
export function selectExtraArgs(params: {
|
|
customCommandEnabled: boolean
|
|
perDownloadExtraArgs: string | undefined
|
|
defaultTemplateId: string | null
|
|
templates: Pick<CommandTemplate, 'id' | 'args'>[]
|
|
}): string[] {
|
|
if (!params.customCommandEnabled) return []
|
|
if (params.perDownloadExtraArgs !== undefined) return parseExtraArgs(params.perDownloadExtraArgs)
|
|
if (params.defaultTemplateId) {
|
|
const tpl = params.templates.find((t) => t.id === params.defaultTemplateId)
|
|
if (tpl) return parseExtraArgs(tpl.args)
|
|
}
|
|
return []
|
|
}
|
|
|
|
// Wrap a single argv token for human-readable display only (Phase C command
|
|
// preview) — never used to build the real argv that gets spawned.
|
|
function quoteForDisplay(arg: string): string {
|
|
if (arg === '') return '""'
|
|
if (/[\s"]/.test(arg)) return `"${arg.replace(/"/g, '\\"')}"`
|
|
return arg
|
|
}
|
|
|
|
/** Render a binary + argv as a single copy-pasteable command line. */
|
|
export function formatCommandLine(exe: string, args: string[]): string {
|
|
return [exe, ...args].map(quoteForDisplay).join(' ')
|
|
}
|
|
|
|
// --- Collection (media-manager) folder paths --------------------------------
|
|
|
|
/**
|
|
* Sanitize one path segment (a channel or playlist name) into a Windows-safe
|
|
* directory name. This matters because, unlike the filename yt-dlp itself writes
|
|
* (which `--restrict-filenames` can clean), these directory segments are built by
|
|
* AeroFetch from untrusted channel/playlist titles and joined onto the output
|
|
* dir — so they must be neutered for both illegal characters AND path traversal.
|
|
*
|
|
* - illegal chars (`< > : " / \ | ? *`) and control chars → space
|
|
* - leading/trailing dots and spaces stripped (illegal / invisible on Windows),
|
|
* which also turns a bare `..` traversal segment into nothing
|
|
* - reserved device names (CON, PRN, NUL, COM1…) get an underscore prefix
|
|
* - length-capped so the full path stays well under MAX_PATH
|
|
* - empty result falls back to 'Untitled'
|
|
*/
|
|
export function sanitizeDirSegment(name: string): string {
|
|
// C0 control chars (charCode < 0x20) and Windows-illegal chars both become a
|
|
// space. The control-char filter is done by char code so no literal control
|
|
// byte ever appears in this source file.
|
|
let s = Array.from(name ?? '', (ch) => (ch.charCodeAt(0) < 0x20 ? ' ' : ch)).join('')
|
|
s = s.replace(/[<>:"/\\|?*]/g, ' ')
|
|
s = s.replace(/\s+/g, ' ').trim().replace(/[. ]+$/, '').replace(/^[. ]+/, '')
|
|
// Reserved device names are reserved even WITH an extension ('CON.txt' is still
|
|
// the CON device), so match an optional trailing '.<ext>' too (audit T3).
|
|
if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i.test(s)) s = `_${s}`
|
|
s = s.slice(0, 80).trim()
|
|
return s || 'Untitled'
|
|
}
|
|
|
|
/**
|
|
* Build the `-o` output template for a collection download:
|
|
* <outDir>/<Channel>/<Playlist>/<NNN> - <baseFilename>
|
|
* where NNN is the 1-based playlist index zero-padded to three digits and
|
|
* baseFilename keeps its yt-dlp field tokens (e.g. '%(title)s.%(ext)s') so they
|
|
* still expand. Channel/playlist are sanitized via sanitizeDirSegment.
|
|
*/
|
|
export function collectionOutputTemplate(
|
|
outDir: string,
|
|
c: CollectionContext,
|
|
baseFilename: string
|
|
): string {
|
|
const n = Number.isFinite(c.index) && c.index > 0 ? Math.floor(c.index) : 1
|
|
const nnn = String(n).padStart(3, '0')
|
|
const segments = [sanitizeDirSegment(c.channel), sanitizeDirSegment(c.playlist)]
|
|
return join(outDir, ...segments, `${nnn} - ${baseFilename}`)
|
|
}
|
|
|
|
function accessArgs(a: AccessOptions): string[] {
|
|
const args: string[] = []
|
|
if (a.proxy.trim()) args.push('--proxy', a.proxy.trim())
|
|
if (a.rateLimit.trim()) args.push('--limit-rate', a.rateLimit.trim())
|
|
if (a.aria2cPath) args.push('--downloader', a.aria2cPath, '--downloader-args', ARIA2C_ARGS)
|
|
// cookiesFromBrowser and cookiesFile are mutually exclusive sources — the UI
|
|
// only ever sets one (driven by Settings.cookieSource), but browser wins if
|
|
// a caller somehow sets both.
|
|
if (a.cookiesFromBrowser) args.push('--cookies-from-browser', a.cookiesFromBrowser)
|
|
else if (a.cookiesFile) args.push('--cookies', a.cookiesFile)
|
|
if (a.restrictFilenames) args.push('--restrict-filenames')
|
|
if (a.downloadArchivePath) args.push('--download-archive', a.downloadArchivePath)
|
|
return args
|
|
}
|
|
|
|
/** Subtitle / SponsorBlock / chapter / metadata flags shared by both kinds. */
|
|
function postProcessArgs(opts: StartDownloadOptions, o: DownloadOptions): string[] {
|
|
const args: string[] = []
|
|
|
|
// Subtitles — video only; audio extraction has nowhere to embed them.
|
|
if (opts.kind === 'video' && o.embedSubtitles) {
|
|
args.push('--write-subs', '--embed-subs', '--sub-langs', o.subtitleLanguages || 'en')
|
|
if (o.autoSubtitles) args.push('--write-auto-subs')
|
|
// Normalise to srt first so embedding works across containers (mp4 → mov_text).
|
|
args.push('--convert-subs', 'srt')
|
|
}
|
|
|
|
// SponsorBlock.
|
|
if (o.sponsorBlock && o.sponsorBlockCategories.length > 0) {
|
|
const cats = o.sponsorBlockCategories.join(',')
|
|
if (o.sponsorBlockMode === 'remove') {
|
|
// Force keyframes at the cut points so segment boundaries are frame-accurate.
|
|
args.push('--sponsorblock-remove', cats, '--force-keyframes-at-cuts')
|
|
} else {
|
|
args.push('--sponsorblock-mark', cats)
|
|
}
|
|
}
|
|
|
|
if (o.embedChapters) args.push('--embed-chapters')
|
|
if (o.embedMetadata) args.push('--embed-metadata')
|
|
|
|
// Media-server sidecar files (Phase K) — written next to the output file so
|
|
// Jellyfin/Plex/Kodi can ingest metadata, poster art, and the description.
|
|
if (o.writeInfoJson) args.push('--write-info-json')
|
|
if (o.writeThumbnailFile) args.push('--write-thumbnail')
|
|
if (o.writeDescription) args.push('--write-description')
|
|
|
|
return args
|
|
}
|
|
|
|
export function buildArgs(
|
|
opts: StartDownloadOptions,
|
|
outputTemplate: string,
|
|
o: DownloadOptions,
|
|
binDir: string,
|
|
access: AccessOptions,
|
|
extraArgs: string[] = []
|
|
): string[] {
|
|
const args = [
|
|
'--newline',
|
|
'--no-color',
|
|
'--no-playlist',
|
|
// --print (below) implies --quiet, which would suppress progress; --progress
|
|
// forces the progress template to emit anyway.
|
|
'--progress',
|
|
'--ffmpeg-location',
|
|
binDir,
|
|
'-o',
|
|
outputTemplate,
|
|
'--progress-template',
|
|
PROGRESS_TEMPLATE,
|
|
// Print the final path after post-processing/move so we can open it later.
|
|
'--print',
|
|
'after_move:path|%(filepath)s',
|
|
'--no-simulate'
|
|
]
|
|
|
|
args.push(...accessArgs(access))
|
|
args.push(...postProcessArgs(opts, o))
|
|
|
|
if (opts.kind === 'audio') {
|
|
args.push('-x', '--audio-format', o.audioFormat, '--audio-quality', audioQuality(opts.quality))
|
|
if (o.embedThumbnail) {
|
|
args.push('--embed-thumbnail')
|
|
if (o.cropThumbnail) args.push('--ppa', CROP_SQUARE_PPA)
|
|
}
|
|
} else {
|
|
args.push('-f', videoSelector(opts), '--merge-output-format', o.videoContainer)
|
|
// Codec preference is a sort tiebreaker AFTER resolution/fps, so it nudges the
|
|
// pick toward the chosen codec without overriding the requested quality.
|
|
if (o.preferredVideoCodec !== 'any') {
|
|
const token = o.preferredVideoCodec === 'av1' ? 'av01' : o.preferredVideoCodec
|
|
args.push('-S', `res,fps,vcodec:${token}`)
|
|
}
|
|
// mp4/mkv carry a cover image; webm has no reliable way to embed one.
|
|
if (o.embedThumbnail && o.videoContainer !== 'webm') args.push('--embed-thumbnail')
|
|
}
|
|
|
|
// Custom-command extra args go last, immediately before the `--` URL
|
|
// terminator, so a user-supplied flag can override anything chosen above
|
|
// (yt-dlp takes the last occurrence of most options).
|
|
if (extraArgs.length > 0) args.push(...extraArgs)
|
|
|
|
// `--` terminates option parsing so the URL can never be read as a flag.
|
|
args.push('--', opts.url)
|
|
return args
|
|
}
|