summaryrefslogtreecommitdiffstats
path: root/yamn
diff options
context:
space:
mode:
authorGab <24553253+gabrix73@users.noreply.github.com>2026-08-16 19:24:16 +0200
committerGab <24553253+gabrix73@users.noreply.github.com>2026-08-16 19:24:16 +0200
commit56e296e5875b10ed053cbdedbab08957fce2a461 (patch)
treee1c49b328f1fe5142f7e2b27593b0905c5e3668a /yamn
parent43fbddf016f94f4ba006d82c9a67dca61b5852a1 (diff)
downloadyamnweb-main.tar.gz
yamnweb-main.tar.xz
yamnweb-main.zip
Harden Usenet threading and ingress deliveryHEADmain
Diffstat (limited to 'yamn')
-rw-r--r--yamn/encoder/encoder.go57
-rw-r--r--yamn/encoder/encoder_test.go69
2 files changed, 116 insertions, 10 deletions
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: "<parent@example.org>",
})
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: <parent@example.org>\n") ||
+ !strings.Contains(text, "In-Reply-To: <parent@example.org>\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: " <root@example.org> <parent@example.org> ",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ text := string(plain)
+ if !strings.Contains(text, "References: <root@example.org> <parent@example.org>\n") ||
+ !strings.Contains(text, "In-Reply-To: <parent@example.org>\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: "<parent@example.org>", valid: true},
+ {name: "thread chain", references: "<root@example.org> <parent@example.org>", valid: true},
+ {name: "missing brackets", references: "parent@example.org"},
+ {name: "missing local part", references: "<@example.org>"},
+ {name: "multiple at signs", references: "<parent@example@org>"},
+ {name: "embedded newline", references: "<root@example.org>\n<parent@example.org>"},
+ {name: "header label", references: "References: <parent@example.org>"},
+ {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 <reply@example.org>",
+ }
+ 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{