Vérifier la carte grise par ses octets, pas son Content-Type déclaré, et fermer docling au réseau
Deux failles relevées par l'audit du 09/08 : docling écoutait sur toutes les interfaces sans authentification alors que fokan-app le joint déjà par ai-net ; la route carte-grise ne vérifiait le type de fichier que sur l'en-tête déclaré par l'appelant, jamais sur le contenu réel. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
c33c1caeb3
commit
8776f8ce0b
3 changed files with 155 additions and 7 deletions
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
Projet « Veille du foyer » (marque sœur de Kankwa, éditée par MIAW). La source de vérité complète est [docs/cadrage-projet-fokan.md](docs/cadrage-projet-fokan.md) — en cas de doute sur une décision produit ou technique, la réponse y est probablement déjà, validée. Les divergences postérieures sont tranchées et datées dans [docs/decisions.md](docs/decisions.md), qui prime sur le cadrage.
|
||||
|
||||
Documents de référence par pilier : [logement](docs/pilier-logement.md) · [véhicules](docs/pilier-vehicules.md) · [papiers](docs/pilier-papiers.md) · [enfance & scolarité](docs/pilier-scolarite.md) · [animaux](docs/pilier-animaux.md) · [contrats](docs/pilier-contrats.md) (transverse, pas un pilier). État connu des défauts et chantiers restants — les quatre audits se complètent : [docs/audit-2026-07-30.md](docs/audit-2026-07-30.md) (connaissance, piliers, documents, ajouts classés par valeur, question du 6e pilier), [docs/audit-2026-07-28.md](docs/audit-2026-07-28.md) (sécurité et exploitation, seule référence sur ces deux volets) et [docs/audit-mail-2026-07-30.md](docs/audit-mail-2026-07-30.md) (service mail de bout en bout : les cinq messages qui peuvent partir, leurs déclencheurs, leurs conditions et leur contenu — seule référence sur ce volet ; son § 6 porte les quatre décisions produit encore ouvertes, avec options chiffrées et recommandation, son § 7 les deux chantiers qui débordent du canal) et [docs/audit-ressources-2026-08-05.md](docs/audit-ressources-2026-08-05.md) (conditions de ressources : les cinq dénominateurs des barèmes officiels, ce que coûte la conversion depuis le net mensuel, et l'audit aide par aide de ce qui mérite une fiche — seule référence sur ce volet).
|
||||
Documents de référence par pilier : [logement](docs/pilier-logement.md) · [véhicules](docs/pilier-vehicules.md) · [papiers](docs/pilier-papiers.md) · [enfance & scolarité](docs/pilier-scolarite.md) · [animaux](docs/pilier-animaux.md) · [contrats](docs/pilier-contrats.md) (transverse, pas un pilier). État connu des défauts et chantiers restants — les cinq audits se complètent : [docs/audit-2026-08-09.md](docs/audit-2026-08-09.md) (le plus récent — sécurité API/back/front, fonctionnel, et revérification de l'étude de marché gratuit/payant du cadrage ; § 0 documente les deux correctifs de sécurité appliqués le jour même), [docs/audit-2026-07-30.md](docs/audit-2026-07-30.md) (connaissance, piliers, documents, ajouts classés par valeur, question du 6e pilier), [docs/audit-2026-07-28.md](docs/audit-2026-07-28.md) (sécurité et exploitation à l'origine du produit) et [docs/audit-mail-2026-07-30.md](docs/audit-mail-2026-07-30.md) (service mail de bout en bout : les cinq messages qui peuvent partir, leurs déclencheurs, leurs conditions et leur contenu — seule référence sur ce volet ; son § 6 porte les quatre décisions produit encore ouvertes, avec options chiffrées et recommandation, son § 7 les deux chantiers qui débordent du canal) et [docs/audit-ressources-2026-08-05.md](docs/audit-ressources-2026-08-05.md) (conditions de ressources : les cinq dénominateurs des barèmes officiels, ce que coûte la conversion depuis le net mensuel, et l'audit aide par aide de ce qui mérite une fiche — seule référence sur ce volet).
|
||||
|
||||
## Constitution produit (arbitre de toute feature)
|
||||
|
||||
|
|
|
|||
111
docs/audit-2026-08-09.md
Normal file
111
docs/audit-2026-08-09.md
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
# Audit du 9 août 2026 — sécurité, fonctionnel, marché
|
||||
|
||||
Trois volets, deux agents en lecture seule (sécurité API/back/front, fonctionnel/produit) et une revérification de l'étude de marché du cadrage. Méthode : lecture du code réel, requêtes en base réelle, `npm audit`, recherche web sur les tarifs concurrents — le détail est dans le corps de chaque section. Les deux correctifs de sécurité retenus ont été appliqués et vérifiés le jour même ; voir [§ 0](#0-état-des-correctifs--09082026).
|
||||
|
||||
## 0. État des correctifs — 09/08/2026
|
||||
|
||||
| # | Sujet | Statut | Ce qui a été fait |
|
||||
|---|---|---|---|
|
||||
| N1 | docling exposé sur toutes les interfaces réseau | ✅ Corrigé | Retrait du `ports: ["5001:5001"]` dans `/home/miaw/docling/docker-compose.yml` — fokan-app est sur le même réseau `ai-net` et joint déjà `docling:5001` par résolution DNS interne, sans aucun port publié sur l'hôte |
|
||||
| N2 | Type de fichier de `/api/vehicule/carte-grise` vérifié sur simple en-tête déclaré | ✅ Corrigé | `detecterType()` (`src/app/api/vehicule/carte-grise/route.ts`) reconnaît le fichier à sa signature d'octets (PDF, JPEG, PNG, WEBP, HEIC/HEIF) et remplace la vérification sur `Content-Type` déclaré, y compris pour le nom de fichier neutre envoyé à docling |
|
||||
| — | Décalage pilote NVIDIA (incident opérationnel, pas un défaut de fokan) | ✅ Résolu | Révélé en recréant le conteneur docling pour appliquer N1 : le module NVIDIA chargé (595.71.05) ne correspondait plus au pilote installé (595.84 — DKMS l'avait reconstruit sans rechargement ni redémarrage). Repli temporaire en CPU (`DOCLING_DEVICE: cpu`, réservation GPU commentée), puis redémarrage de l'hôte par le porteur de projet et retour au GPU une fois `nvidia-smi` propre. `localai`, `fokan-app`, `fokan-db` vérifiés sains après coup |
|
||||
| N3 | 4 vulnérabilités npm « high », transitives de Next.js | ⏸️ Différé | `nanoid`, `postcss`, `sharp` — aucune exploitable en pratique aujourd'hui (`sharp` n'est jamais invoqué, `next/image` absent du code ; `postcss` est une surface de build, pas de runtime). Correction complète = montée vers `next@16`, changement cassant, à planifier hors urgence |
|
||||
|
||||
**Notes sur N1** : le correctif était plus simple que prévu — aucune adresse d'écoute à changer, la publication de port était simplement inutile puisque les deux conteneurs partagent déjà `ai-net`. Le seul imprévu a été la recréation du conteneur elle-même, qui a révélé un problème d'infrastructure sans rapport (le décalage de pilote), documenté ci-dessus pour mémoire — ce n'est pas un défaut de fokan, mais explique pourquoi docling a été indisponible (puis en repli CPU) une partie de la journée.
|
||||
|
||||
---
|
||||
|
||||
## 1. Sécurité — API, back, front
|
||||
|
||||
Périmètre : régressions sur les correctifs de l'[audit du 28/07](audit-2026-07-28.md), revue de toute la surface ajoutée depuis (mode gratuit/payant D-065–D-067, `/api/activer`, `/api/stripe/portal`, `/api/compte/mot-de-passe`, `/api/foyer/[id]/echeance`, `/api/rappels/desabonner`, `/api/vehicule/carte-grise`, boucle d'état, rejeu de quiz).
|
||||
|
||||
**Constat général** : la surface ajoutée depuis le 28/07 est systématiquement défensive — authentification, appartenance au foyer vérifiée avant tout accès `[id]`, validation Zod, plafonds par route. Aucune régression sur les correctifs de juillet (S1–S7, F5, F7).
|
||||
|
||||
### Élevé
|
||||
|
||||
**N1 — docling exposé sur toutes les interfaces réseau, sans authentification.** `ports: ["5001:5001"]` publiait le service sur `0.0.0.0`/`[::]`, contournable sans authentification, alors que `fokan-app` (seul appelant légitime) est déjà sur le même réseau Docker `ai-net`. Conséquence : toutes les protections de `/api/vehicule/carte-grise` (rate limit, plafond de taille, liste blanche de types) devenaient optionnelles pour qui s'adressait directement au port. **Corrigé** — voir § 0.
|
||||
|
||||
### Moyen
|
||||
|
||||
**N2 — Type de fichier vérifié sur `Content-Type` déclaré, jamais sur le contenu.** Un appelant pouvait déclarer n'importe quel type MIME admis en envoyant un binaire arbitraire. Combiné à N1, vecteur d'entrée mal typé vers un service tiers. **Corrigé** — voir § 0.
|
||||
|
||||
**N3 — 4 vulnérabilités « high » transitives, via Next.js.** `npm audit --omit=dev` : `nanoid` (DoS théorique, jamais appelé à taille nulle), `postcss` (build-time uniquement), `sharp` (CVE libvips, mais `next/image` absent de `src/`, donc jamais invoqué). Risque résiduel faible dans les trois cas. **Différé**, planifier `next@16`.
|
||||
|
||||
### Faible
|
||||
|
||||
- `POST /api/event` accepte un corps JSON de taille non plafonnée explicitement (seul le rate limit de 120/min/IP protège) — déjà noté en juillet, toujours vrai.
|
||||
- La connexion par mot de passe n'a pas de rate limit propre à fokan (seul `/sign-in/magic-link` est intercepté) ; la protection anti-brute-force repose sur le plafond intégré de better-auth, actif en production.
|
||||
|
||||
### Confirmations — juillet tient toujours
|
||||
|
||||
Rate limiting étendu de façon cohérente aux nouvelles routes · réclamation de foyer toujours exclusivement dans le webhook Stripe / son équivalent gratuit · jeton `.ics` dédié toujours derrière session + appartenance + abonnement actif · consentement de rétractation toujours horodaté au paiement réel · en-têtes de sécurité toujours présents (CSP absente, toujours justifiée : le seul `dangerouslySetInnerHTML` du produit rend du markdown interne validé en CI) · Postgres toujours sur mot de passe généré + réseau dédié · webhook Stripe toujours défensif sur `items.data[0]` et les statuts intermédiaires · aucun secret trouvé dans le code ou l'historique git.
|
||||
|
||||
### Le mode gratuit/payant (D-065/066/067), vu sécurité
|
||||
|
||||
Mode lu exclusivement côté serveur, aucune fuite dans le bundle client. `/api/checkout` fermé au serveur en gratuit, pas seulement à l'écran. `/api/activer` n'ouvre jamais de session sans passage par le lien magique. `/api/stripe/portal` reste ouvert dans les deux modes (art. L215-1-1). Admission depuis la liste d'attente décidée uniquement à l'écriture, côté serveur. Lien magique scellé par HMAC — seul point mineur, comparaison du sceau non dédiée à temps constant, enjeu jugé faible.
|
||||
|
||||
### RGPD et exploitation
|
||||
|
||||
Le README reste exact sur les trois manques connus : purge RGPD des comptes dormants absente (seule la purge des lectures de guides à 90 jours existe, sans rapport), récap annuel L215-1 absent (aucun template mail), sauvegardes absentes (renvoyées à l'échelle du serveur miaw entier depuis D-054, seul risque que le cadrage qualifie d'inacceptable).
|
||||
|
||||
---
|
||||
|
||||
## 2. Fonctionnalités — ce qui est vraiment livré
|
||||
|
||||
Vérification ligne par ligne du README « Où en est le produit » contre le code et la base réelle.
|
||||
|
||||
| Brique | État | Preuve / détail |
|
||||
|---|---|---|
|
||||
| Quiz + écran de fabrication en flux | Livré | Garde-fou 90 s conforme à D-035/D-036 |
|
||||
| Moteur de réconciliation | Livré | Pur, sans I/O, testé |
|
||||
| 5 piliers, base de connaissance | Livré | 78 fiches confirmées par comptage réel (37 logement, 12 papiers dont 5 contrats, 14 véhicules, 8 scolarité, 7 animaux) |
|
||||
| Carte des périmètres (5 couches) | Livré | nucléaire, sécheresse, ZFE, PPRT, inondation |
|
||||
| Boucle d'état du foyer | Livré | Câblée de bout en bout — gestes → `runReconciliation` systématique, composant réellement monté dans le hub, `notifiable()` seul point de filtre de sourdine |
|
||||
| Rejeu de quiz | Livré | Quota 5/an, décompte par période glissante |
|
||||
| Mode gratuit/payant | Livré | 73 comparaisons de mode auditées, toutes cosmétiques — aucune ne conditionne une fonctionnalité en dehors de la porte unique `isActive` |
|
||||
| Purge RGPD des comptes dormants | Absent | Assumé en commentaire dans le code lui-même (`src/app/confidentialite/page.tsx`) |
|
||||
| Récap annuel (préavis L215-1) | Absent | Aucun template mail, seulement des promesses textuelles sur les pages légales |
|
||||
| Sauvegardes | Absent | Différé à l'échelle du serveur miaw entier depuis D-054 |
|
||||
| Distribution / courroie | 0 exploitable | 34 moteurs en base, tous en confiance « à vérifier » — le sourcing éditorial reste à faire |
|
||||
| Deux-roues — référentiel + Crit'Air | Non fait | Assumé ; le contrôle technique deux-roues, lui, est livré |
|
||||
|
||||
**Dérive relevée en juillet, toujours pas tranchée** : les cinq fiches de `contrats.yaml` (assurance emprunteur, mutuelle, box, mobile, salle de sport) restent classées dans le pilier Papiers. Ni assumé dans la copie du hub, ni déplacé.
|
||||
|
||||
**Dette reprise depuis le 28/07, sans qu'on ait eu à le redemander** : `carte-grise-changement-adresse` (D-063), course sur les attributs d'asset (verrou en mémoire), rattrapage Crit'Air incomplet (tourne aussi à l'enrichissement hebdomadaire), fragilités du webhook Stripe, écran contrats trompeur (remplacé par le rejeu de quiz), funnel silencieusement vide (erreurs désormais journalisées).
|
||||
|
||||
---
|
||||
|
||||
## 3. Étude de marché — gratuit ou payant
|
||||
|
||||
Le cadrage de juillet (§ 14) identifiait sept fronts concurrentiels, tous réactifs et/ou verticaux, avec un quadrant vide : proactif × foyer entier. Revérifié le 09/08/2026 par recherche web : aucun nouvel entrant n'occupe ce quadrant. Grille tarifaire mise à jour :
|
||||
|
||||
| Acteur | Front | Gratuit | Payant |
|
||||
|---|---|---|---|
|
||||
| Cozi | Organiseur familial | Oui, avec pub | Gold 39 $/an · Max (IA) 79,99 $/an |
|
||||
| FamilyWall | Organiseur familial | Oui | Premium 44,99 $/an |
|
||||
| MyFamiliz | Organiseur familial | Essai 1 mois | 2,99 €/mois ou 32,99 €/an |
|
||||
| Mental Loadless | Charge mentale | Oui | 7,99 €/mois — hébergé France |
|
||||
| Origame | Contrats / abonnements | Oui, financé par commission | Zen 6 €/mois |
|
||||
| MyAdmin IA | Scan courrier IA | 5 analyses/mois | Starter 2,99 €/mois · Premium 6,90 €/mois |
|
||||
| SOS Papier | Scan courrier IA | 3 scans/mois | Premium 4,99 €/mois (39,99 €/an) · Pro 9,99 €/mois |
|
||||
| Digiposte | Coffre-fort numérique | Oui, limité | Premium ~39,99 €/an |
|
||||
| fokan (cadrage) | Veille proactive, foyer entier | Preview verrouillée | 19,99 €/an |
|
||||
| fokan (aujourd'hui) | — | 100 places, accès complet, 0 € (`FOKAN_MODE=gratuit`) | — |
|
||||
|
||||
*Sources : pages tarifaires publiques de chaque acteur, consultées le 09/08/2026. L'écart sur Mental Loadless (7,99 €/mois vs 3,99-19,99 €/mois au cadrage) est mineur et sans effet sur la lecture stratégique.*
|
||||
|
||||
**Le pivot du 8 août, ce qu'il apporte et ce qu'il coûte.** Le cadrage entier est construit autour d'un seul indicateur : la conversion quiz → abonné (§ 13.1, « l'étoile polaire »). Depuis D-065, chaque foyer admis en `gratuit` obtient l'accès complet sans jamais passer par un geste de paiement — **l'hypothèse centrale du modèle économique reste donc untestée en conditions réelles**, alors que le cadrage prévoyait de la tester en premier (§ 12, Phase 0). Le pivot est techniquement propre (vérifié § 1) et réversible par construction (D-065 : la bascule inverse ne supprime aucun objet ; D-067 : un foyer admis gratuitement peut être invité à payer pour continuer, sans rétroactivité). Ce qui manque au journal des décisions est **un déclencheur explicite de retour au payant** — date, seuil de places, ou durée d'observation — pour que la phase gratuite reste le test qu'elle devait être, plutôt que l'état par défaut faute d'y revenir. Les 100 places gratuites coûtent de vrais appels API (Géorisques, BAN, ADEME, désormais l'OCR docling) et de vrais mails, sans revenu en face.
|
||||
|
||||
---
|
||||
|
||||
## 4. Ce qui reste ouvert
|
||||
|
||||
Par priorité, hors ce qui est déjà corrigé au § 0 :
|
||||
|
||||
1. **Décider d'un déclencheur de retour au payant** — pas un correctif de code, une décision (§ 3).
|
||||
2. **Trancher le rangement des fiches contrats** — assumer « Papiers » comme fourre-tout dans la copie du hub, ou déplacer ce qui peut l'être (§ 2).
|
||||
3. **Instrumenter un proxy de volonté de payer** pendant la phase gratuite, pour ne pas perdre les 100 premiers points de données utiles au modèle à 19,99 €/an (§ 3).
|
||||
4. **Planifier la purge RGPD et le récap annuel** — deux promesses déjà écrites dans les pages légales, non tenues dans le code. Pas urgent tant qu'aucun foyer n'a atteint 12 mois de dormance.
|
||||
5. **Next.js 16** — pour clore N3, sans urgence.
|
||||
|
||||
Non couvert par cet audit : contenu factuel des 78 fiches (chacune porte sa source, non revérifiée ligne à ligne), configuration Caddy de l'hôte, délivrabilité réelle des mails.
|
||||
|
|
@ -46,6 +46,40 @@ const TYPES_ADMIS = new Set([
|
|||
"application/pdf",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Reconnaît le type réel d'un fichier à sa signature d'octets, jamais au `Content-Type` déclaré
|
||||
* par l'appelant — celui-ci n'est qu'un en-tête HTTP écrit par le client, sans rapport garanti
|
||||
* avec le contenu. Sans ce contrôle, docling (accessible uniquement depuis fokan-app sur
|
||||
* `ai-net`, cf. son docker-compose) recevrait tel quel n'importe quel binaire déclaré comme une
|
||||
* image — la seule chose que `TYPES_ADMIS` empêcherait alors serait le choix du nom de fichier.
|
||||
*
|
||||
* HEIC/HEIF partagent la structure ISO-BMFF (boîte `ftyp` à l'octet 4) ; seule la marque à
|
||||
* l'octet 8 les distingue, et c'est elle qui tranche laquelle des deux valeurs de `TYPES_ADMIS`
|
||||
* retourner.
|
||||
*/
|
||||
function detecterType(bytes: Uint8Array): string | null {
|
||||
const octets = (...valeurs: number[]) => valeurs.every((v, i) => bytes[i] === v);
|
||||
|
||||
if (octets(0x25, 0x50, 0x44, 0x46, 0x2d)) return "application/pdf"; // %PDF-
|
||||
if (octets(0xff, 0xd8, 0xff)) return "image/jpeg";
|
||||
if (octets(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)) return "image/png";
|
||||
if (
|
||||
octets(0x52, 0x49, 0x46, 0x46) && // "RIFF"
|
||||
bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50 // "WEBP"
|
||||
) {
|
||||
return "image/webp";
|
||||
}
|
||||
if (bytes[4] === 0x66 && bytes[5] === 0x74 && bytes[6] === 0x79 && bytes[7] === 0x70) {
|
||||
// ftyp
|
||||
const marque = String.fromCharCode(bytes[8] ?? 0, bytes[9] ?? 0, bytes[10] ?? 0, bytes[11] ?? 0);
|
||||
if (["heic", "heix", "heim", "heis", "hevc", "hevx", "hevm", "hevs"].includes(marque)) {
|
||||
return "image/heic";
|
||||
}
|
||||
if (["mif1", "msf1"].includes(marque)) return "image/heif";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plafond de taille côté fokan. Le client réduit déjà à 1600 px (~75 ko mesuré), mais il peut
|
||||
* échouer — un HEIC que `createImageBitmap` ne sait pas décoder part tel quel. On reste sous les
|
||||
|
|
@ -87,15 +121,17 @@ export async function POST(req: NextRequest) {
|
|||
if (!(fichier instanceof File) || fichier.size === 0) {
|
||||
return NextResponse.json({ error: "aucun fichier reçu" }, { status: 400 });
|
||||
}
|
||||
if (!TYPES_ADMIS.has(fichier.type)) {
|
||||
return NextResponse.json({ error: "format non pris en charge" }, { status: 415 });
|
||||
}
|
||||
// Compté sur les octets, jamais sur `Content-Length` : c'est l'appelant qui l'écrit.
|
||||
if (fichier.size > TAILLE_MAX) {
|
||||
return NextResponse.json({ error: "fichier trop lourd" }, { status: 413 });
|
||||
}
|
||||
const entete = new Uint8Array(await fichier.slice(0, 12).arrayBuffer());
|
||||
const type = detecterType(entete);
|
||||
if (!type || !TYPES_ADMIS.has(type)) {
|
||||
return NextResponse.json({ error: "format non pris en charge" }, { status: 415 });
|
||||
}
|
||||
|
||||
const texte = await avecVerrou(VERROU_DOCLING, () => convertir(fichier)).catch(() => null);
|
||||
const texte = await avecVerrou(VERROU_DOCLING, () => convertir(fichier, type)).catch(() => null);
|
||||
if (texte === null) {
|
||||
return NextResponse.json({ error: "lecture indisponible" }, { status: 502 });
|
||||
}
|
||||
|
|
@ -173,10 +209,11 @@ function puissanceHomologuee(
|
|||
* - `page_range=[1,1]` : un certificat d'immatriculation tient sur une page. Sans cette borne,
|
||||
* un PDF de 500 pages déposé par erreur (ou pas) occuperait le GPU pour rien.
|
||||
*/
|
||||
async function convertir(fichier: File): Promise<string> {
|
||||
async function convertir(fichier: File, type: string): Promise<string> {
|
||||
const corps = new FormData();
|
||||
// Le nom d'origine ne sert à rien ici et porterait le nom du foyer sur bien des téléphones.
|
||||
corps.append("files", fichier, nomNeutre(fichier.type));
|
||||
// L'extension vient du type détecté par signature, jamais du `Content-Type` déclaré.
|
||||
corps.append("files", fichier, nomNeutre(type));
|
||||
corps.append("to_formats", "md");
|
||||
corps.append("do_ocr", "true");
|
||||
corps.append("ocr_engine", "rapidocr");
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue