0d5c80fbbf
Die Zeichen-Whitelist /^[A-Za-z0-9._-]+$/ liess Namen wie ".." durch, weil Punkt und Bindestrich erlaubte Zeichen sind -- path.join(desktopDistDir, '..') loest aber in den Elternordner auf und unterlaeuft genau die Verteidigung in der Tiefe (T-18-02), die diese Zeile laut Kommentar herstellen soll. Jetzt werden "." und ".." explizit abgelehnt UND der aufgeloeste Pfad zusaetzlich gegen desktopDistDir geprueft (haelt auch kuenftige Varianten ab, falls die Whitelist anderswo wiederverwendet wird). Neue Testfaelle fuer manifest-Eintraege namens "..", "." und "../manifest.json". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
177 lines
6.5 KiB
TypeScript
177 lines
6.5 KiB
TypeScript
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
|
import type {
|
|
DesktopLatestResponse,
|
|
DesktopManifest,
|
|
DesktopManifestFile,
|
|
DesktopPlatform,
|
|
} from '@tessera/shared';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
|
|
/**
|
|
* Wertevorrat der Plattformen (Phase 18, D-10). Geschlossen -- eine dritte
|
|
* Plattform waere eine bewusste Erweiterung hier UND am Typ `DesktopPlatform`
|
|
* in packages/shared/src/index.ts.
|
|
*/
|
|
const PLATFORMS = ['windows', 'linux'] as const;
|
|
|
|
/** sha256 als Hex-String -- genau 64 Zeichen, 0-9/a-f (Gross-/Kleinschreibung egal). */
|
|
const SHA256_HEX_RE = /^[a-f0-9]{64}$/i;
|
|
|
|
/**
|
|
* Grundform eines einzelnen Datei-Eintrags im Manifest (WR-03, Code-Review
|
|
* Phase 18): `getManifest()` prueft bislang nur die Kopf-Form, nicht die
|
|
* einzelnen Plattform-Eintraege -- ein kaputter/unvollstaendiger Eintrag
|
|
* wuerde sonst unbemerkt bis in `getPackage()` durchgereicht (z. B.
|
|
* `entry.name === undefined`, das sich zu `"undefined"` coerct und dort
|
|
* fehlleitend als Dateiname gesucht wird).
|
|
*/
|
|
function isValidManifestFileEntry(entry: unknown): entry is DesktopManifestFile {
|
|
if (typeof entry !== 'object' || entry === null) {
|
|
return false;
|
|
}
|
|
const candidate = entry as Record<string, unknown>;
|
|
return (
|
|
typeof candidate.name === 'string' &&
|
|
typeof candidate.size === 'number' &&
|
|
typeof candidate.sha256 === 'string' &&
|
|
SHA256_HEX_RE.test(candidate.sha256)
|
|
);
|
|
}
|
|
|
|
@Injectable()
|
|
export class DesktopService {
|
|
private readonly logger = new Logger(DesktopService.name);
|
|
|
|
/** Resolved path to desktop-dist/ (monorepo root, or /app/desktop-dist im Abbild). */
|
|
private readonly desktopDistDir: string;
|
|
|
|
constructor() {
|
|
const envDir = process.env.DESKTOP_DIST_DIR?.trim();
|
|
this.desktopDistDir =
|
|
envDir && envDir.length > 0
|
|
? envDir
|
|
: // __dirname at runtime = apps/api/dist/desktop/ -- go up 4 levels to monorepo root
|
|
path.resolve(__dirname, '..', '..', '..', '..', 'desktop-dist');
|
|
}
|
|
|
|
/**
|
|
* Liest manifest.json. Gibt `null` zurueck (nie werfen) wenn die Datei
|
|
* fehlt, nicht parsebar ist, oder die Grundform nicht stimmt (version kein
|
|
* String, files kein Objekt) -- D-10: "fehlt das Verzeichnis/Manifest: 404
|
|
* mit klarer Meldung".
|
|
*/
|
|
getManifest(): DesktopManifest | null {
|
|
const manifestPath = path.join(this.desktopDistDir, 'manifest.json');
|
|
if (!fs.existsSync(manifestPath)) {
|
|
return null;
|
|
}
|
|
try {
|
|
const raw = fs.readFileSync(manifestPath, 'utf-8');
|
|
const parsed = JSON.parse(raw) as DesktopManifest;
|
|
if (
|
|
typeof parsed.version !== 'string' ||
|
|
typeof parsed.files !== 'object' ||
|
|
parsed.files === null ||
|
|
Array.isArray(parsed.files)
|
|
) {
|
|
this.logger.warn(`manifest.json unter ${manifestPath} hat unerwartete Form`);
|
|
return null;
|
|
}
|
|
for (const platform of PLATFORMS) {
|
|
const entry = parsed.files[platform];
|
|
if (entry !== undefined && !isValidManifestFileEntry(entry)) {
|
|
this.logger.warn(
|
|
`manifest.json unter ${manifestPath} hat einen ungueltigen Eintrag fuer Plattform ${platform}`,
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
return parsed;
|
|
} catch (error) {
|
|
this.logger.warn(`manifest.json unter ${manifestPath} konnte nicht gelesen werden: ${error}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* `GET /desktop/latest` (D-10): Kopf-Felder aus dem Manifest, je
|
|
* vorhandener Plattform eine relative Download-URL ergaenzt (Client stellt
|
|
* die API-Basis davor, siehe Objective-Abschnitt "Vom Client aus ...").
|
|
*/
|
|
getLatest(): DesktopLatestResponse {
|
|
const manifest = this.getManifest();
|
|
if (!manifest) {
|
|
throw new NotFoundException('Desktop packages are not available on this server');
|
|
}
|
|
const files: DesktopLatestResponse['files'] = {};
|
|
for (const platform of PLATFORMS) {
|
|
const entry = manifest.files[platform];
|
|
if (entry) {
|
|
files[platform] = { ...entry, url: `/desktop/download/${platform}` };
|
|
}
|
|
}
|
|
return {
|
|
version: manifest.version,
|
|
channel: manifest.channel,
|
|
commit: manifest.commit,
|
|
buildTime: manifest.buildTime,
|
|
files,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* `GET /desktop/download/:platform` (D-10, T-18-01): Reihenfolge ist die
|
|
* Sicherheitseigenschaft -- Whitelist VOR jedem Dateisystemzugriff, der
|
|
* Dateiname kommt ausschliesslich aus manifest.json, nie aus der Anfrage.
|
|
*/
|
|
getPackage(platform: string): { stream: fs.ReadStream; entry: DesktopManifestFile } {
|
|
// (1) Whitelist -- vor jedem Dateisystemzugriff.
|
|
if (!PLATFORMS.includes(platform as DesktopPlatform)) {
|
|
throw new BadRequestException('Unknown platform');
|
|
}
|
|
const knownPlatform = platform as DesktopPlatform;
|
|
|
|
// (2) Manifest holen.
|
|
const manifest = this.getManifest();
|
|
if (!manifest) {
|
|
throw new NotFoundException('Desktop packages are not available on this server');
|
|
}
|
|
|
|
// (3) Eintrag fuer diese Plattform muss existieren.
|
|
const entry = manifest.files[knownPlatform];
|
|
if (!entry) {
|
|
throw new NotFoundException(`No package for platform: ${knownPlatform}`);
|
|
}
|
|
|
|
// (4) Verteidigung in der Tiefe (T-18-02): auch ein manipuliertes
|
|
// Manifest darf nicht aus dem Ordner hinausfuehren. Die Zeichen-Whitelist
|
|
// allein reicht nicht -- "." und ".." bestehen ausschliesslich aus
|
|
// erlaubten Zeichen, meinen im Dateisystem aber "aktueller"/"uebergeordneter
|
|
// Ordner". Deshalb zusaetzlich explizit ausschliessen UND den aufgeloesten
|
|
// Pfad gegen den Zielordner pruefen (haelt auch kuenftige Varianten dieses
|
|
// Musters ab, falls die Zeichen-Whitelist anderswo wiederverwendet wird).
|
|
if (
|
|
!/^[A-Za-z0-9._-]+$/.test(entry.name) ||
|
|
entry.name === '.' ||
|
|
entry.name === '..'
|
|
) {
|
|
throw new NotFoundException(`No package for platform: ${knownPlatform}`);
|
|
}
|
|
const filePath = path.join(this.desktopDistDir, entry.name);
|
|
const resolvedRoot = path.resolve(this.desktopDistDir) + path.sep;
|
|
if (!path.resolve(filePath).startsWith(resolvedRoot)) {
|
|
throw new NotFoundException(`No package for platform: ${knownPlatform}`);
|
|
}
|
|
|
|
// (5) Datei muss existieren.
|
|
if (!fs.existsSync(filePath)) {
|
|
throw new NotFoundException(`Package file missing: ${entry.name}`);
|
|
}
|
|
|
|
// (6) Stream zurueckgeben -- kein Puffern der ganzen Datei (Installer
|
|
// sind deutlich groesser als DKV-Exporte).
|
|
return { stream: fs.createReadStream(filePath), entry };
|
|
}
|
|
}
|