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>
186 lines
6.4 KiB
TypeScript
186 lines
6.4 KiB
TypeScript
import { app, session, BrowserWindow, type Cookie, type WebContents } from 'electron'
|
|
import { existsSync, statSync, unlinkSync, writeFileSync } from 'fs'
|
|
import { join } from 'path'
|
|
import { assertHttpUrl } from './url'
|
|
import type { CookiesStatus, CookiesLoginResult } from '@shared/ipc'
|
|
|
|
/**
|
|
* Persisted session AeroFetch's built-in sign-in window uses. Kept separate
|
|
* from the main window's (default) session so a logged-in site can't see or
|
|
* touch anything the app itself loads.
|
|
*/
|
|
const PARTITION = 'persist:aerofetch-login'
|
|
|
|
/**
|
|
* The sign-in window renders untrusted remote content, so every navigation and
|
|
* popup is confined to web URLs — http(s), plus about:blank. This stops a
|
|
* logged-in (or malicious) page from steering the window to a file:// URL, to
|
|
* the app's own aerofetch:// protocol handler, or to any other external URI
|
|
* scheme used as a pivot. (audit T4)
|
|
*/
|
|
export function isAllowedLoginUrl(target: string): boolean {
|
|
try {
|
|
const { protocol } = new URL(target)
|
|
return protocol === 'http:' || protocol === 'https:' || protocol === 'about:'
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Apply the web-only confinement to a sign-in webContents: block top-frame
|
|
* navigations and server redirects to non-web URLs, restrict popups to web URLs
|
|
* (reusing the same secure, partitioned webPreferences), and recurse into any
|
|
* popup the page opens so a nested window can't escape the policy either. The
|
|
* window-open handler alone only governs NEW windows, not navigations of an
|
|
* existing one — both vectors are covered here. (audit T4)
|
|
*/
|
|
function hardenLoginWebContents(wc: WebContents): void {
|
|
wc.setWindowOpenHandler((details) => {
|
|
if (!isAllowedLoginUrl(details.url)) return { action: 'deny' }
|
|
return {
|
|
action: 'allow',
|
|
overrideBrowserWindowOptions: {
|
|
autoHideMenuBar: true,
|
|
webPreferences: {
|
|
partition: PARTITION,
|
|
sandbox: true,
|
|
contextIsolation: true,
|
|
nodeIntegration: false
|
|
}
|
|
}
|
|
}
|
|
})
|
|
wc.on('will-navigate', (e, navUrl) => {
|
|
if (!isAllowedLoginUrl(navUrl)) e.preventDefault()
|
|
})
|
|
wc.on('will-redirect', (e, navUrl) => {
|
|
if (!isAllowedLoginUrl(navUrl)) e.preventDefault()
|
|
})
|
|
wc.on('did-create-window', (child) => hardenLoginWebContents(child.webContents))
|
|
}
|
|
|
|
export function getCookiesFilePath(): string {
|
|
return join(app.getPath('userData'), 'cookies.txt')
|
|
}
|
|
|
|
export function getCookiesStatus(): CookiesStatus {
|
|
const p = getCookiesFilePath()
|
|
if (!existsSync(p)) return { exists: false }
|
|
return { exists: true, savedAt: statSync(p).mtimeMs }
|
|
}
|
|
|
|
export async function clearCookies(): Promise<void> {
|
|
const p = getCookiesFilePath()
|
|
if (existsSync(p)) unlinkSync(p)
|
|
await session.fromPartition(PARTITION).clearStorageData()
|
|
}
|
|
|
|
/**
|
|
* Render cookies in the Netscape cookie-jar format yt-dlp's `--cookies` reads.
|
|
* Columns: domain, includeSubdomains, path, secure, expiry (unix seconds, 0
|
|
* for session cookies), name, value.
|
|
*/
|
|
function toNetscapeCookieFile(cookies: Cookie[]): string {
|
|
const lines = ['# Netscape HTTP Cookie File', '# Generated by AeroFetch — do not edit.', '']
|
|
for (const c of cookies) {
|
|
if (!c.domain || !c.name) continue
|
|
const domain = c.hostOnly || c.domain.startsWith('.') ? c.domain : `.${c.domain}`
|
|
const includeSubdomains = domain.startsWith('.') ? 'TRUE' : 'FALSE'
|
|
const expiry = c.session || !c.expirationDate ? 0 : Math.round(c.expirationDate)
|
|
lines.push(
|
|
[domain, includeSubdomains, c.path || '/', c.secure ? 'TRUE' : 'FALSE', expiry, c.name, c.value].join(
|
|
'\t'
|
|
)
|
|
)
|
|
}
|
|
return lines.join('\n') + '\n'
|
|
}
|
|
|
|
let loginWindow: BrowserWindow | null = null
|
|
let pendingResolvers: Array<(r: CookiesLoginResult) => void> = []
|
|
|
|
function exportAndResolve(): void {
|
|
session
|
|
.fromPartition(PARTITION)
|
|
.cookies.get({})
|
|
.then((cookies) => {
|
|
writeFileSync(getCookiesFilePath(), toNetscapeCookieFile(cookies))
|
|
const result: CookiesLoginResult = { ok: true, cookieCount: cookies.length }
|
|
pendingResolvers.forEach((resolve) => resolve(result))
|
|
})
|
|
.catch((e) => {
|
|
const result: CookiesLoginResult = { ok: false, error: (e as Error).message }
|
|
pendingResolvers.forEach((resolve) => resolve(result))
|
|
})
|
|
.finally(() => {
|
|
pendingResolvers = []
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Open (or focus + navigate) a sign-in window backed by a persisted session
|
|
* partition. The user logs into whatever site they need, then closes the
|
|
* window — cookies are exported to a Netscape-format file at that point,
|
|
* ready for yt-dlp's `--cookies`. Mirrors Seal's "log in via WebView" feature.
|
|
*/
|
|
export function openCookieLoginWindow(url: string): Promise<CookiesLoginResult> {
|
|
return new Promise((resolve) => {
|
|
let validUrl: string
|
|
try {
|
|
validUrl = assertHttpUrl(url)
|
|
} catch (e) {
|
|
resolve({ ok: false, error: (e as Error).message })
|
|
return
|
|
}
|
|
|
|
if (loginWindow && !loginWindow.isDestroyed()) {
|
|
pendingResolvers.push(resolve)
|
|
loginWindow.loadURL(validUrl).catch(() => {})
|
|
loginWindow.focus()
|
|
return
|
|
}
|
|
|
|
pendingResolvers = [resolve]
|
|
let win: BrowserWindow
|
|
try {
|
|
win = new BrowserWindow({
|
|
width: 480,
|
|
height: 720,
|
|
title: 'Sign in — AeroFetch',
|
|
autoHideMenuBar: true,
|
|
webPreferences: {
|
|
partition: PARTITION,
|
|
sandbox: true,
|
|
contextIsolation: true,
|
|
nodeIntegration: false
|
|
}
|
|
})
|
|
} catch (e) {
|
|
pendingResolvers = []
|
|
resolve({ ok: false, error: (e as Error).message })
|
|
return
|
|
}
|
|
loginWindow = win
|
|
|
|
// Confine every navigation/popup to web URLs (recursively, so OAuth/SSO
|
|
// popups sharing the cookie partition are covered too), and deny all gated
|
|
// web permissions — signing in needs no camera/mic/geolocation/etc., and the
|
|
// page is untrusted. (audit T4)
|
|
hardenLoginWebContents(win.webContents)
|
|
win.webContents.session.setPermissionRequestHandler((_wc, _permission, cb) => cb(false))
|
|
|
|
win.on('closed', () => {
|
|
loginWindow = null
|
|
})
|
|
// Closing the window IS "I'm done" — export whatever the partition
|
|
// collected. The partition itself outlives the window, so this is safe
|
|
// even though cookies.get() resolves after 'close' has already fired.
|
|
win.on('close', exportAndResolve)
|
|
|
|
win.loadURL(validUrl).catch(() => {
|
|
/* navigation errors surface as Chromium's own error page */
|
|
})
|
|
})
|
|
}
|