feat/attachment-rehosting #13

Merged
AzSiAz merged 21 commits from feat/attachment-rehosting into main 2026-07-18 14:39:32 +02:00
Owner

Réhébergement des images du contenu archivé

Ferme le constat de sécurité « contenu archivé actif au niveau réseau » : le
content_html stocké ne contient plus aucune URL distante — les pixels de
suivi et les GET aveugles vers des services internes depuis le navigateur du
lecteur sont éliminés, sans amputer le contenu archivé.

Ce que fait cette PR

  • Réhébergement à l'extraction : ExtractDocument télécharge chaque image
    du contenu extrait via le client SSRF-safe existant (PublicOnlyResolver,
    garde IP littérale par hop, budget de redirections), la stocke dans le bucket
    privé sous sources/{sid}/attachments/{aid}, et réécrit le HTML vers
    /api/documents/{id}/attachments/{aid}. Toute image en échec est remplacée
    par son texte alt ; un garde-fou (contains_remote_image) rend fatal tout
    résidu d'URL distante.
  • SVG défangés, jamais bruts : fichiers .svg et blocs <svg> inline
    (extraits avant Ammonia, qui les supprimait) passent par svg-hush ; servis
    avec nosniff + CSP default-src 'none' en défense en profondeur.
  • Table document_attachments sous RLS : « lisible ⇔ le document est
    lisible » (l'EXISTS traverse documents_select), cycle de vie par trigger
    tombstone + drain + sweep étendu, boot guards du serveur mis à jour en
    symétrie.
  • Endpoint authentifié GET /api/documents/{id}/attachments/{attachment_id} :
    404 indistinct (inexistant/refusé), Cache-Control: private, immutable,
    bucket 100 % privé. Zéro changement frontend (URLs relatives same-origin).
  • Recrawl non destructif (correction d'un défaut préexistant) : le ré-ajout
    d'une URL ne déstage plus immédiatement l'ancien snapshot ; un flag
    force_refetch déclenche le fetch, et l'ancienne version (snapshot, contenu,
    attachments) n'est remplacée/tombstonée qu'au commit de la nouvelle. Un
    recrawl sur un site devenu 404 laisse l'archive intacte.
  • ExtractDocument passe en Detached (trouvé en revue finale) : le
    réseau ne pinne plus la transaction du job ; les écritures
    attachments + contenu commitent atomiquement dans une transaction courte
    dédiée.

Limites et configuration

INDARA_ATTACHMENT_MAX_BYTES (10 Mio/image), INDARA_ATTACHMENT_MAX_PER_DOC
(50), INDARA_ATTACHMENT_FETCH_TIMEOUT_SECS (15) ; allowlist MIME fixe
(png, jpeg, gif, webp, avif, svg+xml).

Validation

  • Suite complète verte : cargo test --workspace, clippy -D warnings, fmt.
  • E2E réel sur la stack dev (login OIDC headless via Dex) : invariant
    zéro-URL-distante vérifié en base, endpoint 200-avec-cookie / 401-sans avec
    les bons headers, recrawl réussi → remplacement + tombstones drainées de S3,
    recrawl échoué → archive intacte.
  • Chaque tâche revue individuellement + revue finale de branche (1 critical
    trouvé et corrigé, cf. ci-dessus).

Spec : docs/superpowers/specs/2026-07-18-attachment-rehosting-design.md ·
Plan : docs/superpowers/plans/2026-07-18-attachment-rehosting.md

Suivis post-merge notés en revue : qualifier document_id dans la policy RLS,
test du cap streamé de fetch_image, tests nested-svg/commentaires,
document_id dans les warns par image, cap par image pour les SVG inline,
lock_duration explicite sur la queue Pipeline.

# Réhébergement des images du contenu archivé Ferme le constat de sécurité « contenu archivé actif au niveau réseau » : le `content_html` stocké ne contient plus **aucune URL distante** — les pixels de suivi et les GET aveugles vers des services internes depuis le navigateur du lecteur sont éliminés, sans amputer le contenu archivé. ## Ce que fait cette PR - **Réhébergement à l'extraction** : `ExtractDocument` télécharge chaque image du contenu extrait via le client SSRF-safe existant (`PublicOnlyResolver`, garde IP littérale par hop, budget de redirections), la stocke dans le bucket privé sous `sources/{sid}/attachments/{aid}`, et réécrit le HTML vers `/api/documents/{id}/attachments/{aid}`. Toute image en échec est remplacée par son texte `alt` ; un garde-fou (`contains_remote_image`) rend fatal tout résidu d'URL distante. - **SVG défangés, jamais bruts** : fichiers `.svg` et blocs `<svg>` inline (extraits avant Ammonia, qui les supprimait) passent par `svg-hush` ; servis avec `nosniff` + CSP `default-src 'none'` en défense en profondeur. - **Table `document_attachments`** sous RLS : « lisible ⇔ le document est lisible » (l'`EXISTS` traverse `documents_select`), cycle de vie par trigger tombstone + drain + sweep étendu, boot guards du serveur mis à jour en symétrie. - **Endpoint authentifié** `GET /api/documents/{id}/attachments/{attachment_id}` : 404 indistinct (inexistant/refusé), `Cache-Control: private, immutable`, bucket 100 % privé. Zéro changement frontend (URLs relatives same-origin). - **Recrawl non destructif** (correction d'un défaut préexistant) : le ré-ajout d'une URL ne déstage plus immédiatement l'ancien snapshot ; un flag `force_refetch` déclenche le fetch, et l'ancienne version (snapshot, contenu, attachments) n'est remplacée/tombstonée qu'au commit de la nouvelle. Un recrawl sur un site devenu 404 laisse l'archive intacte. - **`ExtractDocument` passe en `Detached`** (trouvé en revue finale) : le réseau ne pinne plus la transaction du job ; les écritures attachments + contenu commitent atomiquement dans une transaction courte dédiée. ## Limites et configuration `INDARA_ATTACHMENT_MAX_BYTES` (10 Mio/image), `INDARA_ATTACHMENT_MAX_PER_DOC` (50), `INDARA_ATTACHMENT_FETCH_TIMEOUT_SECS` (15) ; allowlist MIME fixe (png, jpeg, gif, webp, avif, svg+xml). ## Validation - Suite complète verte : `cargo test --workspace`, `clippy -D warnings`, fmt. - E2E réel sur la stack dev (login OIDC headless via Dex) : invariant zéro-URL-distante vérifié en base, endpoint 200-avec-cookie / 401-sans avec les bons headers, recrawl réussi → remplacement + tombstones drainées de S3, recrawl échoué → archive intacte. - Chaque tâche revue individuellement + revue finale de branche (1 critical trouvé et corrigé, cf. ci-dessus). Spec : `docs/superpowers/specs/2026-07-18-attachment-rehosting-design.md` · Plan : `docs/superpowers/plans/2026-07-18-attachment-rehosting.md` Suivis post-merge notés en revue : qualifier `document_id` dans la policy RLS, test du cap streamé de `fetch_image`, tests nested-svg/commentaires, `document_id` dans les warns par image, cap par image pour les SVG inline, `lock_duration` explicite sur la queue Pipeline.
AzSiAz self-assigned this 2026-07-18 12:28:08 +02:00
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pick_srcset_candidate utilisait max_by qui renvoie le DERNIER maximum,
si bien que "/a.png, /b.png" ou deux entrées 100w choisissaient /b.png au
lieu de /a.png promis par le doc/spec. Remplacement par un reduce qui ne
remplace le meilleur candidat que sur poids strictement supérieur, donc
les égalités et le cas tout-à-zéro résolvent la première entrée. Test de
contrat srcset_tie_or_no_descriptor_picks_first_entry ajouté.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- doc_lazy_continuation: paragraphe séparé dans l'entête de rewrite.rs
- cloned_ref_to_slice_refs: slice::from_ref dans les tests attachments

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
En mode Transactional, pg-jobs ouvrait la tx du job avant run() et la tenait
pendant tout le handler. Depuis le réhébergement d'images, run() effectue
jusqu'à ~195 s d'I/O réseau (jusqu'à 50 images) sans heartbeat pendant que
cette tx est épinglée : avec le budget de lease de la queue Pipeline, le job
se fait annuler en plein fetch, rejouer dans le même mur, dead-letter, et le
reaper peut démarrer une exécution concurrente passé la lease.

Passe ExtractDocument en Detached (comme FetchDocument et DetectMetadata) : le
sidecar heartbeat rafraîchit la lease et le réseau ne pinne plus aucune tx. Les
deux écritures domaine (replace_attachments + store_extracted) restent atomiques
entre elles dans une transaction courte et dédiée ouverte sur self.pool, après
la fin de tous les fetchs. Le rejeu at-least-once reste sûr (DELETE-puis-INSERT
idempotent, images re-fetchées, orphelins ramassés par le sweep).

Corrige les commentaires devenus faux et amende le spec (étape 5 de
« Pipeline — ExtractDocument »).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
FinalizeDocument était le dernier job Transactional du pipeline. Passage en
Detached (directive : tous les jobs Detached) : ses deux écritures
(set_language + set_status) commitent désormais dans une transaction courte et
dédiée ouverte sur le pool, au lieu de ctx.tx(). Les deux UPDATE sont
idempotents, donc le rejeu at-least-once du mode Detached est sans effet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Qualifie document_attachments.document_id dans la policy
document_attachments_select : un futur ajout de colonne homonyme dans documents
ne pourra plus voler silencieusement la portée du prédicat interne.

Retire size_bytes de AttachmentMeta et du SELECT de get_attachment : l'endpoint
de service ne l'utilise pas (le Content-Length vient du corps). Cache .sqlx
régénéré.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- attachments : borne les octets bruts d'un SVG inline sur limits.max_bytes
  avant defang (le chemin fetch le faisait déjà) ; au-delà → warn + Drop.
- attachments : thread document_id dans acquire et l'ajoute à ses trois warns
  (svg inline rejeté / fetch image échoué / svg fetché rejeté), corrélables
  comme les autres warns de rehost_images.
- worker : set_queue_lock_duration explicite (120s) sur la queue Pipeline. Tous
  les jobs étant Detached avec heartbeat, c'est le bail de reprise après crash,
  pas un plafond d'exécution.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- fetch : fetch_image_gzip_body_is_cut_at_decompressed_cap — corps image gzippé
  surdimensionné (reqwest retire le Content-Length en décompressant), client
  .gzip(true), content-type image/png → TooLarge via la boucle d'accumulation.
- rewrite : svg imbriqué → un seul bloc lifté (externe + interne), un marqueur.
- rewrite : commentaire XML contenant un faux </svg> → sous-arbre lifté sans
  troncature (le cas qui justifie le parsing DOM plutôt que la chirurgie).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
AzSiAz merged commit f61adda04c into main 2026-07-18 14:39:32 +02:00
AzSiAz deleted branch feat/attachment-rehosting 2026-07-18 14:39:32 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
AzSiAz/Indara!13
No description provided.