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>
121 lines
4.7 KiB
TypeScript
121 lines
4.7 KiB
TypeScript
import { dialog, type BrowserWindow } from 'electron'
|
|
import { readFileSync, writeFileSync } from 'fs'
|
|
import { getSettings, setSettings } from './settings'
|
|
import { listTemplates, replaceTemplates } from './templates'
|
|
import { isTemplateLike } from './validation'
|
|
import type {
|
|
BackupExportResult,
|
|
BackupImportResult,
|
|
Settings,
|
|
CommandTemplate
|
|
} from '@shared/ipc'
|
|
|
|
interface BackupFile {
|
|
version: 1
|
|
settings: Settings
|
|
templates: CommandTemplate[]
|
|
}
|
|
|
|
export async function exportBackup(win: BrowserWindow | undefined): Promise<BackupExportResult> {
|
|
const res = await dialog.showSaveDialog(win!, {
|
|
defaultPath: 'aerofetch-backup.json',
|
|
filters: [{ name: 'JSON', extensions: ['json'] }]
|
|
})
|
|
if (res.canceled || !res.filePath) return { ok: false }
|
|
const payload: BackupFile = { version: 1, settings: getSettings(), templates: listTemplates() }
|
|
try {
|
|
writeFileSync(res.filePath, JSON.stringify(payload, null, 2))
|
|
return { ok: true, path: res.filePath }
|
|
} catch (e) {
|
|
return { ok: false, error: (e as Error).message }
|
|
}
|
|
}
|
|
|
|
export async function importBackup(win: BrowserWindow | undefined): Promise<BackupImportResult> {
|
|
const res = await dialog.showOpenDialog(win!, {
|
|
properties: ['openFile'],
|
|
filters: [{ name: 'JSON', extensions: ['json'] }]
|
|
})
|
|
if (res.canceled || !res.filePaths[0]) return { ok: false }
|
|
|
|
let parsed: unknown
|
|
try {
|
|
parsed = JSON.parse(readFileSync(res.filePaths[0], 'utf8'))
|
|
} catch (e) {
|
|
return { ok: false, error: `Could not read backup file: ${(e as Error).message}` }
|
|
}
|
|
if (!parsed || typeof parsed !== 'object') {
|
|
return { ok: false, error: 'Not a valid AeroFetch backup file.' }
|
|
}
|
|
|
|
// setSettings/replaceTemplates validate and sanitize every field, so a
|
|
// hand-edited or partial backup file degrades gracefully rather than
|
|
// corrupting the store.
|
|
const file = parsed as Partial<BackupFile>
|
|
const incomingSettings =
|
|
file.settings && typeof file.settings === 'object'
|
|
? (file.settings as Partial<Settings>)
|
|
: undefined
|
|
// Drop non-object / id-less entries up front (audit T3) so both the consent
|
|
// check below and replaceTemplates see only well-shaped rows. Without this a
|
|
// backup with a null/garbage templates entry throws in sanitize() instead of
|
|
// degrading gracefully as this function promises.
|
|
const incomingTemplates = Array.isArray(file.templates)
|
|
? (file.templates as CommandTemplate[]).filter(isTemplateLike)
|
|
: []
|
|
|
|
// A custom-command template's `args` are extra yt-dlp flags that get spawned on
|
|
// the next download. A backup that arrives with customCommandEnabled + a default
|
|
// template carrying args would silently enable code execution the moment a
|
|
// download starts — so require an explicit, informed confirmation before
|
|
// honouring that, and import the templates in a *disabled* state if declined.
|
|
const commandTemplates = incomingTemplates.filter(
|
|
(t) => t && typeof t === 'object' && typeof t.args === 'string' && t.args.trim() !== ''
|
|
)
|
|
const wouldEnableCustomCommands =
|
|
incomingSettings?.customCommandEnabled === true && commandTemplates.length > 0
|
|
|
|
let applyCustomCommands = true
|
|
if (wouldEnableCustomCommands) {
|
|
const preview = commandTemplates
|
|
.slice(0, 10)
|
|
.map((t) => {
|
|
const name = (t.name ?? '').trim() || 'Untitled command'
|
|
const args = t.args.length > 100 ? `${t.args.slice(0, 100)}…` : t.args
|
|
return `• ${name}: ${args}`
|
|
})
|
|
.join('\n')
|
|
const more =
|
|
commandTemplates.length > 10 ? `\n…and ${commandTemplates.length - 10} more.` : ''
|
|
const choice = await dialog.showMessageBox(win!, {
|
|
type: 'warning',
|
|
buttons: ['Enable custom commands', 'Import without enabling', 'Cancel'],
|
|
defaultId: 1,
|
|
cancelId: 2,
|
|
title: 'Backup contains custom commands',
|
|
message: `This backup enables ${commandTemplates.length} custom-command template${
|
|
commandTemplates.length === 1 ? '' : 's'
|
|
}.`,
|
|
detail:
|
|
'Custom commands run extra yt-dlp flags on every download. Only enable them if you trust this backup file.\n\n' +
|
|
preview +
|
|
more
|
|
})
|
|
if (choice.response === 2) return { ok: false } // Cancel — change nothing
|
|
applyCustomCommands = choice.response === 0
|
|
}
|
|
|
|
if (incomingSettings) {
|
|
// When the user declined to enable custom commands (or there were none to
|
|
// enable), force the toggle off so an imported defaultTemplateId can't auto-run.
|
|
const safeSettings = applyCustomCommands
|
|
? incomingSettings
|
|
: { ...incomingSettings, customCommandEnabled: false }
|
|
setSettings(safeSettings)
|
|
}
|
|
if (incomingTemplates.length > 0 || Array.isArray(file.templates)) {
|
|
replaceTemplates(incomingTemplates)
|
|
}
|
|
return { ok: true }
|
|
}
|