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:
@@ -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.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<IOpenLibraryClient, OpenLibraryClient>(http =>
|
||||
|
||||
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();
|
||||
|
||||
// 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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user