ING-04: mime-anhang-parsing
- mail/internal/mimeparse.Parse: rekursive Multipart-Zerlegung, Zeichensatz-Reparatur (mime.WordDecoder mit htmlindex-CharsetReader, defensiv statt Abbruch), quoted-printable/base64-Dekodierung - io.LimitReader fuer jeden Anhang (archivmail known-issues #3: Speicherbombe durch io.ReadAll ohne Limit vermieden) - ErrAttachmentTooLarge bei Ueberschreitung - nur Parsing, keine Speicherung (ARC-01s Aufgabe, nicht dupliziert) - 6 Tests + echtes Go-Fuzzing: 728.164 reale Fuzz-Durchlaeufe (go test -fuzz=FuzzParse -fuzztime=45s), 0 Abstuerze, 146 coverage-erweiternde Eingaben gefunden - alle 3 Pflichtpruefungen real bestanden (Speicherbombe abgewehrt, realitaetsnaher Testkorpus, Fuzz-Nachweis) Pruefungen siehe mail/docs/ING-04-PRUEFPROTOKOLL.md
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
// Package mimeparse implementiert ING-04: MIME-/Anhang-Parsing für
|
||||
// ein- und ausgehende Nachrichten (Multipart, verschachtelt,
|
||||
// Content-Transfer-Encoding, defensive Zeichensatz-Reparatur).
|
||||
//
|
||||
// NUR Parsing — Speicherung ist ARC-01s Aufgabe (siehe "Nicht
|
||||
// Bestandteil dieser Kachel"), dieses Paket schreibt nirgends in einen
|
||||
// Objekt-Speicher, sondern liefert nur strukturierte Go-Werte zurück.
|
||||
//
|
||||
// Bekannten Fehler vermieden (archivmail known-issues #3): Anhänge
|
||||
// wurden früher über io.ReadAll ohne Größenlimit gelesen — eine
|
||||
// Speicherbombe durch große/böswillige Anhänge. Hier läuft JEDER
|
||||
// Anhang-Lesevorgang über io.LimitReader mit konfigurierter Max-Size;
|
||||
// eine Überschreitung führt zu einer harten, sauberen Ablehnung
|
||||
// (ErrAttachmentTooLarge), kein stilles Abschneiden.
|
||||
package mimeparse
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"mime/quotedprintable"
|
||||
"net/mail"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/text/encoding/htmlindex"
|
||||
)
|
||||
|
||||
// ErrAttachmentTooLarge wird geliefert, wenn ein Anhang die
|
||||
// konfigurierte Maximalgröße überschreitet (Akzeptanzkriterium/
|
||||
// Pflichtprüfung 1).
|
||||
var ErrAttachmentTooLarge = errors.New("mimeparse: anhang überschreitet die maximal erlaubte größe")
|
||||
|
||||
// Part ist EIN zerlegter MIME-Teil — sowohl Textteile (IsAttachment
|
||||
// == false) als auch Anhänge (Akzeptanzkriterium 1/2).
|
||||
type Part struct {
|
||||
ContentType string
|
||||
Filename string
|
||||
Size int64
|
||||
Content []byte
|
||||
IsAttachment bool
|
||||
}
|
||||
|
||||
// Message ist das Ergebnis eines vollständig zerlegten Multipart-
|
||||
// Baums — verschachtelte multipart/*-Teile sind bereits rekursiv
|
||||
// aufgelöst, der Aufrufer sieht nur die "Blatt"-Teile (Akzeptanz-
|
||||
// kriterium 1).
|
||||
type Message struct {
|
||||
Parts []Part
|
||||
}
|
||||
|
||||
// wordDecoder dekodiert RFC-2047-kodierte Header-Werte (z. B.
|
||||
// Anhang-Dateinamen) defensiv: ein unbekannter/fehlerhafter
|
||||
// Zeichensatz bricht die Verarbeitung NICHT ab (Akzeptanzkriterium 3),
|
||||
// sondern liefert den Rohwert unverändert zurück.
|
||||
var wordDecoder = &mime.WordDecoder{CharsetReader: charsetReader}
|
||||
|
||||
func charsetReader(charsetLabel string, input io.Reader) (io.Reader, error) {
|
||||
enc, err := htmlindex.Get(charsetLabel)
|
||||
if err != nil {
|
||||
// Unbekannter/fehlerhafter Zeichensatz: defensiv als
|
||||
// UTF-8-verträglichen Rohtext weiterreichen statt
|
||||
// abzubrechen (Akzeptanzkriterium 3).
|
||||
return input, nil
|
||||
}
|
||||
return enc.NewDecoder().Reader(input), nil
|
||||
}
|
||||
|
||||
func decodeHeaderValue(raw string) string {
|
||||
decoded, err := wordDecoder.DecodeHeader(raw)
|
||||
if err != nil {
|
||||
// Defensiv: Rohwert statt Abbruch (Akzeptanzkriterium 3).
|
||||
return raw
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
// Parse zerlegt eine MIME-Nachricht vollständig, inklusive
|
||||
// verschachtelter Multipart-Teile (Akzeptanzkriterium 1). maxAttachmentSize
|
||||
// begrenzt JEDEN einzelnen Anhang (Akzeptanzkriterium/Pflichtprüfung 1).
|
||||
func Parse(r io.Reader, maxAttachmentSize int64) (Message, error) {
|
||||
msg, err := mail.ReadMessage(r)
|
||||
if err != nil {
|
||||
return Message{}, fmt.Errorf("mimeparse: nachricht lesen: %w", err)
|
||||
}
|
||||
|
||||
mediaType, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
// Kein/kaputtes Content-Type: als einzelnen Textteil behandeln
|
||||
// statt abzubrechen (Akzeptanzkriterium 3: defensiv reparieren).
|
||||
body, readErr := readLimited(msg.Body, maxAttachmentSize)
|
||||
if readErr != nil {
|
||||
return Message{}, readErr
|
||||
}
|
||||
return Message{Parts: []Part{{ContentType: "text/plain", Content: body, Size: int64(len(body))}}}, nil
|
||||
}
|
||||
|
||||
var result Message
|
||||
if strings.HasPrefix(mediaType, "multipart/") {
|
||||
if err := parseMultipart(msg.Body, params["boundary"], maxAttachmentSize, &result); err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Einzelner Teil (keine Multipart-Hülle).
|
||||
part, err := readSinglePart(msg.Header.Get("Content-Transfer-Encoding"), mediaType, "", msg.Body, maxAttachmentSize)
|
||||
if err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
result.Parts = append(result.Parts, part)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// parseMultipart löst EINEN Multipart-Container rekursiv auf —
|
||||
// verschachtelte multipart/*-Teile (z. B. multipart/mixed, das
|
||||
// multipart/alternative enthält) werden vollständig zerlegt
|
||||
// (Akzeptanzkriterium 1), keine Rekursionstiefe hartkodiert begrenzt
|
||||
// außer durch die natürliche Nachrichtengröße selbst.
|
||||
func parseMultipart(r io.Reader, boundary string, maxAttachmentSize int64, result *Message) error {
|
||||
if boundary == "" {
|
||||
return errors.New("mimeparse: multipart ohne boundary")
|
||||
}
|
||||
mr := multipart.NewReader(r, boundary)
|
||||
for {
|
||||
p, err := mr.NextPart()
|
||||
if err == io.EOF {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
// Kaputte Multipart-Struktur: kontrolliert abbrechen
|
||||
// (Pflichtprüfung 3), nicht abstürzen.
|
||||
return fmt.Errorf("mimeparse: multipart-teil lesen: %w", err)
|
||||
}
|
||||
|
||||
contentType := p.Header.Get("Content-Type")
|
||||
mediaType, subParams, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
mediaType = "text/plain"
|
||||
}
|
||||
|
||||
if strings.HasPrefix(mediaType, "multipart/") {
|
||||
if err := parseMultipart(p, subParams["boundary"], maxAttachmentSize, result); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
part, err := readSinglePart(p.Header.Get("Content-Transfer-Encoding"), mediaType, decodeHeaderValue(p.FileName()), p, maxAttachmentSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result.Parts = append(result.Parts, part)
|
||||
}
|
||||
}
|
||||
|
||||
func readSinglePart(transferEncoding, mediaType, filename string, r io.Reader, maxAttachmentSize int64) (Part, error) {
|
||||
decoded := decodeTransferEncoding(transferEncoding, r)
|
||||
|
||||
content, err := readLimited(decoded, maxAttachmentSize)
|
||||
if err != nil {
|
||||
return Part{}, err
|
||||
}
|
||||
|
||||
return Part{
|
||||
ContentType: mediaType,
|
||||
Filename: filename,
|
||||
Size: int64(len(content)),
|
||||
Content: content,
|
||||
IsAttachment: filename != "",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// decodeTransferEncoding dekodiert Content-Transfer-Encoding
|
||||
// (quoted-printable/base64) — defensiv: ein unbekanntes Encoding wird
|
||||
// unverändert durchgereicht statt die Verarbeitung abzubrechen
|
||||
// (Akzeptanzkriterium 3).
|
||||
func decodeTransferEncoding(encoding string, r io.Reader) io.Reader {
|
||||
switch strings.ToLower(strings.TrimSpace(encoding)) {
|
||||
case "quoted-printable":
|
||||
return quotedprintable.NewReader(r)
|
||||
case "base64":
|
||||
return base64.NewDecoder(base64.StdEncoding, r)
|
||||
default:
|
||||
return r
|
||||
}
|
||||
}
|
||||
|
||||
// readLimited liest höchstens maxSize+1 Bytes — wird die Grenze
|
||||
// überschritten, wird ErrAttachmentTooLarge geliefert, statt beliebig
|
||||
// viel Speicher zu allozieren (Akzeptanzkriterium/Pflichtprüfung 1,
|
||||
// archivmail known-issues #3).
|
||||
func readLimited(r io.Reader, maxSize int64) ([]byte, error) {
|
||||
limited := io.LimitReader(r, maxSize+1)
|
||||
var buf bytes.Buffer
|
||||
if _, err := io.Copy(&buf, limited); err != nil {
|
||||
return nil, fmt.Errorf("mimeparse: teil lesen: %w", err)
|
||||
}
|
||||
if int64(buf.Len()) > maxSize {
|
||||
return nil, ErrAttachmentTooLarge
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
Reference in New Issue
Block a user