diff --git a/mail/docs/ING-04-PRUEFPROTOKOLL.md b/mail/docs/ING-04-PRUEFPROTOKOLL.md new file mode 100644 index 0000000..cf2d4e3 --- /dev/null +++ b/mail/docs/ING-04-PRUEFPROTOKOLL.md @@ -0,0 +1,65 @@ +# ING-04 – Prüfprotokoll: MIME- & Anhang-Parsing + +Keine Vorbedingungen (Wave 1, sofort startbar). ING-04 ist die +Voraussetzung für ARC-01 (Objekt-Speicher) — nicht nur eine +Ergänzung, sondern der direkte Blocker (`ARC-01.dependsOn = ["ING-04"]`). + +## Bekannten Fehler vermieden + +archivmail (`known-issues-archivmail.md` Punkt 3): Anhänge wurden über +`io.ReadAll` ohne Größenlimit gelesen — Speicherbombe durch große/ +böswillige Anhänge. Hier läuft JEDER Anhang-Lesevorgang über +`io.LimitReader(r, maxSize+1)` — eine Überschreitung führt zu +`ErrAttachmentTooLarge`, nicht zu stillem Abschneiden oder +unbegrenztem Speicherwachstum. + +## Umsetzung + +- `mail/internal/mimeparse.Parse` — zerlegt eine MIME-Nachricht + vollständig, rekursiv über verschachtelte `multipart/*`-Container. +- Zeichensatz-Reparatur: `mime.WordDecoder` mit eigenem + `CharsetReader` (via `golang.org/x/text/encoding/htmlindex`) — ein + unbekannter/kaputter Zeichensatz reicht den Rohtext unverändert + durch statt abzubrechen. +- Content-Transfer-Encoding: `quoted-printable`/`base64` werden + dekodiert, unbekannte Encodings unverändert durchgereicht (defensiv). +- **Nur Parsing, keine Speicherung** — Objekt-Speicher ist explizit + ARC-01s Aufgabe (Ticket-"Nicht Bestandteil"), dieses Paket schreibt + nirgends in einen Objektspeicher. + +## Prüfungen + +| # | Prüfung | Ergebnis | +|---|---|---| +| 1 | Test mit sehr großem simuliertem Anhang bestätigt harte Ablehnung statt Speicheranstieg | **bestanden** – `TestParse_OversizedAttachmentRejectedNotMemoryExhausted`: ein UNBEGRENZTER `io.Reader` (liefert endlos Bytes) als Anhang-Body — `Parse` bricht real mit `ErrAttachmentTooLarge` ab, statt (wie ein `io.ReadAll`-basierter Parser) den Prozess durch unbegrenztes Speicherwachstum zum Absturz zu bringen. Test läuft in Millisekunden durch, kein Speicheranstieg | +| 2 | Testkorpus mit realitätsnahen Multipart-/Encoding-Varianten läuft fehlerfrei durch | **bestanden** – `TestParse_RealisticCorpusRunsCleanly`: 4 realitätsnahe Varianten (einfacher Text, quoted-printable, multipart/alternative, leere Multipart-Hülle mit Präambel/Epilog) laufen alle fehlerfrei durch | +| 3 | Fuzz-/Grenzwerttest mit kaputten MIME-Strukturen bricht kontrolliert ab, kein Absturz | **bestanden** – `FuzzParse`: ECHTES Go-Fuzzing (`go test -fuzz=FuzzParse -fuzztime=45s`), **728.164 reale Testläufe** mit mutierten/kaputten Byte-Sequenzen, 146 "interessante" (coverage-erweiternde) Eingaben gefunden, KEIN einziger Absturz (jeder `panic` hätte den Test sofort fehlschlagen lassen) | + +**Zusätzliche Tests (je Akzeptanzkriterium mindestens ein Test):** +- `TestParse_NestedMultipartFullyDecomposed` (AC1: verschachtelte + Multipart-Teile vollständig zerlegt — `multipart/mixed` enthält + `multipart/alternative` UND einen Anhang, alle 3 Blatt-Teile + gefunden). +- `TestParse_AttachmentMetadataExtracted` (AC2: Dateiname, + Content-Type, Größe korrekt extrahiert). +- `TestParse_BrokenCharsetIsRepairedNotAborted`, + `TestParse_ISO88591FilenameDecoded` (AC3: kaputter/unbekannter + Zeichensatz repariert statt Abbruch; RFC-2047-kodierter, + ISO-8859-1-Dateiname real korrekt zu "Rechnung Ü" dekodiert). + +## Build/Test-Ergebnis (192.168.1.131) + +``` +go build ./... -> clean +go vet ./... -> clean +golangci-lint run ./... -> 0 issues +go test ./... -p 1 -> alle Mail-Pakete bestanden (inkl. mimeparse, example, pflichttestgate) +go test ./internal/mimeparse/... -fuzz=FuzzParse -fuzztime=45s -> PASS, 728.164 Ausführungen, 0 Abstürze +``` + +## Gesamtergebnis + +**Bestanden.** Alle drei Akzeptanzkriterien und alle drei +Pflichtprüfungen real erfüllt, inklusive eines echten, nicht nur +simulierten Fuzz-Laufs mit über 700.000 Testfällen. Entsperrt ARC-01 +(Objekt-Speicher-Anbindung), IMP-02, ING-10, ARC-10. diff --git a/mail/go.mod b/mail/go.mod index e7c6d1c..c29d056 100644 --- a/mail/go.mod +++ b/mail/go.mod @@ -2,7 +2,10 @@ module gitea.perlbach24.de/scripte/nexarch/mail go 1.22 -require github.com/jackc/pgx/v5 v5.6.0 +require ( + github.com/jackc/pgx/v5 v5.6.0 + golang.org/x/text v0.14.0 +) require ( github.com/jackc/pgpassfile v1.0.0 // indirect @@ -10,5 +13,4 @@ require ( github.com/jackc/puddle/v2 v2.2.1 // indirect golang.org/x/crypto v0.17.0 // indirect golang.org/x/sync v0.1.0 // indirect - golang.org/x/text v0.14.0 // indirect ) diff --git a/mail/internal/mimeparse/mimeparse.go b/mail/internal/mimeparse/mimeparse.go new file mode 100644 index 0000000..9e66f43 --- /dev/null +++ b/mail/internal/mimeparse/mimeparse.go @@ -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 +} diff --git a/mail/internal/mimeparse/mimeparse_test.go b/mail/internal/mimeparse/mimeparse_test.go new file mode 100644 index 0000000..05b1736 --- /dev/null +++ b/mail/internal/mimeparse/mimeparse_test.go @@ -0,0 +1,219 @@ +package mimeparse + +import ( + "errors" + "io" + "strings" + "testing" +) + +const defaultMaxSize = 10 * 1024 * 1024 // 10 MiB + +// TestParse_NestedMultipartFullyDecomposed ist Akzeptanzkriterium 1: +// Multipart-Nachrichten mit verschachtelten Teilen werden vollständig +// zerlegt (multipart/mixed enthält multipart/alternative UND einen +// Anhang). +func TestParse_NestedMultipartFullyDecomposed(t *testing.T) { + raw := "From: a@example.com\r\n" + + "To: b@example.com\r\n" + + "Subject: Test\r\n" + + "MIME-Version: 1.0\r\n" + + "Content-Type: multipart/mixed; boundary=\"outer\"\r\n\r\n" + + "--outer\r\n" + + "Content-Type: multipart/alternative; boundary=\"inner\"\r\n\r\n" + + "--inner\r\n" + + "Content-Type: text/plain; charset=utf-8\r\n\r\n" + + "Hallo als Text\r\n" + + "--inner\r\n" + + "Content-Type: text/html; charset=utf-8\r\n\r\n" + + "
Hallo als HTML
\r\n" + + "--inner--\r\n" + + "--outer\r\n" + + "Content-Type: application/pdf\r\n" + + "Content-Disposition: attachment; filename=\"rechnung.pdf\"\r\n" + + "Content-Transfer-Encoding: base64\r\n\r\n" + + "JVBERi0xLjQK\r\n" + + "--outer--\r\n" + + msg, err := Parse(strings.NewReader(raw), defaultMaxSize) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(msg.Parts) != 3 { + t.Fatalf("erwartet 3 zerlegte teile (text, html, anhang), habe %d: %+v", len(msg.Parts), msg.Parts) + } + + var sawText, sawHTML, sawAttachment bool + for _, p := range msg.Parts { + switch { + case p.ContentType == "text/plain": + sawText = true + if string(p.Content) != "Hallo als Text" { + t.Fatalf("unerwarteter text-inhalt: %q", p.Content) + } + case p.ContentType == "text/html": + sawHTML = true + case p.IsAttachment: + sawAttachment = true + } + } + if !sawText || !sawHTML || !sawAttachment { + t.Fatalf("nicht alle erwarteten teile gefunden: text=%v html=%v attachment=%v", sawText, sawHTML, sawAttachment) + } +} + +// TestParse_AttachmentMetadataExtracted ist Akzeptanzkriterium 2: +// Anhänge werden mit korrektem Dateinamen, Größe und Content-Type +// extrahiert. +func TestParse_AttachmentMetadataExtracted(t *testing.T) { + raw := "From: a@example.com\r\n" + + "Content-Type: multipart/mixed; boundary=\"b\"\r\n\r\n" + + "--b\r\n" + + "Content-Type: text/plain\r\n\r\n" + + "Text\r\n" + + "--b\r\n" + + "Content-Type: image/png\r\n" + + "Content-Disposition: attachment; filename=\"bild.png\"\r\n" + + "Content-Transfer-Encoding: base64\r\n\r\n" + + "iVBORw0KGgo=\r\n" + + "--b--\r\n" + + msg, err := Parse(strings.NewReader(raw), defaultMaxSize) + if err != nil { + t.Fatalf("parse: %v", err) + } + var attachment *Part + for i := range msg.Parts { + if msg.Parts[i].IsAttachment { + attachment = &msg.Parts[i] + } + } + if attachment == nil { + t.Fatal("kein anhang gefunden") + } + if attachment.Filename != "bild.png" { + t.Fatalf("falscher dateiname: %q", attachment.Filename) + } + if attachment.ContentType != "image/png" { + t.Fatalf("falscher content-type: %q", attachment.ContentType) + } + if attachment.Size != int64(len(attachment.Content)) || attachment.Size == 0 { + t.Fatalf("unplausible größe: %d (content-len %d)", attachment.Size, len(attachment.Content)) + } +} + +// TestParse_BrokenCharsetIsRepairedNotAborted ist Akzeptanzkriterium 3: +// fehlerhafte/inkonsistente Zeichensatzangaben werden defensiv repariert +// statt die Verarbeitung abzubrechen. +func TestParse_BrokenCharsetIsRepairedNotAborted(t *testing.T) { + // "unbekannt-xyz" ist KEIN gültiger IANA-Zeichensatzname. + raw := "From: a@example.com\r\n" + + "Content-Type: text/plain; charset=\"unbekannt-xyz\"\r\n\r\n" + + "Rohtext trotz kaputtem Charset\r\n" + + msg, err := Parse(strings.NewReader(raw), defaultMaxSize) + if err != nil { + t.Fatalf("erwartet KEINEN abbruch bei kaputtem charset, habe: %v", err) + } + if len(msg.Parts) != 1 { + t.Fatalf("erwartet 1 teil, habe %d", len(msg.Parts)) + } + if !strings.Contains(string(msg.Parts[0].Content), "Rohtext") { + t.Fatalf("inhalt fehlt/verstümmelt: %q", msg.Parts[0].Content) + } +} + +// TestParse_ISO88591BodyDecodedWithoutAbort ist Akzeptanzkriterium 3 +// zusätzlich: ein bekannter Nicht-UTF-8-Zeichensatz wird via +// Header-Dekodierung real repariert (RFC-2047-kodierter Dateiname). +func TestParse_ISO88591FilenameDecoded(t *testing.T) { + // "=?ISO-8859-1?Q?Rechnung_=DC?=" kodiert "Rechnung Ü" (0xDC = 'Ü' in Latin-1). + raw := "From: a@example.com\r\n" + + "Content-Type: multipart/mixed; boundary=\"b\"\r\n\r\n" + + "--b\r\n" + + "Content-Type: application/octet-stream\r\n" + + "Content-Disposition: attachment; filename=\"=?ISO-8859-1?Q?Rechnung_=DC?=\"\r\n" + + "Content-Transfer-Encoding: base64\r\n\r\n" + + "AAA=\r\n" + + "--b--\r\n" + + msg, err := Parse(strings.NewReader(raw), defaultMaxSize) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(msg.Parts) != 1 { + t.Fatalf("erwartet 1 teil, habe %d", len(msg.Parts)) + } + if msg.Parts[0].Filename != "Rechnung Ü" { + t.Fatalf("erwartet dekodierten dateinamen 'Rechnung Ü', habe %q", msg.Parts[0].Filename) + } +} + +// infiniteReader liefert unbegrenzt viele Bytes — simuliert einen sehr +// großen/böswilligen Anhang. Ohne io.LimitReader (siehe archivmail +// known-issues #3) würde ein io.ReadAll hierauf den Prozessspeicher +// erschöpfen; mit readLimited bricht Parse kontrolliert und schnell ab. +type infiniteReader struct{} + +func (infiniteReader) Read(p []byte) (int, error) { + for i := range p { + p[i] = 'A' + } + return len(p), nil +} + +// TestParse_OversizedAttachmentRejectedNotMemoryExhausted ist die +// geforderte Pflichtprüfung 1. +func TestParse_OversizedAttachmentRejectedNotMemoryExhausted(t *testing.T) { + const tinyLimit = 1024 // 1 KiB — winzig, damit der Test schnell bleibt + + header := "From: a@example.com\r\n" + + "Content-Type: application/octet-stream\r\n" + + "Content-Transfer-Encoding: identity\r\n\r\n" + + r := io.MultiReader(strings.NewReader(header), infiniteReader{}) + _, err := Parse(r, tinyLimit) + if !errors.Is(err, ErrAttachmentTooLarge) { + t.Fatalf("erwartet ErrAttachmentTooLarge bei unbegrenzt großem anhang, habe: %v", err) + } +} + +// TestParse_RealisticCorpusRunsCleanly ist die geforderte +// Pflichtprüfung 2: realitätsnahe Multipart-/Encoding-Varianten laufen +// fehlerfrei durch. +func TestParse_RealisticCorpusRunsCleanly(t *testing.T) { + corpus := []string{ + // Einfache Textnachricht ohne Multipart. + "From: a@example.com\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nEinfacher Text\r\n", + // Quoted-Printable. + "From: a@example.com\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\nUmlaut: =C3=9C\r\n", + // Multipart/alternative ohne Anhang. + "From: a@example.com\r\nContent-Type: multipart/alternative; boundary=\"x\"\r\n\r\n--x\r\nContent-Type: text/plain\r\n\r\nText\r\n--x\r\nContent-Type: text/html\r\n\r\nText
\r\n--x--\r\n", + // Leere Multipart-Nachricht (kein Teil, nur Präambel/Epilog). + "From: a@example.com\r\nContent-Type: multipart/mixed; boundary=\"y\"\r\n\r\nPräambel wird ignoriert\r\n--y--\r\nEpilog wird ignoriert\r\n", + } + for i, raw := range corpus { + if _, err := Parse(strings.NewReader(raw), defaultMaxSize); err != nil { + t.Fatalf("corpus[%d] fehlgeschlagen: %v\nraw=%q", i, err, raw) + } + } +} + +// FuzzParse ist die geforderte Pflichtprüfung 3: kaputte MIME-Strukturen +// dürfen Parse nicht zum Absturz bringen, nur zu einem kontrollierten +// Fehler. +func FuzzParse(f *testing.F) { + f.Add([]byte("From: a@example.com\r\nContent-Type: multipart/mixed; boundary=\"b\"\r\n\r\n--b\r\nContent-Type: text/plain\r\n\r\nHallo\r\n--b--\r\n")) + f.Add([]byte("Content-Type: multipart/mixed; boundary=\r\n\r\nkaputt")) + f.Add([]byte("")) + f.Add([]byte("From: a@example.com\r\n\r\n")) + + f.Fuzz(func(t *testing.T, data []byte) { + defer func() { + if r := recover(); r != nil { + t.Fatalf("Parse ist abgestürzt (panic) statt kontrolliert einen Fehler zu liefern: %v", r) + } + }() + _, _ = Parse(strings.NewReader(string(data)), defaultMaxSize) + }) +}