From 981d2dd34dc45c1ccf9879047e1876e632dfe156 Mon Sep 17 00:00:00 2001 From: Wayne Date: Wed, 24 Jun 2026 22:38:35 -0400 Subject: [PATCH 1/3] Add in-app updater: check Gitea releases, show changelog, download + run installer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new Settings → "Software update" card lets users update AeroFetch from inside the app: - src/main/updater.ts — queries the configured Gitea repo's latest release over the public REST API, compares the tag to app.getVersion(), and (on request) streams the installer asset to the OS temp dir with progress events, then launches it and quits so it can replace the app. - Security: only the trusted update host over HTTPS is ever downloaded from, and only a freshly-downloaded .exe inside our temp dir is ever executed — the download URL from the release JSON can't redirect us elsewhere. No token is ever shipped; the source repo must be anonymously readable (public). - IPC: app:update-check / -download / -run + an -progress push channel, exposed via preload (checkForAppUpdate / downloadAppUpdate / runAppUpdate / onAppUpdateProgress). - UI: "Check for updates" → shows the new version and its release notes, then "Update now" (with a download progress bar) or "View release". Up-to-date and error states handled. Preview mock added so the flow is exercisable in `npm run ui`. Note: the update source repo/host are constants at the top of updater.ts (currently debont80/AeroFetch). Downloads work once the release repo is public and the Gitea instance allows anonymous API + asset access. Co-Authored-By: Claude Opus 4.8 --- src/main/index.ts | 7 + src/main/updater.ts | 182 +++++++++++++++++++ src/preload/index.ts | 21 +++ src/renderer/src/components/SettingsView.tsx | 132 +++++++++++++- src/renderer/src/main.tsx | 22 ++- src/shared/ipc.ts | 47 +++++ 6 files changed, 409 insertions(+), 2 deletions(-) create mode 100644 src/main/updater.ts diff --git a/src/main/index.ts b/src/main/index.ts index 6305fe0..fc0184c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -11,6 +11,7 @@ import { type SystemThemeInfo } from '@shared/ipc' import { getYtdlpVersion, updateYtdlp } from './ytdlp' +import { checkForAppUpdate, downloadAppUpdate, runAppUpdate } from './updater' import { probeMedia } from './probe' import { startDownload, cancelDownload, previewCommand } from './download' import { getSettings, setSettings, ensureMediaDirs } from './settings' @@ -181,6 +182,12 @@ function createWindow(): void { function registerIpcHandlers(): void { ipcMain.handle(IpcChannels.appVersion, () => app.getVersion()) + ipcMain.handle(IpcChannels.appUpdateCheck, () => checkForAppUpdate()) + ipcMain.handle(IpcChannels.appUpdateDownload, (e, url: string) => + downloadAppUpdate(url, e.sender) + ) + ipcMain.handle(IpcChannels.appUpdateRun, (_e, filePath: string) => runAppUpdate(filePath)) + ipcMain.handle(IpcChannels.ytdlpVersion, () => getYtdlpVersion()) ipcMain.handle(IpcChannels.probe, (_e, url: string) => probeMedia(url)) diff --git a/src/main/updater.ts b/src/main/updater.ts new file mode 100644 index 0000000..63e9e8b --- /dev/null +++ b/src/main/updater.ts @@ -0,0 +1,182 @@ +import { app, shell, type WebContents } from 'electron' +import { createWriteStream } from 'fs' +import { stat } from 'fs/promises' +import { join } from 'path' +import { + IpcChannels, + type AppUpdateInfo, + type AppUpdateDownload, + type AppUpdateProgress +} from '@shared/ipc' + +// --- Update source ----------------------------------------------------------- +// The Gitea repo whose Releases host the AeroFetch installers. The updater reads +// the repo's latest release over the public REST API and downloads the installer +// asset attached to it. +// +// IMPORTANT: this points at a repo whose releases must be ANONYMOUSLY readable — +// i.e. a public repo on a Gitea instance that permits anonymous API + downloads. +// AeroFetch never ships a token; on a sign-in-required instance the check simply +// reports that it couldn't reach the server. Change OWNER/REPO to retarget. +const UPDATE_HOST = 'https://gitea.netbird.zimspace.uk:5938' +const UPDATE_OWNER = 'debont80' +const UPDATE_REPO = 'AeroFetch' +const RELEASE_API = `${UPDATE_HOST}/api/v1/repos/${UPDATE_OWNER}/${UPDATE_REPO}/releases/latest` + +// Security: only ever download or execute a file served by the trusted update +// host over HTTPS, even though downloadUrl comes from the release JSON. A +// tampered/man-in-the-middled response can't redirect us to an arbitrary host. +function isTrustedDownloadUrl(url: string): boolean { + try { + const u = new URL(url) + return u.protocol === 'https:' && u.host === new URL(UPDATE_HOST).host + } catch { + return false + } +} + +/** Where downloaded installers are written (and the only dir we'll run one from). */ +function updateDir(): string { + return app.getPath('temp') +} + +/** Parse a version like 'v1.2.3' / '1.2.3' into comparable numeric components. */ +function parseVersion(v: string): number[] { + return v + .replace(/^v/i, '') + .split(/[.\-+]/) + .map((p) => parseInt(p, 10)) + .filter((n) => !Number.isNaN(n)) +} + +/** Component-wise numeric compare: -1 if ab. */ +function compareVersions(a: string, b: string): number { + const pa = parseVersion(a) + const pb = parseVersion(b) + const len = Math.max(pa.length, pb.length) + for (let i = 0; i < len; i++) { + const x = pa[i] ?? 0 + const y = pb[i] ?? 0 + if (x !== y) return x < y ? -1 : 1 + } + return 0 +} + +interface GiteaAsset { + name: string + browser_download_url: string +} +interface GiteaRelease { + tag_name: string + body?: string + html_url?: string + assets?: GiteaAsset[] +} + +/** Pick the Windows installer asset — prefer the NSIS "Setup" .exe, else any .exe. */ +function pickInstaller(assets: GiteaAsset[]): GiteaAsset | undefined { + const exes = assets.filter((a) => /\.exe$/i.test(a.name)) + return exes.find((a) => /setup/i.test(a.name)) ?? exes[0] +} + +/** Query the configured repo's latest release and compare it to the running app. */ +export async function checkForAppUpdate(): Promise { + const currentVersion = app.getVersion() + try { + const res = await fetch(RELEASE_API, { headers: { Accept: 'application/json' } }) + if (!res.ok) { + const auth = res.status === 401 || res.status === 403 || res.status === 404 + return { + ok: false, + available: false, + currentVersion, + error: auth + ? 'Could not reach the update server (it may require sign-in for anonymous access).' + : `Update check failed (HTTP ${res.status}).` + } + } + const rel = (await res.json()) as GiteaRelease + const latestVersion = (rel.tag_name ?? '').replace(/^v/i, '') + const asset = pickInstaller(rel.assets ?? []) + const available = latestVersion !== '' && compareVersions(latestVersion, currentVersion) > 0 + return { + ok: true, + available, + currentVersion, + latestVersion, + notes: rel.body?.trim() || undefined, + htmlUrl: rel.html_url, + downloadUrl: asset?.browser_download_url, + assetName: asset?.name + } + } catch (e) { + return { + ok: false, + available: false, + currentVersion, + error: e instanceof Error ? e.message : String(e) + } + } +} + +/** Stream the installer to a temp file, pushing progress events to the renderer. */ +export async function downloadAppUpdate(url: string, wc: WebContents): Promise { + if (!isTrustedDownloadUrl(url)) { + return { ok: false, error: 'Refused to download from an untrusted location.' } + } + try { + const res = await fetch(url) + if (!res.ok || !res.body) return { ok: false, error: `Download failed (HTTP ${res.status}).` } + + const total = Number(res.headers.get('content-length')) || undefined + // Derive a safe .exe filename from the URL; fall back to a fixed name. + const urlName = decodeURIComponent(new URL(url).pathname.split('/').pop() || '') + const safeName = /^[\w.\- ]+\.exe$/i.test(urlName) ? urlName : 'AeroFetch-Setup.exe' + const filePath = join(updateDir(), safeName) + + const fileStream = createWriteStream(filePath) + let received = 0 + const reader = res.body.getReader() + for (;;) { + const { done, value } = await reader.read() + if (done) break + received += value.length + // Respect backpressure so a ~300 MB installer doesn't buffer in memory. + if (!fileStream.write(Buffer.from(value))) { + await new Promise((resolve) => fileStream.once('drain', resolve)) + } + const progress: AppUpdateProgress = { + received, + total, + fraction: total ? received / total : undefined + } + if (!wc.isDestroyed()) wc.send(IpcChannels.appUpdateProgress, progress) + } + await new Promise((resolve, reject) => + fileStream.end((err?: Error | null) => (err ? reject(err) : resolve())) + ) + return { ok: true, filePath } + } catch (e) { + return { ok: false, error: e instanceof Error ? e.message : String(e) } + } +} + +/** Launch a freshly-downloaded installer, then quit so it can replace the app. */ +export async function runAppUpdate(filePath: string): Promise<{ ok: boolean; error?: string }> { + try { + // Defence in depth: only run a .exe we just wrote into our own temp dir, never + // an arbitrary path handed over IPC. + const dir = updateDir().toLowerCase() + if (!filePath.toLowerCase().startsWith(dir) || !/\.exe$/i.test(filePath)) { + return { ok: false, error: 'Refused to run an unexpected file.' } + } + await stat(filePath) // throws if the file is missing + const err = await shell.openPath(filePath) // hand the installer to the OS + if (err) return { ok: false, error: err } + // Give the installer a beat to spawn, then quit so it can overwrite our files. + setTimeout(() => app.quit(), 1500) + return { ok: true } + } catch (e) { + return { ok: false, error: e instanceof Error ? e.message : String(e) } + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index aa93a80..eb95485 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,6 +1,9 @@ import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron' import { IpcChannels, + type AppUpdateInfo, + type AppUpdateDownload, + type AppUpdateProgress, type YtdlpVersionResult, type YtdlpUpdateChannel, type YtdlpUpdateResult, @@ -31,6 +34,24 @@ const api = { /** AeroFetch's own version string (e.g. '0.3.1'). */ getAppVersion: (): Promise => ipcRenderer.invoke(IpcChannels.appVersion), + /** Check the configured Gitea repo for a newer AeroFetch release. */ + checkForAppUpdate: (): Promise => ipcRenderer.invoke(IpcChannels.appUpdateCheck), + + /** Download the latest release's installer to a temp file. */ + downloadAppUpdate: (url: string): Promise => + ipcRenderer.invoke(IpcChannels.appUpdateDownload, url), + + /** Launch a downloaded installer and quit so it can replace the app. */ + runAppUpdate: (filePath: string): Promise<{ ok: boolean; error?: string }> => + ipcRenderer.invoke(IpcChannels.appUpdateRun, filePath), + + /** Subscribe to installer download progress. Returns an unsubscribe function. */ + onAppUpdateProgress: (cb: (p: AppUpdateProgress) => void): (() => void) => { + const listener = (_e: IpcRendererEvent, p: AppUpdateProgress): void => cb(p) + ipcRenderer.on(IpcChannels.appUpdateProgress, listener) + return () => ipcRenderer.removeListener(IpcChannels.appUpdateProgress, listener) + }, + getYtdlpVersion: (): Promise => ipcRenderer.invoke(IpcChannels.ytdlpVersion), diff --git a/src/renderer/src/components/SettingsView.tsx b/src/renderer/src/components/SettingsView.tsx index 889d490..b315262 100644 --- a/src/renderer/src/components/SettingsView.tsx +++ b/src/renderer/src/components/SettingsView.tsx @@ -10,6 +10,7 @@ import { Caption1, Spinner, Text, + ProgressBar, makeStyles, mergeClasses, tokens, @@ -31,13 +32,16 @@ import { CopyRegular, DeleteRegular, PaintBucketRegular, - AccessibilityRegular + AccessibilityRegular, + ArrowSyncRegular, + OpenRegular } from '@fluentui/react-icons' import { COOKIE_BROWSERS, type YtdlpVersionResult, type YtdlpUpdateChannel, type YtdlpUpdateResult, + type AppUpdateInfo, type MediaKind, type CookieSource, type CookieBrowser, @@ -71,6 +75,17 @@ const useStyles = makeStyles({ padding: '20px', ...shorthands.borderRadius(tokens.borderRadiusXLarge) }, + notesBox: { + maxHeight: '180px', + overflowY: 'auto', + padding: '10px 12px', + backgroundColor: tokens.colorNeutralBackground2, + ...shorthands.borderRadius(tokens.borderRadiusMedium), + border: `1px solid ${tokens.colorNeutralStroke2}`, + whiteSpace: 'pre-wrap', + fontSize: tokens.fontSizeBase200, + lineHeight: tokens.lineHeightBase300 + }, sectionHeader: { display: 'flex', alignItems: 'center', @@ -209,6 +224,14 @@ export function SettingsView(): React.JSX.Element { const [updating, setUpdating] = useState(false) const [updateResult, setUpdateResult] = useState(null) + // --- AeroFetch app self-update --- + const [appVersion, setAppVersion] = useState('') + const [appUpd, setAppUpd] = useState(null) + const [appChecking, setAppChecking] = useState(false) + const [appDownloading, setAppDownloading] = useState(false) + const [appFraction, setAppFraction] = useState(undefined) + const [appUpdError, setAppUpdError] = useState(null) + const [cookiesStatus, setCookiesStatus] = useState(null) const [loginUrl, setLoginUrl] = useState('https://www.youtube.com') const [signingIn, setSigningIn] = useState(false) @@ -223,6 +246,45 @@ export function SettingsView(): React.JSX.Element { window.api.cookiesStatus().then(setCookiesStatus) }, []) + useEffect(() => { + window.api.getAppVersion().then(setAppVersion).catch(() => {}) + return window.api.onAppUpdateProgress((p) => setAppFraction(p.fraction)) + }, []) + + async function checkAppUpdate(): Promise { + setAppChecking(true) + setAppUpd(null) + setAppUpdError(null) + try { + setAppUpd(await window.api.checkForAppUpdate()) + } catch (e) { + setAppUpdError(e instanceof Error ? e.message : String(e)) + } finally { + setAppChecking(false) + } + } + + async function installAppUpdate(): Promise { + if (!appUpd?.downloadUrl) return + setAppDownloading(true) + setAppFraction(undefined) + setAppUpdError(null) + try { + const dl = await window.api.downloadAppUpdate(appUpd.downloadUrl) + if (!dl.ok || !dl.filePath) { + setAppUpdError(dl.error ?? 'Download failed.') + return + } + const run = await window.api.runAppUpdate(dl.filePath) + // On success the app quits as the installer launches; only errors return here. + if (!run.ok) setAppUpdError(run.error ?? 'Could not start the installer.') + } catch (e) { + setAppUpdError(e instanceof Error ? e.message : String(e)) + } finally { + setAppDownloading(false) + } + } + async function exportBackup(): Promise { setExporting(true) setExportResult(null) @@ -765,6 +827,74 @@ export function SettingsView(): React.JSX.Element { )} + +
+ + Software update +
+ + {appVersion + ? `You're running AeroFetch v${appVersion}. Updates are fetched from the AeroFetch release repo.` + : 'Checking your AeroFetch version…'} + + +
+ + {appChecking && } +
+ + {appUpd?.ok && appUpd.available && ( + <> + + Version {appUpd.latestVersion} is available — here's what changed: + + {appUpd.notes &&
{appUpd.notes}
} +
+ + {appUpd.htmlUrl && ( + + )} +
+ {!appUpd.downloadUrl && ( + + This release has no installer attached — use “View release” to download it manually. + + )} + {appDownloading && } + + )} + + {appUpd?.ok && !appUpd.available && ( + You're up to date — v{appUpd.currentVersion} is the latest. + )} + + {appUpd && !appUpd.ok && ( + + {appUpd.error} + + )} + {appUpdError && ( + + {appUpdError} + + )} +
+
diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 18700db..9362047 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -45,7 +45,27 @@ if (import.meta.env.DEV && !window.api) { ] window.api = { - getAppVersion: async () => '0.3.2-preview', + getAppVersion: async () => '0.4.0-preview', + checkForAppUpdate: async () => { + await new Promise((r) => setTimeout(r, 400)) + return { + ok: true, + available: true, + currentVersion: '0.4.0', + latestVersion: '0.5.0', + notes: + '### What’s new in v0.5.0\n- In-app updater (this screen!)\n- Faster downloads\n- Bug fixes', + htmlUrl: 'https://gitea.netbird.zimspace.uk:5938/debont80/AeroFetch/releases', + downloadUrl: 'https://gitea.netbird.zimspace.uk:5938/example/AeroFetch-Setup-0.5.0.exe', + assetName: 'AeroFetch-Setup-0.5.0.exe' + } + }, + downloadAppUpdate: async () => ({ + ok: false, + error: 'Downloading updates is disabled in the browser preview.' + }), + runAppUpdate: async () => ({ ok: true }), + onAppUpdateProgress: () => () => {}, getYtdlpVersion: async () => ({ ok: true, version: '2025.06.01 (UI preview mock)' }), probe: async (url: string) => { await new Promise((r) => setTimeout(r, 700)) // simulate network latency diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index bc88370..100eaa2 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -6,6 +6,14 @@ export const IpcChannels = { /** the AeroFetch app version (package.json / app.getVersion) */ appVersion: 'app:version', + /** check the configured Gitea repo for a newer AeroFetch release */ + appUpdateCheck: 'app:update-check', + /** download the latest release's installer to a temp file */ + appUpdateDownload: 'app:update-download', + /** launch a downloaded installer and quit so it can replace the app */ + appUpdateRun: 'app:update-run', + /** main → renderer push channel for installer download progress */ + appUpdateProgress: 'app:update-progress', ytdlpVersion: 'ytdlp:version', probe: 'media:probe', downloadStart: 'download:start', @@ -188,6 +196,45 @@ export interface YtdlpVersionResult { error?: string } +/** Result of checking the configured Gitea repo for a newer AeroFetch release. */ +export interface AppUpdateInfo { + ok: boolean + /** true when latestVersion is newer than the running app */ + available: boolean + /** the running app's version, e.g. '0.4.0' */ + currentVersion: string + /** the latest release's version (tag without a leading 'v'), when ok */ + latestVersion?: string + /** the release notes / changelog body (Markdown), shown before updating */ + notes?: string + /** the release's web page on Gitea (for "View release") */ + htmlUrl?: string + /** direct download URL of the Windows installer asset, when the release has one */ + downloadUrl?: string + /** the installer asset's file name */ + assetName?: string + /** human-readable error, when ok is false */ + error?: string +} + +/** Result of downloading the update installer to a temp file. */ +export interface AppUpdateDownload { + ok: boolean + /** absolute path to the downloaded installer, when ok */ + filePath?: string + error?: string +} + +/** Live progress of the installer download (main → renderer). */ +export interface AppUpdateProgress { + /** bytes downloaded so far */ + received: number + /** total bytes, when the server reports Content-Length */ + total?: number + /** 0..1 fraction, when total is known */ + fraction?: number +} + /** * yt-dlp's self-update release channels (`--update-to `). * -- 2.47.3 From 7134a3d6344e8295c6acb352ebd3bb7930953416 Mon Sep 17 00:00:00 2001 From: debont80 Date: Thu, 25 Jun 2026 05:06:01 -0400 Subject: [PATCH 2/3] Harden app updater: per-hop redirect pinning, truncation + path-guard fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the in-app updater: - Download via Electron net.request with redirect:'manual', re-validating the host on EVERY hop. fetch() followed redirects automatically and undici hides the Location header, so the trusted-host pin only covered the first URL — a 302 from the trusted host could bounce the download to an arbitrary server. - Reject truncated downloads (received !== Content-Length) and an 'aborted' mid-stream so a partial installer is never launched; clean up the temp file on any failure. - runAppUpdate: compare the parent dir by equality instead of startsWith(), which a sibling like `_evil\x.exe` defeated. - checkForAppUpdate: 15s AbortController timeout (mirrors the yt-dlp calls) and pin htmlUrl to the trusted host before handing it to the OS browser. - parseVersion: ignore -prerelease/+build suffixes so a prerelease never sorts above its final release. - SettingsView: funnel a failed check into the single error slot so two error messages can't render at once. Co-Authored-By: Claude Opus 4.8 --- src/main/updater.ts | 200 ++++++++++++++----- src/renderer/src/components/SettingsView.tsx | 11 +- 2 files changed, 156 insertions(+), 55 deletions(-) diff --git a/src/main/updater.ts b/src/main/updater.ts index 63e9e8b..791c49c 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -1,7 +1,7 @@ -import { app, shell, type WebContents } from 'electron' -import { createWriteStream } from 'fs' -import { stat } from 'fs/promises' -import { join } from 'path' +import { app, net, shell, type WebContents } from 'electron' +import { createWriteStream, type WriteStream } from 'fs' +import { stat, unlink } from 'fs/promises' +import { join, normalize, dirname } from 'path' import { IpcChannels, type AppUpdateInfo, @@ -24,8 +24,10 @@ const UPDATE_REPO = 'AeroFetch' const RELEASE_API = `${UPDATE_HOST}/api/v1/repos/${UPDATE_OWNER}/${UPDATE_REPO}/releases/latest` // Security: only ever download or execute a file served by the trusted update -// host over HTTPS, even though downloadUrl comes from the release JSON. A -// tampered/man-in-the-middled response can't redirect us to an arbitrary host. +// host over HTTPS. downloadUrl comes from the release JSON, and Gitea 302-redirects +// release downloads to its attachment store — so this is re-checked on EVERY +// redirect hop (see downloadAppUpdate), which is what stops a tampered/MITM'd +// response from bouncing us off the trusted host. function isTrustedDownloadUrl(url: string): boolean { try { const u = new URL(url) @@ -40,11 +42,18 @@ function updateDir(): string { return app.getPath('temp') } -/** Parse a version like 'v1.2.3' / '1.2.3' into comparable numeric components. */ +/** + * Parse 'v1.2.3' / '1.2.3-rc.1' into the comparable numeric components of the + * release CORE — the x.y.z before any '-prerelease' or '+build' suffix. Dropping + * the suffix means a prerelease (e.g. 0.5.0-rc.1) never sorts ABOVE its final + * release (0.5.0): at most it compares equal, so we won't offer a prerelease as + * an "upgrade" over the same shipped version. + */ function parseVersion(v: string): number[] { return v .replace(/^v/i, '') - .split(/[.\-+]/) + .split(/[-+]/)[0] + .split('.') .map((p) => parseInt(p, 10)) .filter((n) => !Number.isNaN(n)) } @@ -82,8 +91,15 @@ function pickInstaller(assets: GiteaAsset[]): GiteaAsset | undefined { /** Query the configured repo's latest release and compare it to the running app. */ export async function checkForAppUpdate(): Promise { const currentVersion = app.getVersion() + // Bound the check so a stalled/unreachable server can't hang the UI spinner + // indefinitely — mirrors the timeouts on the yt-dlp calls. + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), 15_000) try { - const res = await fetch(RELEASE_API, { headers: { Accept: 'application/json' } }) + const res = await fetch(RELEASE_API, { + headers: { Accept: 'application/json' }, + signal: controller.signal + }) if (!res.ok) { const auth = res.status === 401 || res.status === 403 || res.status === 404 return { @@ -105,76 +121,162 @@ export async function checkForAppUpdate(): Promise { currentVersion, latestVersion, notes: rel.body?.trim() || undefined, - htmlUrl: rel.html_url, + // Only ever hand the OS browser a release page on our own trusted host. + htmlUrl: rel.html_url && isTrustedDownloadUrl(rel.html_url) ? rel.html_url : undefined, downloadUrl: asset?.browser_download_url, assetName: asset?.name } } catch (e) { + const timedOut = e instanceof Error && e.name === 'AbortError' return { ok: false, available: false, currentVersion, - error: e instanceof Error ? e.message : String(e) + error: timedOut + ? 'Update check timed out — the server took too long to respond.' + : e instanceof Error + ? e.message + : String(e) } + } finally { + clearTimeout(timer) } } +/** How long the download may stall (no bytes received) before we give up. */ +const DOWNLOAD_IDLE_TIMEOUT_MS = 60_000 + /** Stream the installer to a temp file, pushing progress events to the renderer. */ export async function downloadAppUpdate(url: string, wc: WebContents): Promise { if (!isTrustedDownloadUrl(url)) { return { ok: false, error: 'Refused to download from an untrusted location.' } } - try { - const res = await fetch(url) - if (!res.ok || !res.body) return { ok: false, error: `Download failed (HTTP ${res.status}).` } - const total = Number(res.headers.get('content-length')) || undefined - // Derive a safe .exe filename from the URL; fall back to a fixed name. - const urlName = decodeURIComponent(new URL(url).pathname.split('/').pop() || '') - const safeName = /^[\w.\- ]+\.exe$/i.test(urlName) ? urlName : 'AeroFetch-Setup.exe' - const filePath = join(updateDir(), safeName) + // Derive a safe .exe filename from the URL; fall back to a fixed name. + const urlName = decodeURIComponent(new URL(url).pathname.split('/').pop() || '') + const safeName = /^[\w.\- ]+\.exe$/i.test(urlName) ? urlName : 'AeroFetch-Setup.exe' + const filePath = join(updateDir(), safeName) - const fileStream = createWriteStream(filePath) - let received = 0 - const reader = res.body.getReader() - for (;;) { - const { done, value } = await reader.read() - if (done) break - received += value.length - // Respect backpressure so a ~300 MB installer doesn't buffer in memory. - if (!fileStream.write(Buffer.from(value))) { - await new Promise((resolve) => fileStream.once('drain', resolve)) + return new Promise((resolve) => { + let settled = false + let fileStream: WriteStream | null = null + let idle: NodeJS.Timeout | null = null + + const finish = (result: AppUpdateDownload): void => { + if (settled) return + settled = true + if (idle) clearTimeout(idle) + if (!result.ok) { + // Close the handle before unlinking (Windows won't delete an open file) + // and never leave a partial/aborted installer lying around in temp. + if (fileStream && !fileStream.destroyed) fileStream.destroy() + unlink(filePath).catch(() => {}) } - const progress: AppUpdateProgress = { - received, - total, - fraction: total ? received / total : undefined - } - if (!wc.isDestroyed()) wc.send(IpcChannels.appUpdateProgress, progress) + resolve(result) } - await new Promise((resolve, reject) => - fileStream.end((err?: Error | null) => (err ? reject(err) : resolve())) - ) - return { ok: true, filePath } - } catch (e) { - return { ok: false, error: e instanceof Error ? e.message : String(e) } - } + + // net.request (not fetch) so we can re-validate the host on EVERY redirect + // hop: Gitea 302s a release download to its attachment store, and undici's + // fetch hides the Location header under redirect:'manual', so it can't gate + // hops. 'manual' here means a hop only proceeds if we call followRedirect(). + const request = net.request({ url, redirect: 'manual' }) + + const armIdle = (): void => { + if (idle) clearTimeout(idle) + idle = setTimeout(() => { + request.abort() + finish({ ok: false, error: 'Download stalled — please try again.' }) + }, DOWNLOAD_IDLE_TIMEOUT_MS) + } + + request.on('redirect', (_status, _method, redirectUrl) => { + if (!isTrustedDownloadUrl(redirectUrl)) { + request.abort() + finish({ ok: false, error: 'Refused to follow a redirect to an untrusted location.' }) + return + } + request.followRedirect() + }) + + request.on('response', (response) => { + const status = response.statusCode + if (status < 200 || status >= 300) { + request.abort() + finish({ ok: false, error: `Download failed (HTTP ${status}).` }) + return + } + + const lenHeader = response.headers['content-length'] + const total = Number(Array.isArray(lenHeader) ? lenHeader[0] : lenHeader) || undefined + + const stream = createWriteStream(filePath) + fileStream = stream + // Electron's IncomingMessage is event-based (no .pipe). pause()/resume() + // exist at runtime but aren't in its type, so feature-detect them to apply + // backpressure — without it a ~300 MB installer can buffer in memory. + const flow = response as unknown as { pause?(): void; resume?(): void } + let received = 0 + armIdle() + + response.on('data', (chunk: Buffer) => { + armIdle() + received += chunk.length + if (!stream.write(chunk) && flow.pause && flow.resume) { + flow.pause() + stream.once('drain', () => flow.resume?.()) + } + if (!wc.isDestroyed()) { + const progress: AppUpdateProgress = { + received, + total, + fraction: total ? received / total : undefined + } + wc.send(IpcChannels.appUpdateProgress, progress) + } + }) + + response.on('end', () => { + stream.end(() => { + // A truncated download (connection dropped mid-stream) must never be run. + if (total !== undefined && received !== total) { + finish({ ok: false, error: 'The download was incomplete — please try again.' }) + return + } + finish({ ok: true, filePath }) + }) + }) + + // A mid-stream abort emits neither 'end' nor always 'error'; catch it so the + // promise can't hang. + response.on('aborted', () => finish({ ok: false, error: 'The download was interrupted.' })) + response.on('error', (e: Error) => finish({ ok: false, error: e.message })) + stream.on('error', (e: Error) => finish({ ok: false, error: e.message })) + }) + + request.on('error', (e: Error) => finish({ ok: false, error: e.message })) + request.end() + }) } +/** Let the launched installer spawn before we quit to release our files (ms). */ +const INSTALLER_HANDOFF_MS = 1500 + /** Launch a freshly-downloaded installer, then quit so it can replace the app. */ export async function runAppUpdate(filePath: string): Promise<{ ok: boolean; error?: string }> { try { - // Defence in depth: only run a .exe we just wrote into our own temp dir, never - // an arbitrary path handed over IPC. - const dir = updateDir().toLowerCase() - if (!filePath.toLowerCase().startsWith(dir) || !/\.exe$/i.test(filePath)) { + // Defence in depth: only run a .exe that sits DIRECTLY in our own temp dir, + // never an arbitrary path handed over IPC. Compare the parent directory by + // equality — startsWith() is defeated by a sibling like `_evil\x.exe`. + const expectedDir = normalize(updateDir()).toLowerCase() + const target = normalize(filePath) + if (dirname(target).toLowerCase() !== expectedDir || !/\.exe$/i.test(target)) { return { ok: false, error: 'Refused to run an unexpected file.' } } - await stat(filePath) // throws if the file is missing - const err = await shell.openPath(filePath) // hand the installer to the OS + await stat(target) // throws if the file is missing + const err = await shell.openPath(target) // hand the installer to the OS if (err) return { ok: false, error: err } // Give the installer a beat to spawn, then quit so it can overwrite our files. - setTimeout(() => app.quit(), 1500) + setTimeout(() => app.quit(), INSTALLER_HANDOFF_MS) return { ok: true } } catch (e) { return { ok: false, error: e instanceof Error ? e.message : String(e) } diff --git a/src/renderer/src/components/SettingsView.tsx b/src/renderer/src/components/SettingsView.tsx index b315262..8f52a91 100644 --- a/src/renderer/src/components/SettingsView.tsx +++ b/src/renderer/src/components/SettingsView.tsx @@ -256,7 +256,11 @@ export function SettingsView(): React.JSX.Element { setAppUpd(null) setAppUpdError(null) try { - setAppUpd(await window.api.checkForAppUpdate()) + const info = await window.api.checkForAppUpdate() + // Funnel a failed check into the single error slot rather than storing a + // second not-ok AppUpdateInfo, so only one error message can ever render. + if (info.ok) setAppUpd(info) + else setAppUpdError(info.error ?? 'Update check failed.') } catch (e) { setAppUpdError(e instanceof Error ? e.message : String(e)) } finally { @@ -883,11 +887,6 @@ export function SettingsView(): React.JSX.Element { You're up to date — v{appUpd.currentVersion} is the latest. )} - {appUpd && !appUpd.ok && ( - - {appUpd.error} - - )} {appUpdError && ( {appUpdError} -- 2.47.3 From fa92383ef4c5bf4e537df4e3a1a031d05f5a720c Mon Sep 17 00:00:00 2001 From: debont80 Date: Thu, 25 Jun 2026 05:38:02 -0400 Subject: [PATCH 3/3] Verify update installer SHA-256; harden download teardown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrity + defence-in-depth for the in-app updater: - Require and verify a SHA-256 checksum before running an installer. Each release must attach `.sha256`; downloadAppUpdate fetches it from the host-pinned origin (deriving the URL itself, not trusting the renderer), hashes the stream as it writes, and refuses to run on mismatch/missing/ unparseable. Fails closed. NB: same-host hash is integrity, not protection against a fully compromised host — only Authenticode signing covers that. - fetchTrustedText helper GETs the checksum with the same per-hop host re-validation as the installer download, plus a size cap and timeout. - finish() is now the single teardown point and aborts the request on failure, so a write error can't leave Electron pulling bytes into a dead stream; removed three now-redundant explicit abort() calls. - Clear the idle/stall timer once the body is fully received. - Export isTrustedDownloadUrl/compareVersions, extract extractSha256, and add 14 unit tests (host pin incl. userinfo spoof + wrong port, checksum parsing incl. sha512 non-slice, prerelease never outranking its release). Co-Authored-By: Claude Opus 4.8 --- src/main/updater.ts | 120 +++++++++++++++++++++++++++++++++-- test/security-guards.test.ts | 83 ++++++++++++++++++++++++ 2 files changed, 198 insertions(+), 5 deletions(-) diff --git a/src/main/updater.ts b/src/main/updater.ts index 791c49c..1b4e940 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -2,6 +2,7 @@ import { app, net, shell, type WebContents } from 'electron' import { createWriteStream, type WriteStream } from 'fs' import { stat, unlink } from 'fs/promises' import { join, normalize, dirname } from 'path' +import { createHash } from 'crypto' import { IpcChannels, type AppUpdateInfo, @@ -28,7 +29,7 @@ const RELEASE_API = `${UPDATE_HOST}/api/v1/repos/${UPDATE_OWNER}/${UPDATE_REPO}/ // release downloads to its attachment store — so this is re-checked on EVERY // redirect hop (see downloadAppUpdate), which is what stops a tampered/MITM'd // response from bouncing us off the trusted host. -function isTrustedDownloadUrl(url: string): boolean { +export function isTrustedDownloadUrl(url: string): boolean { try { const u = new URL(url) return u.protocol === 'https:' && u.host === new URL(UPDATE_HOST).host @@ -59,7 +60,7 @@ function parseVersion(v: string): number[] { } /** Component-wise numeric compare: -1 if ab. */ -function compareVersions(a: string, b: string): number { +export function compareVersions(a: string, b: string): number { const pa = parseVersion(a) const pb = parseVersion(b) const len = Math.max(pa.length, pb.length) @@ -82,6 +83,17 @@ interface GiteaRelease { assets?: GiteaAsset[] } +/** + * Pull the SHA-256 hex out of a checksum file — a bare digest, `sha256sum` + * output (` file`), or PowerShell Get-FileHash (uppercase). Returns the + * lowercase digest, or null if there's no standalone 64-char hex token (so a + * longer run like a sha512 digest is ignored rather than sliced). + */ +export function extractSha256(text: string): string | null { + const m = text.match(/\b[a-f0-9]{64}\b/i) + return m ? m[0].toLowerCase() : null +} + /** Pick the Windows installer asset — prefer the NSIS "Setup" .exe, else any .exe. */ function pickInstaller(assets: GiteaAsset[]): GiteaAsset | undefined { const exes = assets.filter((a) => /\.exe$/i.test(a.name)) @@ -146,6 +158,65 @@ export async function checkForAppUpdate(): Promise { /** How long the download may stall (no bytes received) before we give up. */ const DOWNLOAD_IDLE_TIMEOUT_MS = 60_000 +// Integrity: every release MUST attach a checksum asset named `.sha256` +// (e.g. AeroFetch-Setup-0.5.0.exe.sha256) whose contents include the installer's +// lowercase SHA-256 hex — bare, or in `sha256sum`/`Get-FileHash` form. We refuse +// to run an installer we can't verify against it. Note this is INTEGRITY + +// defence-in-depth (corruption, truncation, tampering-after-publish, a redirect +// bounced to other storage), NOT protection against a fully compromised host — +// that host serves both files, so only Authenticode signing defends against it. +const REQUIRE_CHECKSUM: boolean = true + +/** + * GET a small text resource from the trusted host, re-validating the host on + * every redirect hop (same discipline as the installer download) and capping the + * body so a hostile/huge response can't exhaust memory. Used for the .sha256 file. + */ +function fetchTrustedText( + url: string, + maxBytes = 64 * 1024, + timeoutMs = 15_000 +): Promise<{ ok: true; text: string } | { ok: false; status?: number; error: string }> { + return new Promise((resolve) => { + let settled = false + const done = (r: { ok: true; text: string } | { ok: false; status?: number; error: string }): void => { + if (settled) return + settled = true + clearTimeout(timer) + if (!r.ok) request.abort() + resolve(r) + } + const request = net.request({ url, redirect: 'manual' }) + const timer = setTimeout(() => done({ ok: false, error: 'timed out' }), timeoutMs) + request.on('redirect', (_s, _m, redirectUrl) => { + if (!isTrustedDownloadUrl(redirectUrl)) { + done({ ok: false, error: 'redirect to an untrusted location' }) + return + } + request.followRedirect() + }) + request.on('response', (response) => { + const status = response.statusCode + if (status < 200 || status >= 300) { + done({ ok: false, status, error: `HTTP ${status}` }) + return + } + const chunks: Buffer[] = [] + let size = 0 + response.on('data', (c: Buffer) => { + size += c.length + if (size > maxBytes) done({ ok: false, error: 'checksum file too large' }) + else chunks.push(c) + }) + response.on('end', () => done({ ok: true, text: Buffer.concat(chunks).toString('utf8') })) + response.on('aborted', () => done({ ok: false, error: 'interrupted' })) + response.on('error', (e: Error) => done({ ok: false, error: e.message })) + }) + request.on('error', (e: Error) => done({ ok: false, error: e.message })) + request.end() + }) +} + /** Stream the installer to a temp file, pushing progress events to the renderer. */ export async function downloadAppUpdate(url: string, wc: WebContents): Promise { if (!isTrustedDownloadUrl(url)) { @@ -157,16 +228,44 @@ export async function downloadAppUpdate(url: string, wc: WebContents): Promise { + const u = new URL(url) + u.pathname += '.sha256' + return u.toString() + })() + const sum = await fetchTrustedText(checksumUrl) + let expectedSha: string | null = null + if (sum.ok) { + expectedSha = extractSha256(sum.text) + if (!expectedSha) return { ok: false, error: "The update's checksum file is malformed." } + } else if (sum.status === 404) { + if (REQUIRE_CHECKSUM) { + return { + ok: false, + error: `This release has no checksum (${safeName}.sha256) attached — refusing to install an unverified update.` + } + } + } else { + return { ok: false, error: `Couldn't fetch the update's checksum — ${sum.error}.` } + } + return new Promise((resolve) => { let settled = false let fileStream: WriteStream | null = null let idle: NodeJS.Timeout | null = null + // Single teardown point. On failure it also aborts the request, so a write + // error (disk full, etc.) can't leave Electron pulling bytes into a dead + // stream. abort() is idempotent, so the call sites below don't repeat it. const finish = (result: AppUpdateDownload): void => { if (settled) return settled = true if (idle) clearTimeout(idle) if (!result.ok) { + request.abort() // Close the handle before unlinking (Windows won't delete an open file) // and never leave a partial/aborted installer lying around in temp. if (fileStream && !fileStream.destroyed) fileStream.destroy() @@ -184,14 +283,12 @@ export async function downloadAppUpdate(url: string, wc: WebContents): Promise { if (idle) clearTimeout(idle) idle = setTimeout(() => { - request.abort() finish({ ok: false, error: 'Download stalled — please try again.' }) }, DOWNLOAD_IDLE_TIMEOUT_MS) } request.on('redirect', (_status, _method, redirectUrl) => { if (!isTrustedDownloadUrl(redirectUrl)) { - request.abort() finish({ ok: false, error: 'Refused to follow a redirect to an untrusted location.' }) return } @@ -201,7 +298,6 @@ export async function downloadAppUpdate(url: string, wc: WebContents): Promise { const status = response.statusCode if (status < 200 || status >= 300) { - request.abort() finish({ ok: false, error: `Download failed (HTTP ${status}).` }) return } @@ -211,6 +307,8 @@ export async function downloadAppUpdate(url: string, wc: WebContents): Promise { armIdle() received += chunk.length + hash.update(chunk) if (!stream.write(chunk) && flow.pause && flow.resume) { flow.pause() stream.once('drain', () => flow.resume?.()) @@ -236,12 +335,23 @@ export async function downloadAppUpdate(url: string, wc: WebContents): Promise { + // Body fully received — "stalled" is no longer meaningful past this point. + if (idle) clearTimeout(idle) stream.end(() => { // A truncated download (connection dropped mid-stream) must never be run. if (total !== undefined && received !== total) { finish({ ok: false, error: 'The download was incomplete — please try again.' }) return } + // Verify the bytes we wrote match the release's published SHA-256. + if (expectedSha && hash.digest('hex') !== expectedSha) { + finish({ + ok: false, + error: + 'The downloaded update failed its checksum check — it may be corrupt or tampered with. Nothing was installed.' + }) + return + } finish({ ok: true, filePath }) }) }) diff --git a/test/security-guards.test.ts b/test/security-guards.test.ts index 3f2bf70..9cae025 100644 --- a/test/security-guards.test.ts +++ b/test/security-guards.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest' import { assertHttpUrl } from '../src/main/url' import { isAllowedLoginUrl } from '../src/main/cookies' +import { isTrustedDownloadUrl, extractSha256, compareVersions } from '../src/main/updater' import { isYtdlpUpdateChannel } from '@shared/ipc' // --- F5: assertHttpUrl — argument-injection guard + normalisation ----------- @@ -68,6 +69,88 @@ describe('isAllowedLoginUrl (audit T4)', () => { }) }) +// --- app-updater: isTrustedDownloadUrl — host pin across redirect hops ------- + +describe('isTrustedDownloadUrl (app-updater host pin)', () => { + const onHost = + 'https://gitea.netbird.zimspace.uk:5938/debont80/AeroFetch/releases/download/v1/AeroFetch-Setup.exe' + + it('accepts https URLs on the exact update host (incl. port)', () => { + expect(isTrustedDownloadUrl(onHost)).toBe(true) + }) + + it('rejects a different host — the redirect/MITM bounce vector', () => { + expect(isTrustedDownloadUrl('https://evil.example/AeroFetch-Setup.exe')).toBe(false) + }) + + it('rejects the right hostname on the wrong port', () => { + // host comparison includes the port, so :443 (default) is not the same origin + expect(isTrustedDownloadUrl('https://gitea.netbird.zimspace.uk/AeroFetch-Setup.exe')).toBe(false) + }) + + it('rejects plain http even on the update host', () => { + expect(isTrustedDownloadUrl('http://gitea.netbird.zimspace.uk:5938/x.exe')).toBe(false) + }) + + it('is not fooled by the trusted host placed in the userinfo', () => { + expect( + isTrustedDownloadUrl('https://gitea.netbird.zimspace.uk:5938@evil.example/x.exe') + ).toBe(false) + }) + + it('rejects unparseable input', () => { + expect(isTrustedDownloadUrl('not a url')).toBe(false) + expect(isTrustedDownloadUrl('')).toBe(false) + }) +}) + +// --- app-updater: extractSha256 — checksum-file parsing ---------------------- + +describe('extractSha256 (app-updater checksum parsing)', () => { + const hash = 'a'.repeat(64) + + it('reads a bare lowercase digest', () => { + expect(extractSha256(hash)).toBe(hash) + }) + + it('reads sha256sum format (` filename`)', () => { + expect(extractSha256(`${hash} AeroFetch-Setup-0.5.0.exe\n`)).toBe(hash) + }) + + it('lowercases a PowerShell Get-FileHash (uppercase) digest', () => { + expect(extractSha256('A'.repeat(64))).toBe(hash) + }) + + it('returns null when there is no standalone 64-char hex token', () => { + expect(extractSha256('')).toBeNull() + expect(extractSha256('not a checksum')).toBeNull() + expect(extractSha256('deadbeef')).toBeNull() // too short + }) + + it('does not slice a 64-char window out of a longer hex run (e.g. sha512)', () => { + expect(extractSha256('a'.repeat(128))).toBeNull() + }) +}) + +// --- app-updater: compareVersions — prerelease never outranks its release ---- + +describe('compareVersions (app-updater version ordering)', () => { + it('orders by numeric components', () => { + expect(compareVersions('0.5.0', '0.4.0')).toBe(1) + expect(compareVersions('0.4.0', '0.5.0')).toBe(-1) + expect(compareVersions('1.2.3', '1.2.3')).toBe(0) + }) + + it('treats a leading v as cosmetic', () => { + expect(compareVersions('v0.5.0', '0.5.0')).toBe(0) + }) + + it('never sorts a prerelease above its final release', () => { + // 0.5.0-rc.1 must not be offered as an "upgrade" over a running 0.5.0 + expect(compareVersions('0.5.0-rc.1', '0.5.0')).toBe(0) + }) +}) + // --- F1: isYtdlpUpdateChannel — --update-to allowlist ----------------------- describe('isYtdlpUpdateChannel (audit F1)', () => { -- 2.47.3