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:
mathieu
2026-08-20 22:14:08 +02:00
co-authored by Claude Opus 5
parent e74d20adfa
commit b16e70913f
7 changed files with 630 additions and 2 deletions
+91
View File
@@ -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 `<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
Le paquet vit dans le dépôt `mabibli_ynh` (voir « Deux dépôts distincts »). Installé,