feat(api): GET /desktop/update — signierte Desktop-Pakete im Format des Tauri-Updaters ausliefern
- Neuer oeffentlicher Endpunkt (vor download/:platform): base-Origin wird
per safeOrigin validiert (nur http/https, kein Pfad/Query/Fragment/
Userinfo, sonst 400) und nur zum Bau der absoluten Download-URL genutzt,
nie serverseitig abgerufen
- 204 ohne Body bei fremder Plattform/Architektur, fehlendem Manifest,
fehlender Signatur oder fehlendem/ungueltigem updateVersion; sonst
{ version, pub_date (nur RFC 3339), url, signature, notes }
- Manifest-Felder signature (je Plattform) und updateVersion optional in
@tessera/shared, Eintragspruefung akzeptiert nur String-Signaturen
- 10 neue Spec-Tests, Test 11 erweitert (23 gesamt); latest/download
unveraendert
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<sha7>` (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
|
||||
|
||||
Reference in New Issue
Block a user