- 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
207 lines
6.8 KiB
Go
207 lines
6.8 KiB
Go
// 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
|
|
}
|