diff --git a/updater.js b/updater.js index d8e7b76..14b2c7e 100644 --- a/updater.js +++ b/updater.js @@ -3,9 +3,11 @@ const { app, shell } = require('electron'); const https = require('https'); const fs = require('fs'); const path = require('path'); +const crypto = require('crypto'); const { spawn } = require('child_process'); const GITEA_API_URL = 'https://git.viper.ipv64.net/api/v1/repos/M_Viper/Git-Manager-Gui/releases'; +const TRUSTED_UPDATE_HOST = 'git.viper.ipv64.net'; class Updater { constructor(mainWindow) { @@ -31,15 +33,28 @@ class Updater { console.log(`[Updater] Version-Check: Server(${serverVer}) vs Lokal(${localVer})`); if (this.compareVersions(serverVer, localVer) > 0) { + const asset = this.findAsset(latestRelease.assets); + const checksumAsset = this.findChecksumAsset(latestRelease.assets, asset); + const expectedSha256 = this.extractChecksumFromReleaseBody(latestRelease.body, asset?.name); + console.log("[Updater] Update verfügbar. Sende Daten an Renderer..."); this.mainWindow.webContents.send('update-available', { version: serverVer, body: latestRelease.body, url: latestRelease.html_url, - asset: this.findAsset(latestRelease.assets) + asset: asset ? { + ...asset, + checksumUrl: checksumAsset ? checksumAsset.browser_download_url : null, + expectedSha256 + } : null }); } else { console.log("[Updater] Anwendung ist auf dem neuesten Stand."); + if (!silent) { + this.mainWindow.webContents.send('update-not-available', { + version: localVer + }); + } } } catch (error) { console.error('[Updater] Fehler beim Update-Check:', error); @@ -77,7 +92,117 @@ class Updater { findAsset(assets) { if (!assets) return null; const ext = process.platform === 'win32' ? '.exe' : '.AppImage'; - return assets.find(a => a.name.toLowerCase().endsWith(ext)); + return assets.find(a => { + const name = String(a?.name || '').toLowerCase(); + const validName = /^[a-z0-9._-]+$/.test(name); + return validName && name.endsWith(ext); + }); + } + + findChecksumAsset(assets, targetAsset) { + if (!Array.isArray(assets) || !targetAsset?.name) return null; + const targetLower = String(targetAsset.name).toLowerCase(); + + const exactCandidates = [ + `${targetLower}.sha256`, + `${targetLower}.sha256sum`, + `${targetLower}.sha512`, + `${targetLower}.sha512sum` + ]; + + const exact = assets.find(a => exactCandidates.includes(String(a?.name || '').toLowerCase())); + if (exact) return exact; + + return assets.find(a => { + const name = String(a?.name || '').toLowerCase(); + return name.includes('checksum') || name.includes('checksums') || name.endsWith('.sha256') || name.endsWith('.sha256sum'); + }) || null; + } + + extractChecksumFromReleaseBody(body, fileName) { + const text = String(body || ''); + const target = String(fileName || '').trim(); + if (!text || !target) return null; + + const lines = text.split(/\r?\n/); + const targetLower = target.toLowerCase(); + for (const line of lines) { + const normalized = String(line || '').trim(); + if (!normalized) continue; + const match = normalized.match(/\b([a-fA-F0-9]{64})\b/); + if (!match) continue; + if (normalized.toLowerCase().includes(targetLower)) { + return match[1].toLowerCase(); + } + } + return null; + } + + downloadText(url) { + return new Promise((resolve, reject) => { + if (!this.isTrustedDownloadUrl(url)) { + reject(new Error('Unsichere Checksum-URL blockiert.')); + return; + } + + https.get(url, { headers: { 'User-Agent': 'Electron-Updater' } }, (res) => { + if (res.statusCode === 301 || res.statusCode === 302) { + return resolve(this.downloadText(res.headers.location || '')); + } + if (res.statusCode !== 200) { + reject(new Error(`Checksum-Download fehlgeschlagen: HTTP ${res.statusCode}`)); + return; + } + let data = ''; + res.on('data', chunk => data += chunk.toString('utf8')); + res.on('end', () => resolve(data)); + }).on('error', reject); + }); + } + + extractChecksumFromText(text, fileName) { + const lines = String(text || '').split(/\r?\n/); + const targetLower = String(fileName || '').toLowerCase(); + for (const line of lines) { + const normalized = String(line || '').trim(); + if (!normalized) continue; + const hashMatch = normalized.match(/\b([a-fA-F0-9]{64})\b/); + if (!hashMatch) continue; + if (!targetLower || normalized.toLowerCase().includes(targetLower)) { + return hashMatch[1].toLowerCase(); + } + } + return null; + } + + computeFileSha256(filePath) { + return new Promise((resolve, reject) => { + const hash = crypto.createHash('sha256'); + const stream = fs.createReadStream(filePath); + stream.on('error', reject); + stream.on('data', chunk => hash.update(chunk)); + stream.on('end', () => resolve(hash.digest('hex').toLowerCase())); + }); + } + + async resolveExpectedSha256(asset) { + const expectedFromAsset = String(asset?.expectedSha256 || '').trim().toLowerCase(); + if (/^[a-f0-9]{64}$/.test(expectedFromAsset)) return expectedFromAsset; + + const checksumUrl = String(asset?.checksumUrl || '').trim(); + if (!checksumUrl) return null; + + const checksumText = await this.downloadText(checksumUrl); + return this.extractChecksumFromText(checksumText, asset?.name); + } + + isTrustedDownloadUrl(rawUrl) { + try { + const parsed = new URL(String(rawUrl || '')); + return parsed.protocol === 'https:' && parsed.hostname === TRUSTED_UPDATE_HOST; + } catch (_) { + return false; + } } /** @@ -89,16 +214,40 @@ class Updater { return; } + if (!this.isTrustedDownloadUrl(asset.browser_download_url)) { + console.error('[Updater] Unsichere Download-URL blockiert.'); + return; + } + const tempPath = path.join(app.getPath('temp'), asset.name); console.log(`[Updater] Download gestartet: ${asset.name} -> ${tempPath}`); + let expectedSha256 = null; + try { + expectedSha256 = await this.resolveExpectedSha256(asset); + } catch (e) { + console.error('[Updater] Konnte erwartete Checksumme nicht laden:', e?.message || e); + return; + } + + if (!expectedSha256) { + console.error('[Updater] Kein SHA-256-Checksum-Wert gefunden. Update wurde aus Sicherheitsgruenden blockiert.'); + return; + } + const file = fs.createWriteStream(tempPath); const download = (url) => { + if (!this.isTrustedDownloadUrl(url)) { + console.error('[Updater] Unsicherer Redirect/Download blockiert.'); + fs.unlink(tempPath, () => {}); + return; + } + https.get(url, { headers: { 'User-Agent': 'Electron-Updater' } }, (res) => { // Handle Redirects if (res.statusCode === 301 || res.statusCode === 302) { - return download(res.headers.location); + return download(res.headers.location || ''); } if (res.statusCode !== 200) { @@ -108,9 +257,22 @@ class Updater { res.pipe(file); - file.on('finish', () => { + file.on('finish', async () => { file.close(); - console.log("[Updater] Download abgeschlossen. Initialisiere entkoppelten Installer..."); + try { + const actualSha256 = await this.computeFileSha256(tempPath); + if (actualSha256 !== expectedSha256) { + console.error('[Updater] Checksum-Validierung fehlgeschlagen. Installation wurde blockiert.'); + fs.unlink(tempPath, () => {}); + return; + } + } catch (verifyErr) { + console.error('[Updater] Checksum-Validierung konnte nicht ausgeführt werden:', verifyErr?.message || verifyErr); + fs.unlink(tempPath, () => {}); + return; + } + + console.log("[Updater] Download und Checksum-Validierung abgeschlossen. Initialisiere entkoppelten Installer..."); this.installAndQuit(tempPath); }); }).on('error', (err) => { @@ -129,15 +291,14 @@ class Updater { console.log(`[Updater] Bereite Installation vor: ${filePath}`); if (process.platform === 'win32') { - // Wir nutzen spawn mit detached: true, damit der Installer weiterläuft, - // wenn der Hauptprozess (Electron) beendet wird. try { - const child = spawn('cmd.exe', ['/c', 'start', '""', filePath], { + const child = spawn(filePath, [], { detached: true, - stdio: 'ignore' + stdio: 'ignore', + shell: false }); - child.unref(); // Trennt die Referenz zum Installer + child.unref(); console.log("[Updater] Installer-Prozess entkoppelt gestartet. App schließt in 2 Sek...");