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>
This commit is contained in:
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
buildArgs,
|
||||
parseExtraArgs,
|
||||
selectExtraArgs,
|
||||
formatCommandLine,
|
||||
sanitizeDirSegment,
|
||||
collectionOutputTemplate,
|
||||
@@ -328,6 +329,80 @@ describe('parseExtraArgs', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectExtraArgs — custom-command consent gate (audit F2)', () => {
|
||||
const templates = [
|
||||
{ id: 'thumb', args: '--write-thumbnail' },
|
||||
{ id: 'danger', args: '--exec "calc.exe"' }
|
||||
]
|
||||
|
||||
it('returns [] when custom commands are disabled, even with a per-download override', () => {
|
||||
// The core fix: a renderer-supplied extraArgs must NOT run while the gate is off.
|
||||
expect(
|
||||
selectExtraArgs({
|
||||
customCommandEnabled: false,
|
||||
perDownloadExtraArgs: '--exec "calc.exe"',
|
||||
defaultTemplateId: 'danger',
|
||||
templates
|
||||
})
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('returns [] when disabled even if a default template id is set', () => {
|
||||
expect(
|
||||
selectExtraArgs({
|
||||
customCommandEnabled: false,
|
||||
perDownloadExtraArgs: undefined,
|
||||
defaultTemplateId: 'thumb',
|
||||
templates
|
||||
})
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('parses a per-download override when enabled', () => {
|
||||
expect(
|
||||
selectExtraArgs({
|
||||
customCommandEnabled: true,
|
||||
perDownloadExtraArgs: '--write-thumbnail --no-mtime',
|
||||
defaultTemplateId: null,
|
||||
templates
|
||||
})
|
||||
).toEqual(['--write-thumbnail', '--no-mtime'])
|
||||
})
|
||||
|
||||
it('an explicit empty override yields [] even with a default template set', () => {
|
||||
expect(
|
||||
selectExtraArgs({
|
||||
customCommandEnabled: true,
|
||||
perDownloadExtraArgs: '',
|
||||
defaultTemplateId: 'thumb',
|
||||
templates
|
||||
})
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('falls back to the default template when no per-download override is given', () => {
|
||||
expect(
|
||||
selectExtraArgs({
|
||||
customCommandEnabled: true,
|
||||
perDownloadExtraArgs: undefined,
|
||||
defaultTemplateId: 'thumb',
|
||||
templates
|
||||
})
|
||||
).toEqual(['--write-thumbnail'])
|
||||
})
|
||||
|
||||
it('returns [] when the default template id matches nothing', () => {
|
||||
expect(
|
||||
selectExtraArgs({
|
||||
customCommandEnabled: true,
|
||||
perDownloadExtraArgs: undefined,
|
||||
defaultTemplateId: 'missing',
|
||||
templates
|
||||
})
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatCommandLine', () => {
|
||||
it('joins the exe and args with spaces when nothing needs quoting', () => {
|
||||
expect(formatCommandLine('yt-dlp.exe', ['-f', 'best', '--', 'https://x.test/v'])).toBe(
|
||||
@@ -410,6 +485,14 @@ describe('sanitizeDirSegment', () => {
|
||||
expect(sanitizeDirSegment('com1')).toBe('_com1')
|
||||
})
|
||||
|
||||
it('prefixes a reserved device name even when it carries an extension (audit T3)', () => {
|
||||
expect(sanitizeDirSegment('CON.txt')).toBe('_CON.txt')
|
||||
expect(sanitizeDirSegment('nul.mp4')).toBe('_nul.mp4')
|
||||
expect(sanitizeDirSegment('LPT1.foo.bar')).toBe('_LPT1.foo.bar')
|
||||
// a non-reserved name that merely starts with similar letters is left alone
|
||||
expect(sanitizeDirSegment('console.log')).toBe('console.log')
|
||||
})
|
||||
|
||||
it('falls back to Untitled for empty / all-illegal input', () => {
|
||||
expect(sanitizeDirSegment('')).toBe('Untitled')
|
||||
expect(sanitizeDirSegment('???')).toBe('Untitled')
|
||||
|
||||
@@ -42,6 +42,20 @@ describe('extractIncomingUrl — aerofetch:// protocol', () => {
|
||||
it('returns null when there is no url= param', () => {
|
||||
expect(extractIncomingUrl(['AeroFetch.exe', 'aerofetch://download'])).toBeNull()
|
||||
})
|
||||
|
||||
it('normalises the target, stripping embedded control chars (audit T3 / F5)', () => {
|
||||
// A tab spliced into the inner URL must not survive to the renderer banner.
|
||||
const raw = 'https://www.youtube.com/watch?v=abc\tdef'
|
||||
const arg = `aerofetch://download?url=${encodeURIComponent(raw)}`
|
||||
expect(extractIncomingUrl(['AeroFetch.exe', arg])).toBe(
|
||||
'https://www.youtube.com/watch?v=abcdef'
|
||||
)
|
||||
})
|
||||
|
||||
it('matches the aerofetch:// scheme case-insensitively (audit T5)', () => {
|
||||
const arg = `AEROFETCH://download?url=${encodeURIComponent(TARGET)}`
|
||||
expect(extractIncomingUrl(['AeroFetch.exe', arg])).toBe(TARGET)
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractIncomingUrl — .url Internet Shortcut (Explorer "Send to")', () => {
|
||||
@@ -62,6 +76,18 @@ describe('extractIncomingUrl — .url Internet Shortcut (Explorer "Send to")', (
|
||||
it('ignores files that merely end in .url-like text but are not real paths', () => {
|
||||
expect(extractIncomingUrl(['AeroFetch.exe', 'not-a-real-path.url'])).toBeNull()
|
||||
})
|
||||
|
||||
it('reads a URL= line that falls within the 64 KB size cap (audit T5)', () => {
|
||||
const junk = '; padding\r\n'.repeat(100) // ~1 KB of leading content
|
||||
const path = urlFile('Small.url', `[InternetShortcut]\r\n${junk}URL=${TARGET}\r\n`)
|
||||
expect(extractIncomingUrl(['AeroFetch.exe', path])).toBe(TARGET)
|
||||
})
|
||||
|
||||
it('ignores a URL= line that falls past the 64 KB size cap (audit T5)', () => {
|
||||
const junk = '; padding\r\n'.repeat(8000) // ~88 KB, beyond the read window
|
||||
const path = urlFile('Huge.url', `[InternetShortcut]\r\n${junk}URL=${TARGET}\r\n`)
|
||||
expect(extractIncomingUrl(['AeroFetch.exe', path])).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractIncomingUrl — no match', () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
buildMediaItems,
|
||||
mergeItemsPreservingState,
|
||||
buildFeedUrl,
|
||||
isYouTubeFeedUrl,
|
||||
parseRssVideoIds,
|
||||
entryUrl,
|
||||
fmtDuration,
|
||||
@@ -57,6 +58,10 @@ describe('entryUrl', () => {
|
||||
expect(entryUrl({ url: 'javascript:alert(1)' })).toBeNull()
|
||||
expect(entryUrl({})).toBeNull()
|
||||
})
|
||||
it('percent-encodes an untrusted id rather than splicing it raw (audit T2)', () => {
|
||||
expect(entryUrl({ id: 'ab cd&x=1' })).toBe('https://www.youtube.com/watch?v=ab%20cd%26x%3D1')
|
||||
expect(entryUrl({ id: 'vid123' })).toBe('https://www.youtube.com/watch?v=vid123') // unchanged
|
||||
})
|
||||
})
|
||||
|
||||
describe('fmtDuration', () => {
|
||||
@@ -189,6 +194,26 @@ describe('buildFeedUrl', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('isYouTubeFeedUrl (audit T7 — SSRF guard)', () => {
|
||||
it('accepts a youtube feeds URL (www optional) and what buildFeedUrl produces', () => {
|
||||
expect(isYouTubeFeedUrl('https://www.youtube.com/feeds/videos.xml?channel_id=UCabc')).toBe(true)
|
||||
expect(isYouTubeFeedUrl('https://youtube.com/feeds/videos.xml?playlist_id=PLxyz')).toBe(true)
|
||||
expect(isYouTubeFeedUrl(buildFeedUrl('channel', 'UCabc')!)).toBe(true)
|
||||
expect(isYouTubeFeedUrl(buildFeedUrl('playlist', 'PLxyz')!)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects internal/arbitrary hosts, wrong scheme, and wrong path (SSRF vectors)', () => {
|
||||
expect(isYouTubeFeedUrl('http://169.254.169.254/latest/meta-data/')).toBe(false) // cloud metadata
|
||||
expect(isYouTubeFeedUrl('http://localhost:8080/admin')).toBe(false)
|
||||
expect(isYouTubeFeedUrl('https://evil.com/feeds/videos.xml')).toBe(false)
|
||||
expect(isYouTubeFeedUrl('https://notyoutube.com.evil.com/feeds/videos.xml')).toBe(false)
|
||||
expect(isYouTubeFeedUrl('http://www.youtube.com/feeds/videos.xml')).toBe(false) // not https
|
||||
expect(isYouTubeFeedUrl('https://www.youtube.com/watch?v=x')).toBe(false) // wrong path
|
||||
expect(isYouTubeFeedUrl('file:///etc/passwd')).toBe(false)
|
||||
expect(isYouTubeFeedUrl('not a url')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseRssVideoIds', () => {
|
||||
it('extracts every <yt:videoId> from a feed body, in order', () => {
|
||||
const xml = `
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { assertHttpUrl } from '../src/main/url'
|
||||
import { isAllowedLoginUrl } from '../src/main/cookies'
|
||||
import { isYtdlpUpdateChannel } from '@shared/ipc'
|
||||
|
||||
// --- F5: assertHttpUrl — argument-injection guard + normalisation -----------
|
||||
|
||||
describe('assertHttpUrl (audit F5)', () => {
|
||||
it('accepts http(s) URLs and returns the normalised href', () => {
|
||||
expect(assertHttpUrl('https://www.youtube.com/watch?v=abc')).toBe(
|
||||
'https://www.youtube.com/watch?v=abc'
|
||||
)
|
||||
// http with a bare host normalises to a trailing slash
|
||||
expect(assertHttpUrl('http://example.com')).toBe('http://example.com/')
|
||||
})
|
||||
|
||||
it('trims surrounding whitespace', () => {
|
||||
expect(assertHttpUrl(' https://example.com/v ')).toBe('https://example.com/v')
|
||||
})
|
||||
|
||||
it('strips interior tabs/newlines the URL parser tolerates (normalised output)', () => {
|
||||
// Returning the raw input would leak these through to argv / loadURL.
|
||||
expect(assertHttpUrl('https://exa\nmple.com/v')).toBe('https://example.com/v')
|
||||
const out = assertHttpUrl('https://example.com/a\tb')
|
||||
expect(out).not.toContain('\t')
|
||||
expect(out).not.toContain('\n')
|
||||
})
|
||||
|
||||
it('rejects non-http(s) protocols', () => {
|
||||
expect(() => assertHttpUrl('file:///C:/Windows/system32')).toThrow()
|
||||
expect(() => assertHttpUrl('javascript:alert(1)')).toThrow()
|
||||
expect(() => assertHttpUrl('ftp://example.com/x')).toThrow()
|
||||
expect(() => assertHttpUrl('aerofetch://download?url=x')).toThrow()
|
||||
})
|
||||
|
||||
it('rejects unparseable input', () => {
|
||||
expect(() => assertHttpUrl('not a url')).toThrow()
|
||||
expect(() => assertHttpUrl('')).toThrow()
|
||||
})
|
||||
|
||||
it('can never return a value that begins with "-" (would read as a yt-dlp flag)', () => {
|
||||
// A leading '-' cannot start a valid URL scheme, so it always throws —
|
||||
// the returned value therefore never opens with a dash.
|
||||
expect(() => assertHttpUrl('-https://example.com')).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
// --- T4: isAllowedLoginUrl — sign-in window navigation confinement ----------
|
||||
|
||||
describe('isAllowedLoginUrl (audit T4)', () => {
|
||||
it('allows http(s) and about:blank', () => {
|
||||
expect(isAllowedLoginUrl('https://accounts.google.com/signin')).toBe(true)
|
||||
expect(isAllowedLoginUrl('http://example.com/login')).toBe(true)
|
||||
expect(isAllowedLoginUrl('about:blank')).toBe(true)
|
||||
})
|
||||
|
||||
it('blocks file://, the app protocol, and other external URI schemes', () => {
|
||||
expect(isAllowedLoginUrl('file:///C:/Windows/System32/calc.exe')).toBe(false)
|
||||
expect(isAllowedLoginUrl('aerofetch://download?url=https://evil.test')).toBe(false)
|
||||
expect(isAllowedLoginUrl('ms-settings:')).toBe(false)
|
||||
expect(isAllowedLoginUrl('mailto:x@y.z')).toBe(false)
|
||||
expect(isAllowedLoginUrl('javascript:alert(1)')).toBe(false)
|
||||
})
|
||||
|
||||
it('blocks unparseable input', () => {
|
||||
expect(isAllowedLoginUrl('not a url')).toBe(false)
|
||||
expect(isAllowedLoginUrl('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// --- F1: isYtdlpUpdateChannel — --update-to allowlist -----------------------
|
||||
|
||||
describe('isYtdlpUpdateChannel (audit F1)', () => {
|
||||
it('accepts the two supported channels', () => {
|
||||
expect(isYtdlpUpdateChannel('stable')).toBe(true)
|
||||
expect(isYtdlpUpdateChannel('nightly')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects an arbitrary repository spec (the --update-to RCE vector)', () => {
|
||||
expect(isYtdlpUpdateChannel('evil/yt-dlp@latest')).toBe(false)
|
||||
expect(isYtdlpUpdateChannel('owner/repo')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects other yt-dlp channels not on AeroFetch’s allowlist', () => {
|
||||
expect(isYtdlpUpdateChannel('master')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects non-string / empty values', () => {
|
||||
expect(isYtdlpUpdateChannel('')).toBe(false)
|
||||
expect(isYtdlpUpdateChannel(undefined)).toBe(false)
|
||||
expect(isYtdlpUpdateChannel(null)).toBe(false)
|
||||
expect(isYtdlpUpdateChannel(42)).toBe(false)
|
||||
})
|
||||
})
|
||||
+66
-2
@@ -4,9 +4,10 @@ import {
|
||||
isSafeOutputDir,
|
||||
isValidHistoryEntry,
|
||||
isValidErrorLogEntry,
|
||||
isTemplateLike
|
||||
isTemplateLike,
|
||||
isValidSource
|
||||
} from '../src/main/validation'
|
||||
import type { HistoryEntry, ErrorLogEntry } from '@shared/ipc'
|
||||
import type { HistoryEntry, ErrorLogEntry, Source } from '@shared/ipc'
|
||||
|
||||
// --- S4: filename template path-traversal -----------------------------------
|
||||
|
||||
@@ -40,6 +41,24 @@ describe('isSafeFilenameTemplate', () => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -144,3 +163,48 @@ describe('isTemplateLike', () => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user