Verify update installer SHA-256; harden download teardown
Integrity + defence-in-depth for the in-app updater: - Require and verify a SHA-256 checksum before running an installer. Each release must attach `<installer>.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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 (`<hash> 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)', () => {
|
||||
|
||||
Reference in New Issue
Block a user