diff --git a/CLAUDE.md b/CLAUDE.md index ec5a1b5..5703be1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1814,6 +1814,97 @@ déduire qu'une page suivante ne contient plus de date récente ; au-delà de 10 un extrait signalé par `Tronquee`, sans boucle ni appels indéfinis. Une source muette conserve les mêmes `EtatSourceBibliographie` et motifs que la bibliographie normale. +## Relais de couvertures — `GET /api/couvertures` (2026-08-20) + +Le cache hors-ligne des couvertures (lot A5) ne fonctionnait **que pour OpenLibrary**, et ce +n'était pas un défaut d'implémentation mais une limite non vue : il lit les octets par +`fetch()`, ce qui exige un en-tête `Access-Control-Allow-Origin`. Or le formulaire livre offre un +champ **« URL de couverture »** libre — ces images s'affichaient parfaitement (une `` n'a +que faire du CORS) sans jamais pouvoir être mises en cache. + +⚠️ **Le symptôme visible n'était pas le bon** : des erreurs CORS en console, avalées par le +`catch`, donc réputées bénignes. La vraie conséquence est ailleurs, en deux temps : couverture +absente hors-ligne, **et** fetch retenté à *chaque* affichage puisque rien n'était jamais rangé. + +**Décidé avec l'utilisateur le 2026-08-20 : relais côté serveur** plutôt que renoncer. L'image +passe par `GET /api/couvertures?url=…`, devient de **même origine**, et la question du CORS +disparaît pour tous les hébergeurs. + +### ⚠️ Un proxy est une surface SSRF — deux verrous, aucun ne suffit seul + +Le service écoute sur `127.0.0.1` et cohabite avec les autres applications du serveur YunoHost. +Un relais non borné permettrait de faire lire au serveur ce que l'appelant ne peut pas atteindre +lui-même — c'est-à-dire de contourner la contrainte d'écoute que ce fichier pose comme dure. + +| Verrou | Ce qu'il empêche | Ce qu'il ne suffit pas à empêcher | +|---|---|---| +| **L'URL doit déjà être en base** (`Livres.CoverUrl` ou `LivresSouhaites.CoverUrl`, égalité exacte) | qu'on fasse chercher une URL choisie au moment de l'appel | qu'une URL interne ait été enregistrée dans une fiche | +| **La connexion ne s'ouvre que vers une adresse publiquement routable** (`GardeAdresses`) | tout le reste, y compris le cas ci-contre | — | + +⚠️ **Le garde agit dans `SocketsHttpHandler.ConnectCallback`, pas avant la requête.** Résoudre le +nom d'abord puis laisser `HttpClient` résoudre à nouveau laisserait passer un **DNS rebinding** : +un nom qui répond une adresse publique à la vérification et `127.0.0.1` à la connexion. Ici la +socket se connecte **aux adresses déjà validées**, et à aucune autre. + +⚠️ La liste des plages est **négative**, contrairement aux filtres de type de document du projet +qui sont positifs : ici, oublier une plage est une faille, alors qu'en écarter une de trop ne +coûte qu'une couverture non mise en cache. Sont refusés bouclage, `10/8`, `172.16/12`, +`192.168/16`, `169.254/16` (métadonnées d'hébergeur), `100.64/10`, `0/8`, multicast, `fe80::/10`, +`fc00::/7`, et **les IPv4 encapsulées en IPv6** (`::ffff:127.0.0.1`) — ce dernier cas est +exactement celui qu'on oublie. + +⚠️ **Tout refus répond 404**, sans distinguer « pas en base » de « injoignable » ou « adresse +interdite ». Autrement le point d'entrée serait un **oracle** : on y lirait quelles URL +l'application connaît, et quelles adresses répondent depuis le serveur. + +### ⚠️ Les redirections sont SUIVIES — la première version avait tort + +Elles avaient d'abord été coupées, au motif qu'un 302 emmène vers une URL que rien n'a validée. +Mesuré : `covers.openlibrary.org` répond **302**, deux fois, avant d'aboutir sur `archive.org`. +Les couper refusait donc **les couvertures les plus courantes du projet**. + +Les suivre reste sûr précisément parce que le garde est **à la connexion** : il s'applique à +chaque saut, cible de redirection comprise. C'est la position du garde, et non l'interdiction de +rediriger, qui ferme le SSRF. Bornées à 3 sauts. + +### L'ordre des tentatives, côté client + +`couvertureMettreEnCache` tente **d'abord l'URL directe** — elle profite du cache HTTP du +navigateur (même URL que l'`` déjà chargée) et n'impose rien à notre serveur — et ne +retombe sur le relais que si le CORS a bloqué. Une réponse opaque (`mode: 'no-cors'`) ne +conviendrait pas : son corps est illisible, donc impossible à ranger en IndexedDB. + +Corollaire agréable : une fois le blob rangé, le `getKey` en tête de fonction court-circuite +tout. **L'erreur CORS n'apparaît donc qu'une fois par couverture et par appareil**, au lieu d'à +chaque affichage. + +### Deux pièges rencontrés, et un troisième à ne pas oublier + +- ⚠️ **`.Produces(200, contentType: "image/*")` fait échouer le DÉMARRAGE du serveur**, pas la + compilation : l'annotation OpenAPI refuse les jokers. Le type varie avec l'image, il n'est + donc pas annoté. +- ⚠️ **Le `Content-Length` ne borne rien à lui seul** : absent en *chunked*, et rien n'oblige un + serveur distant à dire la vérité. La lecture est bornée pendant qu'elle se fait (5 Mio). +- ⚠️ **Un `dotnet run` déjà en cours garde le port** : le second démarre, échoue à écouter, et + l'on mesure sans le savoir le binaire d'avant. Arrivé ici, et cela a produit trois résultats + faux avant d'être vu. + +### Vérifié en exécution + +| Cas | Réponse | +|---|---| +| URL en base, hôte sans CORS | **200 image/jpeg, 15 777 o** | +| URL en base, OpenLibrary (2 redirections) | **200 image/jpeg, 49 153 o** | +| URL inconnue de la base | 404 | +| `http://` | 404 | +| `https://127.0.0.1:5030/favicon.png` | 404 | +| `https://169.254.169.254/…` | 404 | +| sans paramètre | 404 | + +Et dans le navigateur : le magasin IndexedDB `couvertures` contient désormais **les deux** +images, dont celle de l'hébergeur sans CORS — impossible auparavant. Au rechargement suivant, +**aucune** requête vers cet hôte ni vers le relais. 479 tests au vert. + ## Installation YunoHost — éprouvée en production le 2026-08-18 Le paquet vit dans le dépôt `mabibli_ynh` (voir « Deux dépôts distincts »). Installé, diff --git a/MaBibli.Api/Endpoints/CouverturesEndpoints.cs b/MaBibli.Api/Endpoints/CouverturesEndpoints.cs new file mode 100644 index 0000000..54fa7f8 --- /dev/null +++ b/MaBibli.Api/Endpoints/CouverturesEndpoints.cs @@ -0,0 +1,40 @@ +using MaBibli.Api.Services.Couvertures; + +namespace MaBibli.Api.Endpoints; + +public static class CouverturesEndpoints +{ + /// + /// Relais d'images de couverture, pour la mise en cache hors-ligne. + /// + /// + /// ⚠️ Tout refus répond 404, qu'il s'agisse d'une URL inconnue de la base, d'un schéma + /// interdit, d'une adresse non routable ou d'un hôte injoignable. Distinguer ces cas ferait du + /// point d'entrée un oracle : on y lirait quelles URL l'application connaît, et quelles + /// adresses répondent depuis le serveur. Voir . + /// + public static IEndpointRouteBuilder MapCouverturesEndpoints(this IEndpointRouteBuilder routes) + { + routes.MapGet("/api/couvertures", async ( + string? url, + IServiceCouvertures service, + CancellationToken ct) => + { + var resultat = await service.ObtenirAsync(url, ct); + + return resultat.Trouvee + ? Results.File(resultat.Octets!, resultat.TypeContenu!) + : Results.NotFound(); + }) + .WithName("RelayerCouverture") + .WithTags("Couvertures") + .WithSummary("Relaie une couverture déjà enregistrée, en même origine.") + // ⚠️ Pas de `contentType` ici : il varie avec l'image (image/jpeg, image/png…), et + // l'annotation OpenAPI REFUSE les jokers — « image/* » fait échouer le démarrage du + // serveur, pas la compilation. Constaté en exécution. + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status404NotFound); + + return routes; + } +} diff --git a/MaBibli.Api/Program.cs b/MaBibli.Api/Program.cs index b6f9c3e..bfe30f6 100644 --- a/MaBibli.Api/Program.cs +++ b/MaBibli.Api/Program.cs @@ -1,6 +1,7 @@ using MaBibli.Api.Data; using MaBibli.Api.Endpoints; using MaBibli.Api.Services.Catalogue; +using MaBibli.Api.Services.Couvertures; using MaBibli.Api.Services.Identite; using MaBibli.Api.Services.Isbn; using MaBibli.Api.Services.Prets; @@ -50,6 +51,31 @@ builder.Services.AddHttpClient(http => builder.Services.AddScoped(); +// Relais de couvertures : rend une image de MÊME ORIGINE, seule façon de la mettre en cache +// hors-ligne quand son hébergeur n'envoie pas d'en-tête CORS (le formulaire livre accepte +// n'importe quelle URL de couverture). +// +// ⚠️ Les deux réglages du handler ne sont PAS des détails de confort : +// - ConnectCallback n'ouvre la socket que vers une adresse publiquement routable, ce qui +// ferme le SSRF y compris par DNS rebinding (voir GardeAdresses) ; +// - les redirections sont SUIVIES, mais bornées à 3. Elles avaient d'abord été coupées, à +// tort : `covers.openlibrary.org` répond 302 (mesuré), et beaucoup de CDN font de même — +// les couper refusait donc les couvertures les plus courantes. Les suivre reste sûr parce +// que le garde agit à la CONNEXION : il s'applique à chaque saut, cible de redirection +// comprise. C'est la position du garde, et non l'interdiction des redirections, qui ferme +// le SSRF. +builder.Services.AddHttpClient(http => +{ + http.Timeout = timeoutSources; + http.DefaultRequestHeaders.UserAgent.ParseAdd("MaBibli/0.1 (bibliotheque personnelle auto-hebergee)"); +}) +.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler +{ + AllowAutoRedirect = true, + MaxAutomaticRedirections = 3, + ConnectCallback = GardeAdresses.ConnecterAsync, +}); + var app = builder.Build(); // Une seule base SQLite, migrée au démarrage : pas d'étape manuelle à l'installation YunoHost. @@ -98,6 +124,7 @@ app.MapSeriesEndpoints(); app.MapRevuesEndpoints(); app.MapBibliographieEndpoints(); app.MapIdentiteEndpoints(); +app.MapCouverturesEndpoints(); // ⚠️ Le fallback a son propre pipeline : il NE passe PAS par les StaticFileOptions posées // ci-dessus. Sans lui repasser les mêmes options, « / » — c'est-à-dire le start_url de la PWA, diff --git a/MaBibli.Api/Services/Couvertures/GardeAdresses.cs b/MaBibli.Api/Services/Couvertures/GardeAdresses.cs new file mode 100644 index 0000000..fd6c673 --- /dev/null +++ b/MaBibli.Api/Services/Couvertures/GardeAdresses.cs @@ -0,0 +1,115 @@ +using System.Net; +using System.Net.Sockets; + +namespace MaBibli.Api.Services.Couvertures; + +/// +/// Garde-fou SSRF : n'autorise la connexion qu'à des adresses IP publiquement routables. +/// +/// +/// +/// ⚠️ Ce garde est la raison pour laquelle le proxy de couvertures est acceptable. Le +/// service .NET écoute sur 127.0.0.1 et vit sur un serveur YunoHost qui héberge d'autres +/// applications, souvent sur des ports locaux. Un proxy qui irait chercher n'importe quelle URL +/// permettrait de faire lire au serveur ce que le client ne peut pas atteindre lui-même — +/// c'est-à-dire exactement ce que la contrainte « écouter sur 127.0.0.1 uniquement » cherche à +/// empêcher. +/// +/// +/// ⚠️ La vérification est faite au moment de CONNECTER, pas avant la requête. Résoudre le +/// nom d'abord puis laisser HttpClient résoudre à nouveau laisserait passer un +/// DNS rebinding : un nom qui répond une adresse publique à la vérification et +/// 127.0.0.1 à la connexion. Ici, la socket se connecte aux adresses déjà validées, +/// et à aucune autre. +/// +/// +public static class GardeAdresses +{ + /// + /// Rappel de connexion pour . + /// + public static async ValueTask ConnecterAsync( + SocketsHttpConnectionContext contexte, CancellationToken ct) + { + var adresses = await Dns.GetHostAddressesAsync(contexte.DnsEndPoint.Host, ct); + var permises = adresses.Where(EstPubliquementRoutable).ToArray(); + + if (permises.Length == 0) + { + // Message volontairement muet sur ce qui a été résolu : l'appelant n'a pas à + // apprendre la topologie du réseau du serveur. + throw new HttpRequestException("Adresse non autorisée."); + } + + var socket = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true }; + + try + { + await socket.ConnectAsync(permises, contexte.DnsEndPoint.Port, ct); + return new NetworkStream(socket, ownsSocket: true); + } + catch + { + socket.Dispose(); + throw; + } + } + + /// + /// Vrai si l'adresse est publiquement routable, c'est-à-dire hors de tout ce qui pourrait + /// désigner le serveur lui-même ou son réseau local. + /// + /// + /// Liste négative, contrairement à la plupart des filtres du projet : ici, laisser + /// passer une plage oubliée est une faille, alors qu'en écarter une de trop ne coûte qu'une + /// couverture non mise en cache. + /// + public static bool EstPubliquementRoutable(IPAddress adresse) + { + // Une adresse IPv4 encapsulée en IPv6 (::ffff:127.0.0.1) doit être jugée sur son IPv4, + // sans quoi le bouclage passerait déguisé. + if (adresse.IsIPv4MappedToIPv6) + { + adresse = adresse.MapToIPv4(); + } + + if (IPAddress.IsLoopback(adresse)) + { + return false; + } + + if (adresse.AddressFamily == AddressFamily.InterNetwork) + { + var o = adresse.GetAddressBytes(); + + return o[0] switch + { + 0 => false, // 0.0.0.0/8 « ce réseau » + 10 => false, // 10.0.0.0/8 privé + 127 => false, // bouclage (déjà couvert, par sûreté) + 169 when o[1] == 254 => false, // 169.254.0.0/16 lien-local (métadonnées cloud) + 172 when o[1] >= 16 && o[1] <= 31 => false, // 172.16.0.0/12 privé + 192 when o[1] == 168 => false, // 192.168.0.0/16 privé + 192 when o[1] == 0 && o[2] == 0 => false, // 192.0.0.0/24 usages protocolaires + 100 when o[1] >= 64 && o[1] <= 127 => false, // 100.64.0.0/10 CGNAT + >= 224 => false, // multicast et réservé + _ => true, + }; + } + + if (adresse.AddressFamily == AddressFamily.InterNetworkV6) + { + if (adresse.IsIPv6LinkLocal || adresse.IsIPv6SiteLocal || adresse.IsIPv6Multicast + || adresse.Equals(IPAddress.IPv6Any)) + { + return false; + } + + // fc00::/7 : adresses locales uniques (l'équivalent IPv6 du privé). + return (adresse.GetAddressBytes()[0] & 0xFE) != 0xFC; + } + + // Ni IPv4 ni IPv6 : on ne sait pas juger, donc on refuse. + return false; + } +} diff --git a/MaBibli.Api/Services/Couvertures/ServiceCouvertures.cs b/MaBibli.Api/Services/Couvertures/ServiceCouvertures.cs new file mode 100644 index 0000000..3b9c1b9 --- /dev/null +++ b/MaBibli.Api/Services/Couvertures/ServiceCouvertures.cs @@ -0,0 +1,149 @@ +using MaBibli.Api.Data; +using Microsoft.EntityFrameworkCore; + +namespace MaBibli.Api.Services.Couvertures; + +/// Résultat d'une demande de couverture : les octets, ou rien. +public sealed record ResultatCouverture(byte[]? Octets, string? TypeContenu) +{ + public static readonly ResultatCouverture Refusee = new(null, null); + + public bool Trouvee => Octets is not null && TypeContenu is not null; +} + +public interface IServiceCouvertures +{ + Task ObtenirAsync(string? url, CancellationToken ct); +} + +/// +/// Relaie une image de couverture en même origine, pour qu'elle puisse être mise en cache +/// hors-ligne quel que soit son hébergeur. +/// +/// +/// +/// Pourquoi ce service existe. Le cache hors-ligne des couvertures (lot A5) lit les octets +/// par fetch(), ce qui exige un en-tête Access-Control-Allow-Origin. OpenLibrary +/// l'envoie, mais le formulaire livre accepte n'importe quelle URL de couverture : ces +/// images s'affichaient bien (une balise <img> n'a que faire du CORS) sans jamais +/// pouvoir être mises en cache. Relayées par notre propre serveur, elles deviennent de même +/// origine, et la question du CORS disparaît. +/// +/// +/// ⚠️ Un proxy est une surface d'attaque (SSRF), et il est borné par DEUX verrous +/// indépendants — les deux sont nécessaires, aucun ne suffit : +/// +/// +/// +/// L'URL doit déjà exister en base comme couverture d'un livre ou d'une envie. Le proxy +/// ne va donc jamais chercher une URL choisie au moment de l'appel : seulement une valeur que +/// l'application détient déjà. +/// +/// +/// La connexion n'est ouverte que vers une adresse publiquement routable +/// (). C'est ce second verrou qui tient si quelqu'un enregistre +/// d'abord une fiche portant une URL interne : la valeur serait en base, la connexion serait +/// refusée quand même. +/// +/// +/// +/// ⚠️ Tout refus est indistinct côté appelant (voir l'endpoint) : distinguer « pas en +/// base » de « injoignable » ferait du proxy un oracle qui renseigne sur le réseau du serveur. +/// +/// +public sealed class ServiceCouvertures(HttpClient http, MaBibliDbContext db) : IServiceCouvertures +{ + /// + /// Plafond de lecture. Une couverture de livre pèse quelques dizaines de kilo-octets ; ce + /// plafond n'est pas là pour trier les images mais pour qu'une URL pointant sur un très gros + /// fichier ne puisse pas remplir la mémoire du serveur. + /// + public const int TailleMaximale = 5 * 1024 * 1024; + + public async Task ObtenirAsync(string? url, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(url) + || !Uri.TryCreate(url, UriKind.Absolute, out var uri) + || uri.Scheme != Uri.UriSchemeHttps) + { + // https uniquement : l'application est servie en https par YunoHost, une image en + // clair y serait de toute façon bloquée par le navigateur (contenu mixte). + return ResultatCouverture.Refusee; + } + + if (!await EstConnueAsync(url, ct)) + { + return ResultatCouverture.Refusee; + } + + try + { + using var reponse = await http.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, ct); + + // Les redirections sont suivies (3 au plus, voir Program.cs) : OpenLibrary répond + // 302 sur ses couvertures. Chaque saut repasse par le garde d'adresses, qui agit à + // la connexion — c'est lui qui borne, pas l'interdiction de rediriger. + if (!reponse.IsSuccessStatusCode) + { + return ResultatCouverture.Refusee; + } + + var type = reponse.Content.Headers.ContentType?.MediaType; + if (type is null || !type.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) + { + return ResultatCouverture.Refusee; + } + + if (reponse.Content.Headers.ContentLength > TailleMaximale) + { + return ResultatCouverture.Refusee; + } + + var octets = await LireBorneAsync(reponse.Content, ct); + + return octets is null ? ResultatCouverture.Refusee : new ResultatCouverture(octets, type); + } + catch (Exception) + { + // Hôte injoignable, adresse refusée par le garde, délai dépassé : une couverture non + // relayée reste un agrément perdu, jamais une erreur à propager. + return ResultatCouverture.Refusee; + } + } + + /// + /// L'URL est-elle déjà enregistrée comme couverture ? Comparaison exacte : c'est la + /// valeur stockée qui autorise, pas une ressemblance. + /// + private async Task EstConnueAsync(string url, CancellationToken ct) => + await db.Livres.AnyAsync(l => l.CoverUrl == url, ct) + || await db.LivresSouhaites.AnyAsync(s => s.CoverUrl == url, ct); + + /// + /// Lit au plus octets, et rend null si le corps dépasse. + /// + /// + /// ⚠️ Le Content-Length vérifié plus haut ne suffit pas : il est absent d'une + /// réponse en chunked, et rien n'oblige un serveur distant à dire la vérité. + /// + private static async Task LireBorneAsync(HttpContent contenu, CancellationToken ct) + { + await using var flux = await contenu.ReadAsStreamAsync(ct); + using var tampon = new MemoryStream(); + + var morceau = new byte[81920]; + int lus; + + while ((lus = await flux.ReadAsync(morceau, ct)) > 0) + { + if (tampon.Length + lus > TailleMaximale) + { + return null; + } + + tampon.Write(morceau, 0, lus); + } + + return tampon.ToArray(); + } +} diff --git a/MaBibli.Client/wwwroot/js/cache-hors-ligne.js b/MaBibli.Client/wwwroot/js/cache-hors-ligne.js index b3c0c98..38ec462 100644 --- a/MaBibli.Client/wwwroot/js/cache-hors-ligne.js +++ b/MaBibli.Client/wwwroot/js/cache-hors-ligne.js @@ -99,6 +99,17 @@ export function surveiller(reference) { // // OpenLibrary envoie `Access-Control-Allow-Origin: *` (vérifié) : un fetch cross-origin normal // suffit, pas besoin du contournement `no-cors`/réponse opaque. +// +// ⚠️ Mais le formulaire livre accepte N'IMPORTE QUELLE URL de couverture, et la plupart des +// hébergeurs n'envoient aucun en-tête CORS. Ces images s'affichaient bien — une n'a que +// faire du CORS — sans jamais pouvoir être mises en cache, et le fetch repartait à CHAQUE +// affichage puisque rien n'était jamais rangé. D'où le repli par `/api/couvertures`, qui relaie +// l'image en MÊME ORIGINE (voir ServiceCouvertures côté serveur, et ses deux verrous anti-SSRF). +// +// ⚠️ L'ordre compte : on tente d'abord l'URL directe, qui profite du cache HTTP du navigateur +// (même URL que l' déjà chargée) et n'impose rien à notre serveur. Le relais n'entre en jeu +// que pour ce que le CORS a bloqué. Une réponse opaque (`mode: 'no-cors'`) ne conviendrait pas : +// son corps est illisible, donc impossible à ranger en IndexedDB. export async function couvertureMettreEnCache(url) { try { const base = await ouvrir(); @@ -106,8 +117,20 @@ export async function couvertureMettreEnCache(url) { const existe = await attendre(tx.objectStore(MAGASIN_COUVERTURES).getKey(url), tx); if (existe !== undefined) return; // Déjà en cache : pas de re-téléchargement. - const reponse = await fetch(url, { cache: 'force-cache' }); - if (!reponse.ok) return; // 404/502 intermittent (CLAUDE.md) : rien à ranger. + let reponse = null; + + try { + reponse = await fetch(url, { cache: 'force-cache' }); + } catch (eDirect) { + // Bloqué par le CORS, ou hôte injoignable : on ne sait pas lequel, et peu importe. + } + + if (!reponse || !reponse.ok) { + reponse = await fetch('/api/couvertures?url=' + encodeURIComponent(url)); + } + + // 404/502 intermittent d'OpenLibrary (CLAUDE.md), ou relais refusé : rien à ranger. + if (!reponse.ok) return; const blob = await reponse.blob(); const ecriture = base.transaction(MAGASIN_COUVERTURES, 'readwrite'); diff --git a/MaBibli.Tests/ServiceCouverturesTests.cs b/MaBibli.Tests/ServiceCouverturesTests.cs new file mode 100644 index 0000000..7230a2d --- /dev/null +++ b/MaBibli.Tests/ServiceCouverturesTests.cs @@ -0,0 +1,183 @@ +using System.Net; +using MaBibli.Api.Data; +using MaBibli.Api.Services.Couvertures; +using MaBibli.Shared.Entites; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace MaBibli.Tests; + +/// +/// Relais de couvertures : ce qui est testé ici, ce n'est pas qu'une image arrive, c'est +/// tout ce que le proxy refuse d'aller chercher. +/// +/// +/// ⚠️ Un proxy dont le bornage se relâche ne « marche moins bien » pas : il devient un moyen de +/// faire lire au serveur ce que l'appelant ne peut pas atteindre lui-même. Le service écoute sur +/// 127.0.0.1 et cohabite avec les autres applications YunoHost du serveur — c'est +/// exactement ce que la contrainte d'écoute cherche à empêcher. +/// +public class ServiceCouverturesTests : IDisposable +{ + private readonly SqliteConnection _connexion; + private readonly MaBibliDbContext _db; + + private const string UrlConnue = "https://editions-exemple.fr/couverture.jpg"; + + public ServiceCouverturesTests() + { + _connexion = new SqliteConnection("Data Source=:memory:"); + _connexion.Open(); + + _db = new MaBibliDbContext(new DbContextOptionsBuilder() + .UseSqlite(_connexion).Options); + _db.Database.EnsureCreated(); + + var livre = new Livre { Titre = "Un livre", CoverUrl = UrlConnue }; + livre.RecalculerFormes(); + _db.Livres.Add(livre); + _db.SaveChanges(); + } + + /// + /// Client qui échoue à tout coup : ces tests portent sur le bornage, et un refus doit + /// tomber avant tout appel réseau. Si l'un d'eux passait jusqu'ici, il échouerait — + /// c'est précisément ce qu'on veut constater. + /// + private ServiceCouvertures Service() => + new(new HttpClient(new HandlerQuiRefuse()), _db); + + private sealed class HandlerQuiRefuse : HttpMessageHandler + { + public bool AEteAppele { get; private set; } + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + AEteAppele = true; + throw new HttpRequestException("Aucun appel réseau ne devrait avoir lieu."); + } + } + + // ── Verrou nº1 : l'URL doit déjà être en base ──────────────────────────── + + [Fact] + public async Task Une_url_inconnue_de_la_base_est_refusee() + { + var handler = new HandlerQuiRefuse(); + var service = new ServiceCouvertures(new HttpClient(handler), _db); + + var resultat = await service.ObtenirAsync("https://ailleurs.example/image.jpg", default); + + Assert.False(resultat.Trouvee); + + // Le point important : le refus tombe AVANT la requête. Sans ce verrou, le proxy irait + // chercher n'importe quelle URL passée en paramètre. + Assert.False(handler.AEteAppele); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("pas une url")] + [InlineData("file:///etc/passwd")] + [InlineData("http://editions-exemple.fr/couverture.jpg")] // https uniquement + public async Task Les_schemas_et_les_saisies_invalides_sont_refuses(string? url) + { + Assert.False((await Service().ObtenirAsync(url, default)).Trouvee); + } + + [Fact] + public async Task Une_url_connue_franchit_le_bornage_et_va_jusqu_au_reseau() + { + var handler = new HandlerQuiRefuse(); + var service = new ServiceCouvertures(new HttpClient(handler), _db); + + // Le réseau échoue (handler), donc pas de couverture — mais l'appel a bien eu lieu : + // c'est ce qui distingue « refusé par le bornage » de « injoignable ». + var resultat = await service.ObtenirAsync(UrlConnue, default); + + Assert.False(resultat.Trouvee); + Assert.True(handler.AEteAppele); + } + + [Fact] + public async Task Une_couverture_d_ENVIE_autorise_aussi_le_relais() + { + // Les deux tables comptent : une envie porte sa couverture comme un livre, et elle + // s'affiche dans la liste d'envies — donc elle doit pouvoir se mettre en cache. + const string urlEnvie = "https://editions-exemple.fr/envie.jpg"; + + var souhait = new LivreSouhaite + { + Utilisateur = "mathieu", + Titre = "Une envie", + Auteur = "Quelqu'un", + CoverUrl = urlEnvie, + }; + souhait.RecalculerFormes(); + _db.LivresSouhaites.Add(souhait); + await _db.SaveChangesAsync(); + + var handler = new HandlerQuiRefuse(); + var service = new ServiceCouvertures(new HttpClient(handler), _db); + + await service.ObtenirAsync(urlEnvie, default); + + Assert.True(handler.AEteAppele); + } + + public void Dispose() + { + _db.Dispose(); + _connexion.Dispose(); + GC.SuppressFinalize(this); + } +} + +/// +/// Verrou nº2 : la liste des adresses vers lesquelles une connexion peut s'ouvrir. +/// +/// +/// C'est le verrou qui tient même si une URL interne a été enregistrée en base dans une +/// fiche : la valeur serait connue, la connexion serait refusée quand même. +/// +public class GardeAdressesTests +{ + [Theory] + [InlineData("127.0.0.1")] // bouclage : le service lui-même + [InlineData("127.1.2.3")] + [InlineData("0.0.0.0")] + [InlineData("10.0.0.5")] // privé + [InlineData("172.16.0.1")] + [InlineData("172.31.255.254")] + [InlineData("192.168.1.1")] + [InlineData("169.254.169.254")] // lien-local : le point de métadonnées des hébergeurs + [InlineData("100.64.0.1")] // CGNAT + [InlineData("224.0.0.1")] // multicast + [InlineData("::1")] // bouclage IPv6 + [InlineData("::")] + [InlineData("fe80::1")] // lien-local IPv6 + [InlineData("fc00::1")] // adresses locales uniques + [InlineData("fd12:3456::1")] + [InlineData("::ffff:127.0.0.1")] // bouclage déguisé en IPv6 + [InlineData("::ffff:192.168.0.1")] + public void Les_adresses_internes_sont_refusees(string adresse) + { + Assert.False(GardeAdresses.EstPubliquementRoutable(IPAddress.Parse(adresse))); + } + + [Theory] + [InlineData("1.1.1.1")] + [InlineData("172.15.0.1")] // juste HORS de 172.16.0.0/12 + [InlineData("172.32.0.1")] // juste au-dessus + [InlineData("100.63.255.255")] // juste sous le CGNAT + [InlineData("100.128.0.1")] // juste au-dessus + [InlineData("192.167.0.1")] + [InlineData("2606:4700:4700::1111")] + public void Les_adresses_publiques_sont_acceptees(string adresse) + { + Assert.True(GardeAdresses.EstPubliquementRoutable(IPAddress.Parse(adresse))); + } +}