diff --git a/README.md b/README.md index 2df72e0..1e767e9 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ It ships as a **regular installer** *and* a **single portable `.exe`** (no admin ## Download & install -Grab the latest build from the [**Releases**](https://gitea.netbird.zimspace.uk:5938/debont80/AeroFetch/releases) page: +Grab the latest build from the [**Releases**](https://github.com/debont80/AeroFetch/releases) page: - **`AeroFetch Setup .exe`** — standard installer. Installs per-user (no admin), adds a Start-menu shortcut, and auto-updates. - **`AeroFetch--portable.exe`** — single self-contained executable. No install, no admin; keeps its data in an `AeroFetch-data` folder beside the `.exe`. Ideal for locked-down or shared PCs. @@ -64,7 +64,7 @@ Each release also publishes a `.sha256` next to every executable so you can veri Requires **Node.js 24.x**. ```bash -git clone https://gitea.netbird.zimspace.uk:5938/debont80/AeroFetch.git +git clone https://github.com/debont80/AeroFetch.git cd AeroFetch npm install diff --git a/src/main/config.ts b/src/main/config.ts index 40ee060..4e9decc 100644 --- a/src/main/config.ts +++ b/src/main/config.ts @@ -2,19 +2,27 @@ * Build/host configuration (CC11): the deploy-specific constants that identify * WHERE this build talks to, separated from runtime tunables (constants.ts — * timeouts, caps, tickers) and from user settings (settings.ts). Retarget a - * fork's update source by editing these three values. + * fork's update source by editing these values. * * The update source is the main AeroFetch repo itself, made PUBLIC — so anonymous * reads work and the updater sends no auth at all; there is no secret in the bundle - * to leak. (A prior design split releases into a separate releases-only repo so the - * source could stay private; that's retired now that the source repo is public - * itself — one fewer moving part.) A private fork retargets its updater by editing + * to leak. (Earlier designs split releases into a separate releases-only repo, then + * moved the whole source repo off a self-hosted Gitea to GitHub — each retirement + * left one fewer moving part.) A private fork retargets its updater by editing * these constants and rebuilding. */ -export const UPDATE_HOST = 'https://gitea.netbird.zimspace.uk:5938' export const UPDATE_OWNER = 'debont80' export const UPDATE_REPO = 'AeroFetch' -export const RELEASE_API = `${UPDATE_HOST}/api/v1/repos/${UPDATE_OWNER}/${UPDATE_REPO}/releases/latest` +export const UPDATE_API_HOST = 'https://api.github.com' +export const RELEASE_API = `${UPDATE_API_HOST}/repos/${UPDATE_OWNER}/${UPDATE_REPO}/releases/latest` +// Hosts a release asset download may touch, HTTPS-only, re-checked on EVERY redirect +// hop (mirrors FFMPEG_TRUSTED_HOSTS below): github.com serves browser_download_url, +// which GitHub 302s to one of these asset-storage CDN hosts. +export const UPDATE_TRUSTED_HOSTS = [ + 'github.com', + 'release-assets.githubusercontent.com', + 'objects.githubusercontent.com' +] as const // --- ffmpeg dependency source ------------------------------------------------ // ffmpeg/ffprobe are NOT bundled in the installer (they're the bulk of its size); diff --git a/src/main/updater.ts b/src/main/updater.ts index 5895bc2..6b82c9b 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -1,25 +1,25 @@ import { app, net, shell, type WebContents } from 'electron' import { stat } from 'fs/promises' import { join, normalize, dirname } from 'path' -import { UPDATE_HOST, RELEASE_API } from './config' +import { UPDATE_TRUSTED_HOSTS, RELEASE_API } from './config' import { streamVerifiedFile } from './lib/verifiedDownload' import { IpcChannels, type AppUpdateInfo, type AppUpdateDownload } from '@shared/ipc' // --- Update source ----------------------------------------------------------- -// The Gitea repo whose Releases host the AeroFetch installers lives in +// The GitHub repo whose Releases host the AeroFetch installers lives in // config.ts (CC11) with the other build/host constants. The updater reads the // repo's latest release over the public REST API and downloads the installer // asset attached to it. -// Security: only ever download or execute a file served by the trusted update -// 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 +// Security: only ever download or execute a file served by an allowlisted update +// host over HTTPS. downloadUrl comes from the release JSON, and GitHub 302-redirects +// release downloads to an asset-storage CDN host — 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. +// response from bouncing us off the trusted hosts. export function isTrustedDownloadUrl(url: string): boolean { try { const u = new URL(url) - return u.protocol === 'https:' && u.host === new URL(UPDATE_HOST).host + return u.protocol === 'https:' && (UPDATE_TRUSTED_HOSTS as readonly string[]).includes(u.host) } catch { return false } @@ -60,15 +60,15 @@ export function compareVersions(a: string, b: string): number { return 0 } -interface GiteaAsset { +interface GithubAsset { name: string browser_download_url: string } -interface GiteaRelease { +interface GithubRelease { tag_name: string body?: string html_url?: string - assets?: GiteaAsset[] + assets?: GithubAsset[] } /** @@ -102,7 +102,7 @@ export function extractSha256(text: string, fileName?: string): string | null { } /** Pick the Windows installer asset — prefer the NSIS "Setup" .exe, else any .exe. */ -function pickInstaller(assets: GiteaAsset[]): GiteaAsset | undefined { +function pickInstaller(assets: GithubAsset[]): GithubAsset | undefined { const exes = assets.filter((a) => /\.exe$/i.test(a.name)) return exes.find((a) => /setup/i.test(a.name)) ?? exes[0] } @@ -116,7 +116,12 @@ export async function checkForAppUpdate(): Promise { const timer = setTimeout(() => controller.abort(), 15_000) try { const res = await fetch(RELEASE_API, { - headers: { Accept: 'application/json' }, + headers: { + Accept: 'application/vnd.github+json', + // GitHub's REST API rejects requests with no User-Agent (403). + 'User-Agent': 'AeroFetch-Updater', + 'X-GitHub-Api-Version': '2022-11-28' + }, // The API lives at an exact pinned URL and answers 200 directly. Refusing // redirects keeps the check pinned to that origin; a stray redirect (e.g. to // a sign-in page) fails safe into the catch below rather than being followed. @@ -130,11 +135,11 @@ export async function checkForAppUpdate(): Promise { available: false, currentVersion, error: auth - ? 'Could not reach the update server (it may require sign-in for anonymous access).' + ? 'Could not reach the update server (rate-limited, or the repo/release is unavailable).' : `Update check failed (HTTP ${res.status}).` } } - const rel = (await res.json()) as GiteaRelease + const rel = (await res.json()) as GithubRelease const latestVersion = (rel.tag_name ?? '').replace(/^v/i, '') const asset = pickInstaller(rel.assets ?? []) const available = latestVersion !== '' && compareVersions(latestVersion, currentVersion) > 0 @@ -302,7 +307,7 @@ export async function downloadAppUpdate(wc: WebContents, url: string): Promise { if (!wc.isDestroyed()) wc.send(IpcChannels.appUpdateProgress, progress) diff --git a/src/preload/index.ts b/src/preload/index.ts index 216ef99..51061af 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -44,7 +44,7 @@ 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. */ + /** Check the configured GitHub repo for a newer AeroFetch release. */ checkForAppUpdate: (): Promise => ipcRenderer.invoke(IpcChannels.appUpdateCheck), /** Download the latest release's installer to a temp file. */ diff --git a/src/renderer/src/mockApi.ts b/src/renderer/src/mockApi.ts index 8d5ae21..6ede11f 100644 --- a/src/renderer/src/mockApi.ts +++ b/src/renderer/src/mockApi.ts @@ -67,8 +67,8 @@ export const mockApi: Api = { 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', + htmlUrl: 'https://github.com/debont80/AeroFetch/releases', + downloadUrl: 'https://github.com/debont80/AeroFetch/releases/download/v0.5.0/AeroFetch-Setup-0.5.0.exe', assetName: 'AeroFetch-Setup-0.5.0.exe' } }, diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 24870e8..0bbefc8 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -6,7 +6,7 @@ export const IpcChannels = { /** the AeroFetch app version (package.json / app.getVersion) */ appVersion: 'app:version', - /** check the configured Gitea repo for a newer AeroFetch release */ + /** check the configured GitHub repo for a newer AeroFetch release */ appUpdateCheck: 'app:update-check', /** download the latest release's installer to a temp file */ appUpdateDownload: 'app:update-download', @@ -328,7 +328,7 @@ export interface FfmpegSetupResult { error?: string } -/** Result of checking the configured Gitea repo for a newer AeroFetch release. */ +/** Result of checking the configured GitHub repo for a newer AeroFetch release. */ export interface AppUpdateInfo { ok: boolean /** true when latestVersion is newer than the running app */ @@ -339,7 +339,7 @@ export interface AppUpdateInfo { latestVersion?: string /** the release notes / changelog body (Markdown), shown before updating */ notes?: string - /** the release's web page on Gitea (for "View release") */ + /** the release's web page on GitHub (for "View release") */ htmlUrl?: string /** direct download URL of the Windows installer asset, when the release has one */ downloadUrl?: string diff --git a/test/security-guards.test.ts b/test/security-guards.test.ts index 3bc6689..33cf1ce 100644 --- a/test/security-guards.test.ts +++ b/test/security-guards.test.ts @@ -69,35 +69,41 @@ describe('isAllowedLoginUrl (audit T4)', () => { }) }) -// --- app-updater: isTrustedDownloadUrl — host pin across redirect hops ------- +// --- app-updater: isTrustedDownloadUrl — host allowlist 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' +describe('isTrustedDownloadUrl (app-updater host allowlist)', () => { + const onHost = 'https://github.com/debont80/AeroFetch/releases/download/v1/AeroFetch-Setup.exe' + const onCdnHost = + 'https://release-assets.githubusercontent.com/github-production-release-asset/123/abc' - it('accepts https URLs on the exact update host (incl. port)', () => { + it('accepts https URLs on github.com and its asset CDN hosts', () => { expect(isTrustedDownloadUrl(onHost)).toBe(true) + expect(isTrustedDownloadUrl(onCdnHost)).toBe(true) + expect( + isTrustedDownloadUrl( + 'https://objects.githubusercontent.com/github-production-release-asset/x' + ) + ).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 a lookalike subdomain', () => { + expect(isTrustedDownloadUrl('https://github.com.evil.example/x.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('rejects a trusted hostname on an unexpected port', () => { + expect(isTrustedDownloadUrl('https://github.com:1234/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 plain http even on a trusted host', () => { + expect(isTrustedDownloadUrl('http://github.com/x.exe')).toBe(false) + }) + + it('is not fooled by a trusted host placed in the userinfo', () => { + expect(isTrustedDownloadUrl('https://github.com@evil.example/x.exe')).toBe(false) }) it('rejects unparseable input', () => { diff --git a/test/updaterDownload.test.ts b/test/updaterDownload.test.ts index 060150d..4527354 100644 --- a/test/updaterDownload.test.ts +++ b/test/updaterDownload.test.ts @@ -73,7 +73,8 @@ import type { WebContents } from 'electron' // --- helpers ------------------------------------------------------------------ -const HOST = 'https://gitea.netbird.zimspace.uk:5938' +const HOST = 'https://github.com' +const CDN_HOST = 'https://release-assets.githubusercontent.com' const ASSET = `${HOST}/debont80/AeroFetch/releases/download/v9.9.9/AeroFetch-Setup-9.9.9.exe` const INSTALLER = Buffer.from('FAKE-INSTALLER-BYTES-'.repeat(64)) const GOOD_SHA = createHash('sha256').update(INSTALLER).digest('hex') @@ -213,6 +214,21 @@ describe('downloadAppUpdate (CL4 behavior pins)', () => { expect(madeRequests[1]!.redirectsFollowed).toBe(1) }) + it('follows a redirect to the allowlisted asset CDN host (github.com → githubusercontent.com)', async () => { + requestScripts.push(checksumOk(`${GOOD_SHA} AeroFetch-Setup-9.9.9.exe\n`)) + requestScripts.push((req) => { + req.emit('redirect', 302, 'GET', `${CDN_HOST}/github-production-release-asset/abc123`) + const res = new FakeResponse(200, { 'content-length': String(INSTALLER.length) }) + req.emit('response', res) + res.emit('data', INSTALLER) + res.emit('end') + }) + const { wc } = fakeWc() + const r = await downloadAppUpdate(wc, ASSET) + expect(r.ok).toBe(true) + expect(madeRequests[1]!.redirectsFollowed).toBe(1) + }) + it('refuses a checksum-fetch redirect off the trusted host', async () => { requestScripts.push((req) => { req.emit('redirect', 302, 'GET', 'https://evil.example/x.sha256')