Files
AeroFetch/test/validation.test.ts
T
debont80 3536626a8a security: harden command exec, IPC, deep-link, cookies, and persistence
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>
2026-06-24 08:04:19 -04:00

211 lines
6.6 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import {
isSafeFilenameTemplate,
isSafeOutputDir,
isValidHistoryEntry,
isValidErrorLogEntry,
isTemplateLike,
isValidSource
} from '../src/main/validation'
import type { HistoryEntry, ErrorLogEntry, Source } from '@shared/ipc'
// --- S4: filename template path-traversal -----------------------------------
describe('isSafeFilenameTemplate', () => {
it('accepts the default template', () => {
expect(isSafeFilenameTemplate('%(title)s.%(ext)s')).toBe(true)
})
it('accepts sub-folder templates with yt-dlp fields', () => {
expect(isSafeFilenameTemplate('%(uploader)s/%(title)s.%(ext)s')).toBe(true)
expect(isSafeFilenameTemplate('%(uploader)s\\%(title)s.%(ext)s')).toBe(true)
})
it('rejects forward-slash traversal', () => {
expect(isSafeFilenameTemplate('%(title)s/../../evil.%(ext)s')).toBe(false)
})
it('rejects backslash traversal', () => {
expect(isSafeFilenameTemplate('%(title)s\\..\\..\\win32.exe')).toBe(false)
})
it('rejects a bare .. segment', () => {
expect(isSafeFilenameTemplate('..')).toBe(false)
})
it('rejects absolute paths', () => {
expect(isSafeFilenameTemplate('C:\\Windows\\system32\\%(title)s')).toBe(false)
expect(isSafeFilenameTemplate('/etc/%(title)s')).toBe(false)
})
it('allows .. only when embedded in a longer segment (not a real traversal)', () => {
// '..foo' / 'foo..bar' are filenames, not parent-dir references.
expect(isSafeFilenameTemplate('my..video.%(ext)s')).toBe(true)
expect(isSafeFilenameTemplate('.%(title)s.%(ext)s')).toBe(true) // leading dot = hidden file
})
// --- T1: Windows-specific bypasses --------------------------------------
it('rejects a drive-relative prefix that path.isAbsolute misses', () => {
// 'C:foo' is NOT absolute per Node, but still escapes the output dir.
expect(isSafeFilenameTemplate('C:foo\\%(title)s.%(ext)s')).toBe(false)
expect(isSafeFilenameTemplate('c:%(title)s')).toBe(false)
})
it('rejects a .. segment dressed up with trailing/leading dots or spaces', () => {
// Windows trims trailing dots/spaces, so these all resolve to '..'.
expect(isSafeFilenameTemplate('%(title)s/.. /x')).toBe(false)
expect(isSafeFilenameTemplate('%(title)s/.. ./x')).toBe(false)
expect(isSafeFilenameTemplate('%(title)s/ ../x')).toBe(false)
expect(isSafeFilenameTemplate('%(title)s\\.. \\x')).toBe(false)
expect(isSafeFilenameTemplate('...')).toBe(false)
})
})
// --- S4: output directory ----------------------------------------------------
describe('isSafeOutputDir', () => {
it('accepts an empty string (resolves to OS Downloads)', () => {
expect(isSafeOutputDir('')).toBe(true)
})
it('accepts an absolute Windows path', () => {
expect(isSafeOutputDir('C:\\Users\\me\\Downloads')).toBe(true)
})
it('rejects a relative path', () => {
expect(isSafeOutputDir('Downloads')).toBe(false)
expect(isSafeOutputDir('..\\elsewhere')).toBe(false)
})
})
// --- S5: persisted JSON entry validation -------------------------------------
const validHistory: HistoryEntry = {
id: 'a',
title: 'A video',
url: 'https://example.com/v',
kind: 'video',
quality: '1080p',
completedAt: 1700000000000
}
describe('isValidHistoryEntry', () => {
it('accepts a well-formed entry', () => {
expect(isValidHistoryEntry(validHistory)).toBe(true)
})
it('accepts optional string fields', () => {
expect(
isValidHistoryEntry({
...validHistory,
filePath: 'C:\\x.mp4',
channel: 'ch',
sizeLabel: '10 MB',
thumbnail: 'https://x/y.jpg'
})
).toBe(true)
})
it('rejects a wrong kind', () => {
expect(isValidHistoryEntry({ ...validHistory, kind: 'image' })).toBe(false)
})
it('rejects a missing required field', () => {
const { url: _url, ...noUrl } = validHistory
expect(isValidHistoryEntry(noUrl)).toBe(false)
})
it('rejects a non-string filePath (e.g. injected number/object)', () => {
expect(isValidHistoryEntry({ ...validHistory, filePath: 42 })).toBe(false)
})
it('rejects non-objects', () => {
expect(isValidHistoryEntry(null)).toBe(false)
expect(isValidHistoryEntry('nope')).toBe(false)
expect(isValidHistoryEntry(undefined)).toBe(false)
})
})
const validError: ErrorLogEntry = {
id: 'e',
url: 'https://example.com/v',
kind: 'audio',
error: 'boom',
occurredAt: 1700000000000
}
describe('isValidErrorLogEntry', () => {
it('accepts a well-formed entry', () => {
expect(isValidErrorLogEntry(validError)).toBe(true)
expect(isValidErrorLogEntry({ ...validError, title: 'optional' })).toBe(true)
})
it('rejects a missing error message', () => {
const { error: _error, ...noError } = validError
expect(isValidErrorLogEntry(noError)).toBe(false)
})
it('rejects a non-numeric occurredAt', () => {
expect(isValidErrorLogEntry({ ...validError, occurredAt: 'yesterday' })).toBe(false)
})
})
describe('isTemplateLike', () => {
it('accepts an object with a string or numeric id', () => {
expect(isTemplateLike({ id: 'x', name: 'n', args: '--foo' })).toBe(true)
expect(isTemplateLike({ id: 1 })).toBe(true)
})
it('rejects entries without an id', () => {
expect(isTemplateLike({ name: 'n', args: '--foo' })).toBe(false)
expect(isTemplateLike(null)).toBe(false)
expect(isTemplateLike('str')).toBe(false)
})
})
// --- T7: source row validation (feedUrl drives a network fetch) --------------
const validSource: Source = {
id: 's1',
url: 'https://www.youtube.com/@x',
kind: 'channel',
title: 'X',
addedAt: 1700000000000,
itemCount: 5
}
describe('isValidSource', () => {
it('accepts a well-formed source and its optional fields', () => {
expect(isValidSource(validSource)).toBe(true)
expect(
isValidSource({
...validSource,
channel: 'X',
watched: true,
feedUrl: 'https://www.youtube.com/feeds/videos.xml?channel_id=UC',
lastIndexedAt: 1700000000001
})
).toBe(true)
})
it('rejects a non-string feedUrl (audit T7)', () => {
expect(isValidSource({ ...validSource, feedUrl: 123 })).toBe(false)
})
it('rejects a non-boolean watched (audit T7)', () => {
expect(isValidSource({ ...validSource, watched: 'yes' })).toBe(false)
})
it('rejects a wrong kind or a missing required field', () => {
expect(isValidSource({ ...validSource, kind: 'video' })).toBe(false)
const { id: _id, ...noId } = validSource
expect(isValidSource(noId)).toBe(false)
})
it('rejects non-objects', () => {
expect(isValidSource(null)).toBe(false)
expect(isValidSource('nope')).toBe(false)
})
})