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:
+56
-21
@@ -1,4 +1,4 @@
|
||||
import { app, session, BrowserWindow, type Cookie } from 'electron'
|
||||
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'
|
||||
@@ -11,6 +11,55 @@ import type { CookiesStatus, CookiesLoginResult } from '@shared/ipc'
|
||||
*/
|
||||
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')
|
||||
}
|
||||
@@ -114,26 +163,12 @@ export function openCookieLoginWindow(url: string): Promise<CookiesLoginResult>
|
||||
}
|
||||
loginWindow = win
|
||||
|
||||
// Some sites log in via an OAuth/SSO popup. Let those open as real windows
|
||||
// sharing the same partition, rather than silently swallowing the click.
|
||||
// Restrict popups to http(s) only — the same defence the main window applies
|
||||
// (index.ts) — so a logged-in page can't open a file:// popup to exfil cookies
|
||||
// to disk or use a custom-protocol popup as a pivot.
|
||||
win.webContents.setWindowOpenHandler((details) => {
|
||||
try {
|
||||
const { protocol } = new URL(details.url)
|
||||
if (protocol !== 'http:' && protocol !== 'https:') return { action: 'deny' }
|
||||
} catch {
|
||||
return { action: 'deny' } // unparseable URL — never open it
|
||||
}
|
||||
return {
|
||||
action: 'allow',
|
||||
overrideBrowserWindowOptions: {
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: { partition: PARTITION, sandbox: true, contextIsolation: true, nodeIntegration: false }
|
||||
}
|
||||
}
|
||||
})
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user