From 56e296e5875b10ed053cbdedbab08957fce2a461 Mon Sep 17 00:00:00 2001 From: Gab <24553253+gabrix73@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:24:16 +0200 Subject: Harden Usenet threading and ingress delivery --- yamn/encoder/encoder.go | 57 ++++++++++++++++++++++++++++++------ yamn/encoder/encoder_test.go | 69 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 116 insertions(+), 10 deletions(-) (limited to 'yamn') diff --git a/yamn/encoder/encoder.go b/yamn/encoder/encoder.go index 3b595b1..6c730c9 100644 --- a/yamn/encoder/encoder.go +++ b/yamn/encoder/encoder.go @@ -24,13 +24,15 @@ import ( ) const ( - maxChainLength = 10 - headerBytes = 256 - encHeadBytes = 160 - bodyBytes = 17920 - maxPlainBytes = 17910 - messageBytes = maxChainLength*headerBytes + bodyBytes - armorVersion = "0.2.7" + maxChainLength = 10 + headerBytes = 256 + encHeadBytes = 160 + bodyBytes = 17920 + maxPlainBytes = 17910 + maxReferencesBytes = 900 + maxReferenceIDs = 20 + messageBytes = maxChainLength*headerBytes + bodyBytes + armorVersion = "0.2.7" ) var ( @@ -137,6 +139,14 @@ func Validate(r Request) error { return fmt.Errorf("%w: invalid header value", ErrInvalidRequest) } } + if strings.TrimSpace(r.ReplyTo) != "" { + if _, err := mail.ParseAddress(r.ReplyTo); err != nil { + return fmt.Errorf("%w: invalid Reply-To address", ErrInvalidRequest) + } + } + if !validReferences(r.References) { + return fmt.Errorf("%w: invalid References message ID", ErrInvalidRequest) + } return nil } @@ -158,8 +168,9 @@ func composeMessage(r Request) ([]byte, error) { if r.Newsgroup != "" { b.WriteString("Newsgroups: " + r.Newsgroup + "\n") } - if r.References != "" { - b.WriteString("References: " + r.References + "\n") + if references := strings.Fields(r.References); len(references) > 0 { + b.WriteString("References: " + strings.Join(references, " ") + "\n") + b.WriteString("In-Reply-To: " + references[len(references)-1] + "\n") } b.WriteString("\n") b.WriteString(r.Body) @@ -167,6 +178,34 @@ func composeMessage(r Request) ([]byte, error) { } func validHeaderValue(value string) bool { return !strings.ContainsAny(value, "\r\n\x00") } +func validReferences(value string) bool { + if value == "" { + return true + } + if len(value) > maxReferencesBytes { + return false + } + references := strings.Fields(value) + if len(references) == 0 || len(references) > maxReferenceIDs { + return false + } + for _, reference := range references { + if len(reference) < 5 || reference[0] != '<' || reference[len(reference)-1] != '>' { + return false + } + messageID := reference[1 : len(reference)-1] + if strings.Count(messageID, "@") != 1 || strings.HasPrefix(messageID, "@") || strings.HasSuffix(messageID, "@") { + return false + } + for _, character := range messageID { + if character < 33 || character > 126 || character == '<' || character == '>' { + return false + } + } + } + return true +} + func isRemailerName(s string) bool { if s == "" { return false diff --git a/yamn/encoder/encoder_test.go b/yamn/encoder/encoder_test.go index 3509070..333b704 100644 --- a/yamn/encoder/encoder_test.go +++ b/yamn/encoder/encoder_test.go @@ -57,16 +57,83 @@ func TestValidateUsenetRequiresGatewayRecipient(t *testing.T) { func TestComposeUsenetGatewayHeaders(t *testing.T) { plain, err := composeMessage(Request{ Kind: Usenet, To: "mail2news@example.org", Subject: "test", Newsgroup: "misc.test", Body: "hello", + References: "", }) if err != nil { t.Fatal(err) } text := string(plain) - if !strings.Contains(text, "To: mail2news@example.org\n") || !strings.Contains(text, "Newsgroups: misc.test\n") { + if !strings.Contains(text, "To: mail2news@example.org\n") || + !strings.Contains(text, "Newsgroups: misc.test\n") || + !strings.Contains(text, "References: \n") || + !strings.Contains(text, "In-Reply-To: \n") { t.Fatalf("missing Usenet delivery headers: %q", text) } } +func TestComposeReferencesChainUsesLastIDAsParent(t *testing.T) { + plain, err := composeMessage(Request{ + Kind: Usenet, To: "mail2news@example.org", Subject: "test", Newsgroup: "misc.test", Body: "hello", + References: " ", + }) + if err != nil { + t.Fatal(err) + } + text := string(plain) + if !strings.Contains(text, "References: \n") || + !strings.Contains(text, "In-Reply-To: \n") { + t.Fatalf("thread headers were not normalized: %q", text) + } +} + +func TestValidateReferences(t *testing.T) { + tests := []struct { + name string + references string + valid bool + }{ + {name: "empty", valid: true}, + {name: "whitespace only", references: " "}, + {name: "parent", references: "", valid: true}, + {name: "thread chain", references: " ", valid: true}, + {name: "missing brackets", references: "parent@example.org"}, + {name: "missing local part", references: "<@example.org>"}, + {name: "multiple at signs", references: ""}, + {name: "embedded newline", references: "\n"}, + {name: "header label", references: "References: "}, + {name: "too long", references: "<" + strings.Repeat("a", maxReferencesBytes) + "@example.org>"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + request := Request{ + Kind: Email, PublicKeyring: "/tmp/pubring.mix", Entry: "entry", Chain: []string{"entry"}, + To: "user@example.org", Body: "hello", References: test.references, + } + err := Validate(request) + if test.valid && err != nil { + t.Fatalf("expected valid References, got %v", err) + } + if !test.valid && err == nil { + t.Fatal("expected invalid References to be rejected") + } + }) + } +} + +func TestValidateReplyTo(t *testing.T) { + request := Request{ + Kind: Email, PublicKeyring: "/tmp/pubring.mix", Entry: "entry", Chain: []string{"entry"}, + To: "user@example.org", Body: "hello", ReplyTo: "Pseudonym ", + } + if err := Validate(request); err != nil { + t.Fatalf("expected valid Reply-To, got %v", err) + } + request.ReplyTo = "not an address" + if err := Validate(request); err == nil { + t.Fatal("expected invalid Reply-To to be rejected") + } +} + func TestEncodeProducesYAMNArmor(t *testing.T) { keyring := testKeyring(t) result, err := Encode(Request{ -- cgit v1.2.3