/** * 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, MediaProfile, 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 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 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) && isOptionalString(e.detail) ) } /** * 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 return typeof t.id === 'string' || typeof t.id === 'number' } /** * A persisted profiles.json row must at least be an object carrying an id; the * rest is coerced by profiles.ts's sanitize(), so a weak shape check plus * normalisation there is enough (same approach as isTemplateLike). */ export function isProfileLike(o: unknown): o is MediaProfile { if (!o || typeof o !== 'object') return false const p = o as Record return typeof p.id === 'string' || typeof p.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 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 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') ) }