Files
AeroFetch/test/security-guards.test.ts
T
debont80 fa92383ef4 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>
2026-06-25 05:38:02 -04:00

178 lines
6.7 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 -----------
describe('assertHttpUrl (audit F5)', () => {
it('accepts http(s) URLs and returns the normalised href', () => {
expect(assertHttpUrl('https://www.youtube.com/watch?v=abc')).toBe(
'https://www.youtube.com/watch?v=abc'
)
// http with a bare host normalises to a trailing slash
expect(assertHttpUrl('http://example.com')).toBe('http://example.com/')
})
it('trims surrounding whitespace', () => {
expect(assertHttpUrl(' https://example.com/v ')).toBe('https://example.com/v')
})
it('strips interior tabs/newlines the URL parser tolerates (normalised output)', () => {
// Returning the raw input would leak these through to argv / loadURL.
expect(assertHttpUrl('https://exa\nmple.com/v')).toBe('https://example.com/v')
const out = assertHttpUrl('https://example.com/a\tb')
expect(out).not.toContain('\t')
expect(out).not.toContain('\n')
})
it('rejects non-http(s) protocols', () => {
expect(() => assertHttpUrl('file:///C:/Windows/system32')).toThrow()
expect(() => assertHttpUrl('javascript:alert(1)')).toThrow()
expect(() => assertHttpUrl('ftp://example.com/x')).toThrow()
expect(() => assertHttpUrl('aerofetch://download?url=x')).toThrow()
})
it('rejects unparseable input', () => {
expect(() => assertHttpUrl('not a url')).toThrow()
expect(() => assertHttpUrl('')).toThrow()
})
it('can never return a value that begins with "-" (would read as a yt-dlp flag)', () => {
// A leading '-' cannot start a valid URL scheme, so it always throws —
// the returned value therefore never opens with a dash.
expect(() => assertHttpUrl('-https://example.com')).toThrow()
})
})
// --- T4: isAllowedLoginUrl — sign-in window navigation confinement ----------
describe('isAllowedLoginUrl (audit T4)', () => {
it('allows http(s) and about:blank', () => {
expect(isAllowedLoginUrl('https://accounts.google.com/signin')).toBe(true)
expect(isAllowedLoginUrl('http://example.com/login')).toBe(true)
expect(isAllowedLoginUrl('about:blank')).toBe(true)
})
it('blocks file://, the app protocol, and other external URI schemes', () => {
expect(isAllowedLoginUrl('file:///C:/Windows/System32/calc.exe')).toBe(false)
expect(isAllowedLoginUrl('aerofetch://download?url=https://evil.test')).toBe(false)
expect(isAllowedLoginUrl('ms-settings:')).toBe(false)
expect(isAllowedLoginUrl('mailto:x@y.z')).toBe(false)
expect(isAllowedLoginUrl('javascript:alert(1)')).toBe(false)
})
it('blocks unparseable input', () => {
expect(isAllowedLoginUrl('not a url')).toBe(false)
expect(isAllowedLoginUrl('')).toBe(false)
})
})
// --- 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)', () => {
it('accepts the two supported channels', () => {
expect(isYtdlpUpdateChannel('stable')).toBe(true)
expect(isYtdlpUpdateChannel('nightly')).toBe(true)
})
it('rejects an arbitrary repository spec (the --update-to RCE vector)', () => {
expect(isYtdlpUpdateChannel('evil/yt-dlp@latest')).toBe(false)
expect(isYtdlpUpdateChannel('owner/repo')).toBe(false)
})
it('rejects other yt-dlp channels not on AeroFetchs allowlist', () => {
expect(isYtdlpUpdateChannel('master')).toBe(false)
})
it('rejects non-string / empty values', () => {
expect(isYtdlpUpdateChannel('')).toBe(false)
expect(isYtdlpUpdateChannel(undefined)).toBe(false)
expect(isYtdlpUpdateChannel(null)).toBe(false)
expect(isYtdlpUpdateChannel(42)).toBe(false)
})
})