Files
mabibli/MaBibli.Tests/ServiceCouverturesTests.cs
T
mathieuandClaude Opus 5 b16e70913f 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>
2026-08-20 22:14:08 +02:00

184 lines
6.7 KiB
C#

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)));
}
}