From d52e29151e0dd27f5e3333b0fd85fb2d5f697507 Mon Sep 17 00:00:00 2001 From: "j.foucher" Date: Tue, 7 Jul 2026 13:49:22 +0200 Subject: [PATCH] =?UTF-8?q?v1.0.5=20=E2=80=94=20Fix=20/download-url=20sur?= =?UTF-8?q?=20manifests=20multi-channels=20(same-version)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug rapporté : sur un manifest avec deux entrées partageant le numéro de version 1.5.4.32 (channels proserve-firefighter vs proserve-full), le client se prenait au démarrage de l'install le garde-fou : « Incohérence serveur : l'endpoint /download-url retourne un nom de fichier différent du manifest. Manifest : proserve-full-1.5.4.32.zip. Signé : proserve-firefighter-1.5.4.32.zip. → DownloadUrl.php côté serveur doit lire le filename depuis manifest.download.url, pas via un template hardcodé. » Root cause : DownloadUrl.php faisait un `foreach ... if version match { break; }` — il retournait donc TOUJOURS la première entrée matchant le numéro, quel que soit le channel réellement cliqué côté client. Pareil que le bug de sync_one (même famille de problèmes), mais côté endpoint runtime du client. Fix côté serveur : /download-url accepte maintenant un query param optionnel `?filename=proserve-full-1.5.4.32.zip`. Si présent, le foreach filtre sur (numéro version AND basename(download.url) == filename attendu). Whitelist défensive sur le filename (path traversal). Rétro-compat : sans param, le 1er match par numéro gagne comme avant. Fix côté client : le client extrait le filename attendu de `row.Remote. Download.Url` (déjà connu, signé Ed25519) et le passe à l'endpoint. Deux sites d'appel modifiés : le call initial dans InstallVersionAsync + le callback RefreshUrlAsync (utilisé quand un segment reçoit 403/410 mid-DL et qu'il faut re-signer). Sans le refresh à jour, un DL long sur ADSL tomberait au 1er refresh forcé. Extension d'interface : ILicenseService.GetSignedDownloadUrlAsync prend maintenant un `string? expectedFilename` en 2e param. Callers qui passent null continuent de fonctionner comme avant (utile pour les tests). Bump : 1.0.4 → 1.0.5 (bug fix ciblé sur les setups multi-channel). Co-Authored-By: Claude Opus 4.7 (1M context) --- installer/PSLauncher.iss | 2 +- server/api/routes/DownloadUrl.php | 28 +++++++++++++++++-- src/PSLauncher.App/PSLauncher.App.csproj | 6 ++-- .../ViewModels/MainViewModel.cs | 15 ++++++++-- .../Licensing/ILicenseService.cs | 8 +++++- .../Licensing/LicenseService.cs | 9 +++++- 6 files changed, 58 insertions(+), 10 deletions(-) diff --git a/installer/PSLauncher.iss b/installer/PSLauncher.iss index b9ec615..4954334 100644 --- a/installer/PSLauncher.iss +++ b/installer/PSLauncher.iss @@ -11,7 +11,7 @@ #define MyAppName "PROSERVE Launcher" #define MyAppShortName "PS_Launcher" -#define MyAppVersion "1.0.4" +#define MyAppVersion "1.0.5" #define MyAppPublisher "ASTERION VR" #define MyAppURL "https://asterionvr.com" #define MyAppExeName "PS_Launcher.exe" diff --git a/server/api/routes/DownloadUrl.php b/server/api/routes/DownloadUrl.php index 39f1014..7a4babe 100644 --- a/server/api/routes/DownloadUrl.php +++ b/server/api/routes/DownloadUrl.php @@ -75,12 +75,36 @@ final class DownloadUrl Response::error('manifest_missing', 'Manifest absent côté serveur', 500); } $manifest = json_decode(file_get_contents($manifestPath), true); + + // Filename attendu, envoyé par le client (extrait de son propre manifest signé). + // Sert à disambiguer les manifestes multi-channels où plusieurs entrées + // partagent le même numéro de version (ex : proserve-firefighter-1.5.4.32 et + // proserve-full-1.5.4.32 sur v1.5.4.32). Sans ce filtre, le foreach ci-dessous + // retournait la 1re entrée matchant le numéro → l'URL signée pointait vers le + // MAUVAIS ZIP, et le client détectait le mismatch filename manifest vs signé + // et abortait (garde-fou côté MainViewModel.InstallVersionAsync). Optionnel + // pour rétro-compat avec les vieux clients (v1.0.4-) qui ne l'envoient pas ; + // dans ce cas la 1re entrée gagne, comme avant. + $expectedFilename = trim((string)($_GET['filename'] ?? '')); + // Whitelist défensive — même règle que la validation du filename lu du manifest + // plus bas dans cette route. Bloque path traversal via query param. + if ($expectedFilename !== '' && !preg_match('/^[a-zA-Z0-9._-]+\.zip$/', $expectedFilename)) { + Response::error('invalid_filename', "Query filename invalide : '{$expectedFilename}'", 400); + } + $entry = null; foreach ($manifest['versions'] ?? [] as $v) { - if (($v['version'] ?? '') === $version) { $entry = $v; break; } + if (($v['version'] ?? '') !== $version) continue; + if ($expectedFilename !== '') { + $entryFilename = basename(parse_url((string)($v['download']['url'] ?? ''), PHP_URL_PATH) ?: ''); + if ($entryFilename !== $expectedFilename) continue; + } + $entry = $v; + break; } if (!$entry) { - Response::error('version_not_found', "Version {$version} absente du manifest", 404); + $suffix = $expectedFilename !== '' ? " (filename attendu : {$expectedFilename})" : ''; + Response::error('version_not_found', "Version {$version} absente du manifest{$suffix}", 404); } // Vérif droits téléchargement diff --git a/src/PSLauncher.App/PSLauncher.App.csproj b/src/PSLauncher.App/PSLauncher.App.csproj index bec7227..748edba 100644 --- a/src/PSLauncher.App/PSLauncher.App.csproj +++ b/src/PSLauncher.App/PSLauncher.App.csproj @@ -18,9 +18,9 @@ PROSERVE Launcher © 2026 ASTERION VR — All rights reserved PSLauncher.App - 1.0.4 - 1.0.4.0 - 1.0.4.0 + 1.0.5 + 1.0.5.0 + 1.0.5.0 true diff --git a/src/PSLauncher.App/ViewModels/MainViewModel.cs b/src/PSLauncher.App/ViewModels/MainViewModel.cs index 09cfd49..135a612 100644 --- a/src/PSLauncher.App/ViewModels/MainViewModel.cs +++ b/src/PSLauncher.App/ViewModels/MainViewModel.cs @@ -1531,7 +1531,15 @@ public sealed partial class MainViewModel : ObservableObject else { _currentPeerHost = null; - var signed = await _licenseService.GetSignedDownloadUrlAsync(row.Version, ct); + // Extrait le filename attendu depuis l'URL du manifest signé pour le + // passer à /download-url. Nécessaire sur les manifestes multi-channels + // où plusieurs entrées partagent un numéro de version (firefighter vs + // full sur v1.5.4.32) : sans ça le serveur retourne l'URL du 1er match + // par numéro → mismatch filename → abort avec l'erreur InvalidOperation + // ci-dessous. Rétro-compat : un serveur qui ignore ?filename= retombe + // sur son comportement historique. + var expectedFilename = Path.GetFileName(new Uri(row.Remote.Download.Url).AbsolutePath); + var signed = await _licenseService.GetSignedDownloadUrlAsync(row.Version, expectedFilename, ct); var urlString = signed ?? row.Remote.Download.Url; url = new Uri(urlString); @@ -1573,7 +1581,10 @@ public sealed partial class MainViewModel : ObservableObject async Task RefreshUrlAsync(CancellationToken c) { if (peerSrc is not null) return peerSrc.ZipUrl; - var fresh = await _licenseService.GetSignedDownloadUrlAsync(row.Version, c); + // Passe le filename attendu — le refresh doit re-signer la même + // ligne channel qu'à l'origine, pas la 1re entrée par version. + var refreshFilename = Path.GetFileName(new Uri(row.Remote.Download.Url).AbsolutePath); + var fresh = await _licenseService.GetSignedDownloadUrlAsync(row.Version, refreshFilename, c); return fresh is null ? null : new Uri(fresh); } var job = new DownloadJob(row.Version, url, row.Remote.Download.SizeBytes, row.Remote.Download.Sha256) diff --git a/src/PSLauncher.Core/Licensing/ILicenseService.cs b/src/PSLauncher.Core/Licensing/ILicenseService.cs index 4ce4725..c247dbe 100644 --- a/src/PSLauncher.Core/Licensing/ILicenseService.cs +++ b/src/PSLauncher.Core/Licensing/ILicenseService.cs @@ -16,8 +16,14 @@ public interface ILicenseService /// Demande au serveur une URL HMAC-signée valide 1 h pour le ZIP de cette version. /// Retourne null si l'endpoint n'est pas dispo / pas configuré (ancien serveur), /// auquel cas l'appelant retombe sur l'URL publique du manifest. + /// est le nom du fichier attendu, extrait du + /// download.url côté manifest client. Sert au serveur à disambiguer les + /// entrées quand plusieurs channels partagent le même numéro de version + /// (proserve-firefighter-1.5.4.32 vs proserve-full-1.5.4.32 sur v1.5.4.32). + /// Si null ou vide, le serveur retombe sur "1re entrée matchant le numéro" + /// (rétro-compat avec les vieux serveurs qui ignorent ce paramètre). /// - Task GetSignedDownloadUrlAsync(string version, CancellationToken ct); + Task GetSignedDownloadUrlAsync(string version, string? expectedFilename, CancellationToken ct); /// /// Décrypte la clé de license stockée en cache (DPAPI CurrentUser). Retourne null diff --git a/src/PSLauncher.Core/Licensing/LicenseService.cs b/src/PSLauncher.Core/Licensing/LicenseService.cs index 4fe2e96..6b072a2 100644 --- a/src/PSLauncher.Core/Licensing/LicenseService.cs +++ b/src/PSLauncher.Core/Licensing/LicenseService.cs @@ -266,7 +266,7 @@ public sealed class LicenseService : ILicenseService return license.CanDownload(version); } - public async Task GetSignedDownloadUrlAsync(string version, CancellationToken ct) + public async Task GetSignedDownloadUrlAsync(string version, string? expectedFilename, CancellationToken ct) { var key = GetDecryptedKey(); if (string.IsNullOrEmpty(key)) return null; @@ -276,6 +276,13 @@ public sealed class LicenseService : ILicenseService // PHP. La requête reste en HTTPS donc la clé n'est pas exposée sur le câble. var encodedKey = Uri.EscapeDataString(key); var url = TrimSlash(_serverBaseUrlProvider()) + "/download-url/" + version + "?key=" + encodedKey; + // Passe le filename attendu pour disambiguer les channels sur les manifests + // multi-entrée par version. Si null/empty, le serveur retombe sur "1re entrée + // matchant le numéro" (rétro-compat avec les vieux serveurs). + if (!string.IsNullOrWhiteSpace(expectedFilename)) + { + url += "&filename=" + Uri.EscapeDataString(expectedFilename); + } try { // Timeout court : on est dans le hot path d'Install. Si OVH ne répond pas