diff --git a/apps/api/src/desktop/desktop.controller.ts b/apps/api/src/desktop/desktop.controller.ts index 22ccae5..5ecce8b 100644 --- a/apps/api/src/desktop/desktop.controller.ts +++ b/apps/api/src/desktop/desktop.controller.ts @@ -1,7 +1,17 @@ -import { Controller, Get, Inject, Param, StreamableFile } from '@nestjs/common'; -import type { DesktopLatestResponse } from '@tessera/shared'; +import { + BadRequestException, + Controller, + Get, + Inject, + Param, + Query, + Res, + StreamableFile, +} from '@nestjs/common'; +import type { DesktopLatestResponse, DesktopUpdateResponse } from '@tessera/shared'; +import type { Response } from 'express'; import { Public } from '../auth/decorators/public.decorator'; -import { DesktopService } from './desktop.service'; +import { DesktopService, safeOrigin } from './desktop.service'; @Controller('desktop') export class DesktopController { @@ -22,6 +32,36 @@ export class DesktopController { return this.desktopService.getLatest(); } + // Bewusst oeffentlich (D-10, wie latest/download): der Desktop-Client prueft + // beim Start vor jeder Anmeldung. Statische Route VOR `download/:platform` + // (Konvention Route-Order, auch wenn sich die beiden hier nicht + // ueberschatten). 204 ist der vom Updater-Plugin definierte Status fuer + // "kein Update"; `passthrough` + `res.status(204)` funktioniert, weil Nest + // den Standardstatus VOR dem Handler setzt und die Antwort danach ohne + // eigenen Statuscode schreibt -- der Handler-Status gewinnt. `current` + // wird nicht deklariert, weil der Service es nicht auswertet. + @Public() + @Get('update') + update( + @Query('target') target: unknown, + @Query('arch') arch: unknown, + @Query('base') base: unknown, + @Res({ passthrough: true }) res: Response, + ): DesktopUpdateResponse | undefined { + const origin = safeOrigin(base); + if (!origin) { + throw new BadRequestException( + 'base must be an http(s) origin without path, query or credentials', + ); + } + const result = this.desktopService.getUpdate({ target, arch, origin }); + if (!result) { + res.status(204); + return undefined; + } + return result; + } + // Bewusst oeffentlich (D-10): der Download selbst braucht keine Anmeldung, // gleicher Grund wie getLatest oben. @Public() diff --git a/apps/api/src/desktop/desktop.service.spec.ts b/apps/api/src/desktop/desktop.service.spec.ts index 2faa3db..2643e2c 100644 --- a/apps/api/src/desktop/desktop.service.spec.ts +++ b/apps/api/src/desktop/desktop.service.spec.ts @@ -9,7 +9,7 @@ import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; import { IS_PUBLIC_KEY } from '../auth/decorators/public.decorator'; import { DesktopController } from './desktop.controller'; import { DesktopModule } from './desktop.module'; -import { DesktopService } from './desktop.service'; +import { DesktopService, safeOrigin } from './desktop.service'; /** * DesktopService/DesktopController.spec — HTTP-Durchstich ueber @@ -34,7 +34,23 @@ const PACKAGE_NAME = 'test-package.bin'; let packageSize: number; let packageSha256: string; -function writeManifest(files: Record) { +/** Origin des "eigenen" Servers, wie ihn der Desktop-Client als `base` mitschickt. */ +const ORIGIN = 'https://tessera.example.com'; +/** Beliebige Base64-Zeile -- die API reicht die Signatur nur durch, prueft sie nicht. */ +const SIG = 'dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZQo='; + +type ManifestHead = { + version?: string; + channel?: string; + commit?: string; + buildTime?: string; + updateVersion?: string; +}; + +function writeManifest( + files: Record, + head: ManifestHead = {}, +) { fs.writeFileSync( path.join(tempDir, 'manifest.json'), JSON.stringify({ @@ -42,11 +58,29 @@ function writeManifest(files: Record) { + const params = new URLSearchParams(query); + return `${baseUrl}/desktop/update?${params.toString()}`; +} + beforeAll(async () => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tessera-desktop-')); const packageBytes = crypto.randomBytes(64 * 1024); @@ -211,8 +245,139 @@ describe('DesktopService/DesktopController — HTTP-Durchstich (Phase 18)', () = expect(res.status).toBe(404); }); - it('Test 11 (bewusst oeffentlich): getLatest und download tragen @Public()', () => { + it('Test 11 (bewusst oeffentlich): getLatest, download und update tragen @Public()', () => { expect(Reflect.getMetadata(IS_PUBLIC_KEY, DesktopController.prototype.getLatest)).toBe(true); expect(Reflect.getMetadata(IS_PUBLIC_KEY, DesktopController.prototype.download)).toBe(true); + expect(Reflect.getMetadata(IS_PUBLIC_KEY, DesktopController.prototype.update)).toBe(true); + }); + + it('Test 12 (update, beta, signiert): 200 im dynamischen Updater-Format mit absoluter URL aus base', async () => { + writeManifest(linuxEntry(SIG), { channel: 'beta', updateVersion: '1.1.0-beta.gabc1234' }); + const res = await fetch( + updateUrl({ target: 'linux', arch: 'x86_64', current: '1.1.0', base: ORIGIN }), + ); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('application/json'); + expect(await res.json()).toEqual({ + version: '1.1.0-beta.gabc1234', + pub_date: '2026-09-16T00:00:00Z', + url: `${ORIGIN}/api-proxy/desktop/download/linux`, + signature: SIG, + notes: 'Tessera 1.1.0-beta.gabc1234', + }); + }); + + it('Test 13 (update, live): version und notes tragen die reine X.Y.Z', async () => { + writeManifest(linuxEntry(SIG), { channel: 'live', updateVersion: '1.1.0' }); + const res = await fetch( + updateUrl({ target: 'linux', arch: 'x86_64', current: '1.0.0', base: ORIGIN }), + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.version).toBe('1.1.0'); + expect(body.notes).toBe('Tessera 1.1.0'); + }); + + it('Test 14 (base mit Schlussstrich): url wird aus dem Origin ohne Schlussstrich gebildet', async () => { + writeManifest(linuxEntry(SIG), { channel: 'live', updateVersion: '1.1.0' }); + const res = await fetch( + updateUrl({ target: 'linux', arch: 'x86_64', current: '1.0.0', base: `${ORIGIN}/` }), + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.url).toBe(`${ORIGIN}/api-proxy/desktop/download/linux`); + }); + + it('Test 15 (ohne Signatur): Standard-Manifest -> 204 ohne Body', async () => { + const res = await fetch( + updateUrl({ target: 'linux', arch: 'x86_64', current: '1.0.0', base: ORIGIN }), + ); + expect(res.status).toBe(204); + expect(await res.text()).toBe(''); + }); + + it('Test 16 (updateVersion fehlt oder ungueltig trotz Signatur): 204', async () => { + writeManifest(linuxEntry(SIG)); + const resMissing = await fetch( + updateUrl({ target: 'linux', arch: 'x86_64', current: '1.0.0', base: ORIGIN }), + ); + expect(resMissing.status).toBe(204); + + writeManifest(linuxEntry(SIG), { channel: 'beta', updateVersion: '1.1.0-beta.abc1234' }); + const resInvalid = await fetch( + updateUrl({ target: 'linux', arch: 'x86_64', current: '1.0.0', base: ORIGIN }), + ); + expect(resInvalid.status).toBe(204); + }); + + it('Test 17 (Plattform/Architektur): darwin, windows ohne Eintrag, aarch64 und fehlendes target -> 204', async () => { + writeManifest(linuxEntry(SIG), { channel: 'live', updateVersion: '1.1.0' }); + const cases: Record[] = [ + { target: 'darwin', arch: 'x86_64', current: '1.0.0', base: ORIGIN }, + { target: 'windows', arch: 'x86_64', current: '1.0.0', base: ORIGIN }, + { target: 'linux', arch: 'aarch64', current: '1.0.0', base: ORIGIN }, + { arch: 'x86_64', current: '1.0.0', base: ORIGIN }, + ]; + for (const query of cases) { + const res = await fetch(updateUrl(query)); + expect(res.status, JSON.stringify(query)).toBe(204); + } + }); + + it('Test 18 (base-Validierung, HTTP): fehlendes, fremdes oder unreines base -> 400', async () => { + writeManifest(linuxEntry(SIG), { channel: 'live', updateVersion: '1.1.0' }); + const missing = await fetch(updateUrl({ target: 'linux', arch: 'x86_64', current: '1.0.0' })); + expect(missing.status).toBe(400); + const bad = [ + 'ftp://host', + 'https://user:pw@host', + 'https://host/pfad', + 'https://host/?x=1', + 'https://host/#f', + 'kein url', + ]; + for (const base of bad) { + const res = await fetch( + updateUrl({ target: 'linux', arch: 'x86_64', current: '1.0.0', base }), + ); + expect(res.status, base).toBe(400); + } + }); + + it('Test 19 (safeOrigin direkt): nur reine http(s)-Origins, kleingeschrieben, ohne Schlussstrich', () => { + expect(safeOrigin('https://tessera.example.com')).toBe('https://tessera.example.com'); + expect(safeOrigin('http://localhost:3000/')).toBe('http://localhost:3000'); + expect(safeOrigin('HTTPS://Tessera.Example.com')).toBe('https://tessera.example.com'); + expect(safeOrigin(['https://a', 'https://b'])).toBeNull(); + expect(safeOrigin(undefined)).toBeNull(); + expect(safeOrigin('')).toBeNull(); + expect(safeOrigin('https://host/pfad')).toBeNull(); + expect(safeOrigin('javascript:alert(1)')).toBeNull(); + }); + + it('Test 20 (Manifest fehlt): update -> 204', async () => { + fs.rmSync(path.join(tempDir, 'manifest.json')); + const res = await fetch( + updateUrl({ target: 'linux', arch: 'x86_64', current: '1.0.0', base: ORIGIN }), + ); + expect(res.status).toBe(204); + }); + + it('Test 21 (Manifest-Validierung): signature als Zahl macht den Eintrag ungueltig -- download/linux 404', async () => { + // Am `writeManifest()`-Helper vorbei (dessen Typ verlangt einen String). + fs.writeFileSync( + path.join(tempDir, 'manifest.json'), + JSON.stringify({ + version: '1.1.0', + channel: 'dev', + commit: 'abc1234', + buildTime: '2026-09-16T00:00:00Z', + files: { + linux: { name: PACKAGE_NAME, size: packageSize, sha256: packageSha256, signature: 123 }, + }, + }), + ); + const res = await fetch(`${baseUrl}/desktop/download/linux`); + expect(res.status).toBe(404); }); }); diff --git a/apps/api/src/desktop/desktop.service.ts b/apps/api/src/desktop/desktop.service.ts index bef1521..d7fe17a 100644 --- a/apps/api/src/desktop/desktop.service.ts +++ b/apps/api/src/desktop/desktop.service.ts @@ -4,6 +4,7 @@ import type { DesktopManifest, DesktopManifestFile, DesktopPlatform, + DesktopUpdateResponse, } from '@tessera/shared'; import * as fs from 'fs'; import * as path from 'path'; @@ -35,10 +36,58 @@ function isValidManifestFileEntry(entry: unknown): entry is DesktopManifestFile typeof candidate.name === 'string' && typeof candidate.size === 'number' && typeof candidate.sha256 === 'string' && - SHA256_HEX_RE.test(candidate.sha256) + SHA256_HEX_RE.test(candidate.sha256) && + (candidate.signature === undefined || typeof candidate.signature === 'string') ); } +/** + * Form von `updateVersion` im Manifest: `X.Y.Z` (live/dev) oder + * `X.Y.Z-beta.g` (beta) -- exakt die Form, die desktop-collect.sh + * schreibt und die der Client-Comparator (`beta_commit`) erwartet. + */ +const UPDATE_VERSION_RE = /^\d+\.\d+\.\d+(-beta\.g[0-9a-f]{7})?$/; + +/** + * RFC-3339-Zeitstempel. `pub_date` geht nur in die Antwort, wenn `buildTime` + * diese Form hat -- ein unparsebares Datum liesse `check()` im Client + * scheitern (das Plugin deserialisiert `pub_date` strikt). + */ +const RFC3339_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/; + +/** + * Validiert den Query-Parameter `base` von `GET /desktop/update` (T-KGC-02): + * nur ein reiner http/https-Origin -- kein Pfad ausser `/`, keine Query, kein + * Fragment, keine Zugangsdaten. Rueckgabe ist `url.origin` (Host + * kleingeschrieben, ohne Schlussstrich), sonst `null`. + * + * `base` wird NUR zum Bauen der Rueckgabe-URL fuer den Anfragenden verwendet, + * nie serverseitig abgerufen (kein SSRF). Ein Angreifer koennte damit + * hoechstens seinen eigenen Client auf einen fremden Download lenken -- den + * die Signaturpruefung im Client ablehnt. + */ +export function safeOrigin(base: unknown): string | null { + if (typeof base !== 'string' || base.length === 0 || base.length > 2048) { + return null; + } + let url: URL; + try { + url = new URL(base); + } catch { + return null; + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return null; + } + if (url.username !== '' || url.password !== '') { + return null; + } + if (url.pathname !== '/' || url.search !== '' || url.hash !== '') { + return null; + } + return url.origin; +} + @Injectable() export class DesktopService { private readonly logger = new Logger(DesktopService.name); @@ -78,6 +127,10 @@ export class DesktopService { this.logger.warn(`manifest.json unter ${manifestPath} hat unerwartete Form`); return null; } + if (parsed.updateVersion !== undefined && typeof parsed.updateVersion !== 'string') { + this.logger.warn(`manifest.json unter ${manifestPath} hat ein ungueltiges updateVersion`); + return null; + } for (const platform of PLATFORMS) { const entry = parsed.files[platform]; if (entry !== undefined && !isValidManifestFileEntry(entry)) { @@ -120,6 +173,67 @@ export class DesktopService { }; } + /** + * `GET /desktop/update` (quick-260917-kgc): Antwort im dynamischen Format + * von `tauri-plugin-updater` -- Pflichtfelder `version` (SemVer), `url` + * (absolut) und `signature`; optional `pub_date` (RFC 3339) und `notes`. + * `null` bedeutet "kein Update" und wird im Controller zu 204 ohne Body. + * + * `current` wird bewusst nicht ausgewertet: die Entscheidung "neuer?" trifft + * der Comparator im Client, die API kennt den Client-Commit nicht. Die + * absolute `url` entsteht aus dem validierten `origin` des Anfragenden + * (`safeOrigin`), nie aus einem serverseitigen Abruf. `/desktop/latest` + * bleibt fuer die Web-Oberflaeche mit relativen URLs. + */ + getUpdate(input: { + target: unknown; + arch: unknown; + origin: string; + }): DesktopUpdateResponse | null { + // (1) Plattform per Whitelist -- vor jedem Dateisystemzugriff. + if (!PLATFORMS.includes(input.target as DesktopPlatform)) { + return null; + } + const platform = input.target as DesktopPlatform; + + // (2) Es gibt nur x86_64-Pakete. + if (input.arch !== 'x86_64') { + return null; + } + + // (3) Manifest holen. + const manifest = this.getManifest(); + if (!manifest) { + return null; + } + + // (4) updateVersion muss vorhanden sein und die erwartete Form haben. + if ( + typeof manifest.updateVersion !== 'string' || + !UPDATE_VERSION_RE.test(manifest.updateVersion) + ) { + return null; + } + + // (5) Eintrag der Plattform mit Signatur -- ohne Signatur kein Update. + const entry = manifest.files[platform]; + if (!entry || typeof entry.signature !== 'string' || entry.signature.length === 0) { + return null; + } + + // (6) Antwort im Plugin-Format. + const response: DesktopUpdateResponse = { + version: manifest.updateVersion, + url: `${input.origin}/api-proxy/desktop/download/${platform}`, + signature: entry.signature, + notes: `Tessera ${manifest.updateVersion}`, + }; + if (typeof manifest.buildTime === 'string' && RFC3339_RE.test(manifest.buildTime)) { + response.pub_date = manifest.buildTime; + } + return response; + } + /** * `GET /desktop/download/:platform` (D-10, T-18-01): Reihenfolge ist die * Sicherheitseigenschaft -- Whitelist VOR jedem Dateisystemzugriff, der diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 941d8d8..22e6937 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -30,6 +30,12 @@ export interface DesktopManifestFile { name: string; size: number; sha256: string; + /** + * Base64-Inhalt der `.sig`-Datei des Tauri-Bundlers (minisign), geschrieben + * von desktop-collect.sh, gelesen nur von `GET /desktop/update`. Fehlt bei + * Bauten mit `--no-sign` -- dann gibt es kein Update in der App. + */ + signature?: string; } export interface DesktopManifest { @@ -37,9 +43,28 @@ export interface DesktopManifest { channel: string; commit: string; buildTime: string; + /** + * SemVer-Form, die der Updater im Client vergleicht: `X.Y.Z` bei live, + * `X.Y.Z-beta.g` bei beta (Praefix `g` Pflicht -- ein rein numerischer + * SHA mit fuehrender Null waere kein gueltiger SemVer-Identifier). + */ + updateVersion?: string; files: Partial>; } +/** + * Antwort von `GET /desktop/update` -- das dynamische Antwortformat von + * `tauri-plugin-updater`. Die Feldnamen sind vom Plugin vorgegeben, darum + * snake_case `pub_date`. `url` muss absolut sein, `signature` ist Pflicht. + */ +export interface DesktopUpdateResponse { + version: string; + pub_date?: string; + url: string; + signature: string; + notes?: string; +} + export interface DesktopLatestFile extends DesktopManifestFile { url: string; }