Relaie les couvertures en même origine, pour les mettre en cache hors-ligne
Le cache hors-ligne des couvertures ne marchait que pour OpenLibrary : il lit les octets par fetch(), donc exige un en-tête CORS, alors que le formulaire livre accepte n'importe quelle URL. Ces images s'affichaient (une <img> n'a que faire du CORS) sans jamais pouvoir être rangées — et le fetch repartait à chaque affichage puisque rien n'était stocké. GET /api/couvertures relaie l'image depuis notre serveur. Un proxy est une surface SSRF : il est borné par deux verrous indépendants — l'URL doit déjà exister en base comme couverture, et la connexion ne s'ouvre que vers une adresse publiquement routable. Ce second verrou vit dans le ConnectCallback, pas dans une pré-vérification DNS, ce qui ferme aussi le DNS rebinding — et c'est ce qui permet de suivre les redirections, indispensables puisque covers.openlibrary.org répond 302. Tout refus répond 404 : distinguer les cas ferait du point d'entrée un oracle sur les URL connues et sur le réseau du serveur. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
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.
|
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 `<img>` 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'`<img>` 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
|
## 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é,
|
Le paquet vit dans le dépôt `mabibli_ynh` (voir « Deux dépôts distincts »). Installé,
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using MaBibli.Api.Services.Couvertures;
|
||||||
|
|
||||||
|
namespace MaBibli.Api.Endpoints;
|
||||||
|
|
||||||
|
public static class CouverturesEndpoints
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Relais d'images de couverture, pour la mise en cache hors-ligne.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// ⚠️ <b>Tout refus répond 404</b>, 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 <see cref="ServiceCouvertures"/>.
|
||||||
|
/// </remarks>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using MaBibli.Api.Data;
|
using MaBibli.Api.Data;
|
||||||
using MaBibli.Api.Endpoints;
|
using MaBibli.Api.Endpoints;
|
||||||
using MaBibli.Api.Services.Catalogue;
|
using MaBibli.Api.Services.Catalogue;
|
||||||
|
using MaBibli.Api.Services.Couvertures;
|
||||||
using MaBibli.Api.Services.Identite;
|
using MaBibli.Api.Services.Identite;
|
||||||
using MaBibli.Api.Services.Isbn;
|
using MaBibli.Api.Services.Isbn;
|
||||||
using MaBibli.Api.Services.Prets;
|
using MaBibli.Api.Services.Prets;
|
||||||
@@ -50,6 +51,31 @@ builder.Services.AddHttpClient<IOpenLibraryClient, OpenLibraryClient>(http =>
|
|||||||
|
|
||||||
builder.Services.AddScoped<IIsbnLookupService, IsbnLookupService>();
|
builder.Services.AddScoped<IIsbnLookupService, IsbnLookupService>();
|
||||||
|
|
||||||
|
// 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<IServiceCouvertures, ServiceCouvertures>(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();
|
var app = builder.Build();
|
||||||
|
|
||||||
// Une seule base SQLite, migrée au démarrage : pas d'étape manuelle à l'installation YunoHost.
|
// 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.MapRevuesEndpoints();
|
||||||
app.MapBibliographieEndpoints();
|
app.MapBibliographieEndpoints();
|
||||||
app.MapIdentiteEndpoints();
|
app.MapIdentiteEndpoints();
|
||||||
|
app.MapCouverturesEndpoints();
|
||||||
|
|
||||||
// ⚠️ Le fallback a son propre pipeline : il NE passe PAS par les StaticFileOptions posées
|
// ⚠️ 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,
|
// ci-dessus. Sans lui repasser les mêmes options, « / » — c'est-à-dire le start_url de la PWA,
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
|
||||||
|
namespace MaBibli.Api.Services.Couvertures;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Garde-fou SSRF : n'autorise la connexion qu'à des adresses IP publiquement routables.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ⚠️ <b>Ce garde est la raison pour laquelle le proxy de couvertures est acceptable.</b> Le
|
||||||
|
/// service .NET écoute sur <c>127.0.0.1</c> 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.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// ⚠️ <b>La vérification est faite au moment de CONNECTER, pas avant la requête.</b> Résoudre le
|
||||||
|
/// nom d'abord puis laisser <c>HttpClient</c> résoudre à nouveau laisserait passer un
|
||||||
|
/// <i>DNS rebinding</i> : un nom qui répond une adresse publique à la vérification et
|
||||||
|
/// <c>127.0.0.1</c> à la connexion. Ici, la socket se connecte <b>aux adresses déjà validées</b>,
|
||||||
|
/// et à aucune autre.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class GardeAdresses
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Rappel de connexion pour <see cref="SocketsHttpHandler.ConnectCallback"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static async ValueTask<Stream> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Liste <b>négative</b>, 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.
|
||||||
|
/// </remarks>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
using MaBibli.Api.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace MaBibli.Api.Services.Couvertures;
|
||||||
|
|
||||||
|
/// <summary>Résultat d'une demande de couverture : les octets, ou rien.</summary>
|
||||||
|
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<ResultatCouverture> ObtenirAsync(string? url, CancellationToken ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Relaie une image de couverture <b>en même origine</b>, pour qu'elle puisse être mise en cache
|
||||||
|
/// hors-ligne quel que soit son hébergeur.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Pourquoi ce service existe.</b> Le cache hors-ligne des couvertures (lot A5) lit les octets
|
||||||
|
/// par <c>fetch()</c>, ce qui exige un en-tête <c>Access-Control-Allow-Origin</c>. OpenLibrary
|
||||||
|
/// l'envoie, mais le formulaire livre accepte <b>n'importe quelle URL</b> de couverture : ces
|
||||||
|
/// images s'affichaient bien (une balise <c><img></c> 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.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// ⚠️ <b>Un proxy est une surface d'attaque (SSRF), et il est borné par DEUX verrous
|
||||||
|
/// indépendants</b> — les deux sont nécessaires, aucun ne suffit :
|
||||||
|
/// </para>
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item>
|
||||||
|
/// <b>L'URL doit déjà exister en base</b> 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à.
|
||||||
|
/// </item>
|
||||||
|
/// <item>
|
||||||
|
/// <b>La connexion n'est ouverte que vers une adresse publiquement routable</b>
|
||||||
|
/// (<see cref="GardeAdresses"/>). 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.
|
||||||
|
/// </item>
|
||||||
|
/// </list>
|
||||||
|
/// <para>
|
||||||
|
/// ⚠️ <b>Tout refus est indistinct</b> 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.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class ServiceCouvertures(HttpClient http, MaBibliDbContext db) : IServiceCouvertures
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public const int TailleMaximale = 5 * 1024 * 1024;
|
||||||
|
|
||||||
|
public async Task<ResultatCouverture> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// L'URL est-elle déjà enregistrée comme couverture ? Comparaison <b>exacte</b> : c'est la
|
||||||
|
/// valeur stockée qui autorise, pas une ressemblance.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<bool> EstConnueAsync(string url, CancellationToken ct) =>
|
||||||
|
await db.Livres.AnyAsync(l => l.CoverUrl == url, ct)
|
||||||
|
|| await db.LivresSouhaites.AnyAsync(s => s.CoverUrl == url, ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lit au plus <see cref="TailleMaximale"/> octets, et rend <c>null</c> si le corps dépasse.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// ⚠️ Le <c>Content-Length</c> vérifié plus haut ne suffit pas : il est <b>absent</b> d'une
|
||||||
|
/// réponse en <i>chunked</i>, et rien n'oblige un serveur distant à dire la vérité.
|
||||||
|
/// </remarks>
|
||||||
|
private static async Task<byte[]?> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -99,6 +99,17 @@ export function surveiller(reference) {
|
|||||||
//
|
//
|
||||||
// OpenLibrary envoie `Access-Control-Allow-Origin: *` (vérifié) : un fetch cross-origin normal
|
// OpenLibrary envoie `Access-Control-Allow-Origin: *` (vérifié) : un fetch cross-origin normal
|
||||||
// suffit, pas besoin du contournement `no-cors`/réponse opaque.
|
// 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 <img> 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'<img> 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) {
|
export async function couvertureMettreEnCache(url) {
|
||||||
try {
|
try {
|
||||||
const base = await ouvrir();
|
const base = await ouvrir();
|
||||||
@@ -106,8 +117,20 @@ export async function couvertureMettreEnCache(url) {
|
|||||||
const existe = await attendre(tx.objectStore(MAGASIN_COUVERTURES).getKey(url), tx);
|
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.
|
if (existe !== undefined) return; // Déjà en cache : pas de re-téléchargement.
|
||||||
|
|
||||||
const reponse = await fetch(url, { cache: 'force-cache' });
|
let reponse = null;
|
||||||
if (!reponse.ok) return; // 404/502 intermittent (CLAUDE.md) : rien à ranger.
|
|
||||||
|
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 blob = await reponse.blob();
|
||||||
const ecriture = base.transaction(MAGASIN_COUVERTURES, 'readwrite');
|
const ecriture = base.transaction(MAGASIN_COUVERTURES, 'readwrite');
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Relais de couvertures : ce qui est testé ici, ce n'est pas qu'une image arrive, c'est
|
||||||
|
/// <b>tout ce que le proxy refuse d'aller chercher</b>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// ⚠️ 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
|
||||||
|
/// <c>127.0.0.1</c> et cohabite avec les autres applications YunoHost du serveur — c'est
|
||||||
|
/// exactement ce que la contrainte d'écoute cherche à empêcher.
|
||||||
|
/// </remarks>
|
||||||
|
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<MaBibliDbContext>()
|
||||||
|
.UseSqlite(_connexion).Options);
|
||||||
|
_db.Database.EnsureCreated();
|
||||||
|
|
||||||
|
var livre = new Livre { Titre = "Un livre", CoverUrl = UrlConnue };
|
||||||
|
livre.RecalculerFormes();
|
||||||
|
_db.Livres.Add(livre);
|
||||||
|
_db.SaveChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Client qui échoue à tout coup : ces tests portent sur le <b>bornage</b>, et un refus doit
|
||||||
|
/// tomber <i>avant</i> tout appel réseau. Si l'un d'eux passait jusqu'ici, il échouerait —
|
||||||
|
/// c'est précisément ce qu'on veut constater.
|
||||||
|
/// </summary>
|
||||||
|
private ServiceCouvertures Service() =>
|
||||||
|
new(new HttpClient(new HandlerQuiRefuse()), _db);
|
||||||
|
|
||||||
|
private sealed class HandlerQuiRefuse : HttpMessageHandler
|
||||||
|
{
|
||||||
|
public bool AEteAppele { get; private set; }
|
||||||
|
|
||||||
|
protected override Task<HttpResponseMessage> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verrou nº2 : la liste des adresses vers lesquelles une connexion peut s'ouvrir.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// C'est le verrou qui tient <b>même si</b> une URL interne a été enregistrée en base dans une
|
||||||
|
/// fiche : la valeur serait connue, la connexion serait refusée quand même.
|
||||||
|
/// </remarks>
|
||||||
|
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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user