From f920d0fceaea410948186d429be37eaffeb3a4ba Mon Sep 17 00:00:00 2001 From: David Bennington Date: Wed, 6 Aug 2025 13:24:40 +0100 Subject: [PATCH] Add support for postfix XFORWARD command. --- backend.go | 4 ++ cmd/smtp-debug-server/main.go | 4 ++ conn.go | 41 +++++++++++++++ example_server_test.go | 6 +++ parse.go | 2 + server.go | 6 +++ server_test.go | 97 ++++++++++++++++++++++++++++++++++- 7 files changed, 159 insertions(+), 1 deletion(-) diff --git a/backend.go b/backend.go index f1beb203..7a193701 100644 --- a/backend.go +++ b/backend.go @@ -63,6 +63,10 @@ type Session interface { // // r must be consumed before Data returns. Data(r io.Reader) error + + // XForward is called for any attribute name/value pair. + // See https://www.postfix.org/XFORWARD_README.html + XForward(attrName, attrValue string) error } // LMTPSession is an add-on interface for Session. It can be implemented by diff --git a/cmd/smtp-debug-server/main.go b/cmd/smtp-debug-server/main.go index d54f4fc4..2ed3b29e 100644 --- a/cmd/smtp-debug-server/main.go +++ b/cmd/smtp-debug-server/main.go @@ -39,6 +39,10 @@ func (s *session) Data(r io.Reader) error { return nil } +func (s *session) XForward(attrName, attrValue string) error { + return nil +} + func (s *session) Reset() {} func (s *session) Logout() error { diff --git a/conn.go b/conn.go index ff43e0ed..ce215402 100644 --- a/conn.go +++ b/conn.go @@ -137,6 +137,8 @@ func (c *Conn) handle(cmd string, arg string) { c.handleBdat(arg) case "DATA": c.handleData(arg) + case "XFORWARD": + c.handleXForward(arg) case "QUIT": c.writeResponse(221, EnhancedCode{2, 0, 0}, "Bye") c.Close() @@ -308,6 +310,9 @@ func (c *Conn) handleGreet(enhanced bool, arg string) { caps = append(caps, fmt.Sprintf("MT-PRIORITY %s", c.server.MtPriorityProfile)) } } + if c.server.EnableXFORWARD { + caps = append(caps, "XFORWARD NAME ADDR PROTO HELO") + } args := []string{"Hello " + domain} args = append(args, caps...) @@ -990,6 +995,42 @@ func (c *Conn) handleData(arg string) { c.writeResponse(code, enhancedCode, msg) } +func (c *Conn) handleXForward(arg string) { + /* + Handling is according to https://www.postfix.org/XFORWARD_README.html + xforward-command = XFORWARD 1*( SP attribute-name"="attribute-value ) + attribute-name = ( NAME | ADDR | PORT | PROTO | HELO | IDENT | SOURCE ) + attribute-value = xtext + Attribute values are xtext encoded as per RFC 1891. https://datatracker.ietf.org/doc/html/rfc1891 + */ + + if c.bdatPipe != nil { + c.writeResponse(502, EnhancedCode{5, 5, 1}, "XFORWARD not allowed during message transfer") + return + } + + attributes := strings.Split(arg, " ") + for _, attribute := range attributes { + if !strings.Contains(attribute, "=") { + c.writeResponse(501, EnhancedCode{5, 5, 1}, "Bad XFORWARD command.") + return + } + nameValue := strings.SplitN(attribute, "=", 2) + attrName := strings.ToUpper(nameValue[0]) // attr names are case insensitive, normalise + attrValue, err := decodeXtext(nameValue[1]) + if err != nil { + c.writeResponse(501, EnhancedCode{5, 5, 1}, "Bad XFORWARD value.") + return + } + err = c.Session().XForward(attrName, attrValue) + if err != nil { + c.writeResponse(501, EnhancedCode{5, 0, 0}, "Error: transaction failed: "+err.Error()) + return + } + } + c.writeResponse(250, EnhancedCode{2, 0, 0}, "OK") +} + func (c *Conn) handleBdat(arg string) { args := strings.Fields(arg) if len(args) == 0 { diff --git a/example_server_test.go b/example_server_test.go index 71f4a4fb..dcb5d68a 100644 --- a/example_server_test.go +++ b/example_server_test.go @@ -68,6 +68,11 @@ func (s *Session) Data(r io.Reader) error { return nil } +func (s *Session) XForward(attrName, attrValue string) error { + log.Printf("XFORWARD %s=%s\n", attrName, attrValue) + return nil +} + func (s *Session) Reset() {} func (s *Session) Logout() error { @@ -99,6 +104,7 @@ func ExampleServer() { s.MaxMessageBytes = 1024 * 1024 s.MaxRecipients = 50 s.AllowInsecureAuth = true + s.EnableXFORWARD = true log.Println("Starting server at", s.Addr) if err := s.ListenAndServe(); err != nil { diff --git a/parse.go b/parse.go index 14d597d0..fd26b10c 100644 --- a/parse.go +++ b/parse.go @@ -22,6 +22,8 @@ func parseCmd(line string) (cmd string, arg string, err error) { switch { case strings.HasPrefix(strings.ToUpper(line), "STARTTLS"): return "STARTTLS", "", nil + case strings.HasPrefix(strings.ToUpper(line), "XFORWARD"): + return "XFORWARD", strings.TrimSpace(line[8:]), nil case l == 0: return "", "", nil case l < 4: diff --git a/server.go b/server.go index e0e0acd0..ed9c0ccb 100644 --- a/server.go +++ b/server.go @@ -73,6 +73,12 @@ type Server struct { // Advertise MT-PRIORITY (RFC 6710) capability. // Should only be used if backend supports it. EnableMTPRIORITY bool + + // Advertise XFORWARD NAME ADDR PROTO HELO + // (https://www.postfix.org/XFORWARD_README.html) capability. + // Should only be used if backend supports it. + EnableXFORWARD bool + // The priority profile mapping as defined // in RFC 6710 section 10.2. // diff --git a/server_test.go b/server_test.go index 06db7468..f4627124 100644 --- a/server_test.go +++ b/server_test.go @@ -25,6 +25,7 @@ type message struct { RcptOpts []*smtp.RcptOptions Data []byte Opts *smtp.MailOptions + XForward map[string]string } type backend struct { @@ -98,7 +99,9 @@ func (s *session) Auth(mech string) (sasl.Server, error) { } func (s *session) Reset() { - s.msg = &message{} + s.msg = &message{ + XForward: make(map[string]string), + } } func (s *session) Logout() error { @@ -157,6 +160,11 @@ func (s *session) Data(r io.Reader) error { return nil } +func (s *session) XForward(attrName, attrValue string) error { + s.msg.XForward[attrName] = attrValue + return nil +} + func (s *session) LMTPData(r io.Reader, collector smtp.StatusCollector) error { if err := s.Data(r); err != nil { return err @@ -1712,3 +1720,90 @@ func TestServerMTPRIORITY(t *testing.T) { t.Fatal("Incorrect MtPriority parameter value:", fmt.Sprintf("expected %d, got %d", expectedPriority, *priority)) } } + +func TestServerXFORWARD(t *testing.T) { + be, s, c, scanner, caps := testServerEhlo(t, + func(s *smtp.Server) { + s.EnableXFORWARD = true + }) + defer s.Close() + defer c.Close() + + if _, ok := caps["XFORWARD NAME ADDR PROTO HELO"]; !ok { + t.Fatal("Missing capability: XFORWARD") + } + + io.WriteString(c, "MAIL FROM:\r\n") + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "250 ") { + t.Fatal("Invalid MAIL response:", scanner.Text()) + } + + io.WriteString(c, "RCPT TO:\r\n") + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "250 ") { + t.Fatal("Invalid RCPT response:", scanner.Text()) + } + + // Atrribute names are case insensitive, check we normalised them + io.WriteString(c, "XFORWARD NAME=spike.porcupine.org ADDR=168.100.189.2 proto=ESMTP\r\n") + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "250 ") { + t.Fatal("Invalid XFORWARD response:", scanner.Text()) + } + + io.WriteString(c, "DATA\r\n") + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "354 ") { + t.Fatal("Invalid DATA response:", scanner.Text()) + } + + io.WriteString(c, "From: root@nsa.gov\r\n") + io.WriteString(c, "\r\n") + io.WriteString(c, "Hey\r <3\r\n") + io.WriteString(c, "..this dot is fine\r\n") + io.WriteString(c, ".\r\n") + scanner.Scan() + if !strings.HasPrefix(scanner.Text(), "250 ") { + t.Fatal("Invalid DATA response:", scanner.Text()) + } + + if len(be.messages) != 0 || len(be.anonmsgs) != 1 { + t.Fatal("Invalid number of sent messages:", be.messages, be.anonmsgs) + } + + msg := be.anonmsgs[0] + + if len(msg.XForward) != 3 { + t.Fatal("Invalid XFORWARD data:", msg.XForward) + } + + if got, ok := msg.XForward["NAME"]; !ok { + t.Fatal("Missing XFORWARD attribute NAME") + } else if got != "spike.porcupine.org" { + t.Fatal("Invalid XFORWARD attribute value for NAME", got) + } + + if got, ok := msg.XForward["ADDR"]; !ok { + t.Fatal("Missing XFORWARD attribute ADDR") + } else if got != "168.100.189.2" { + t.Fatal("Invalid XFORWARD attribute value for ADDR", got) + } + + if got, ok := msg.XForward["PROTO"]; !ok { + t.Fatal("Missing (normalised) XFORWARD attribute PROTO") + } else if got != "ESMTP" { + t.Fatal("Invalid (normalised) XFORWARD attribute value for PROTO", got) + } + + // Sanity that the rest of the connection continued fine + if msg.From != "root@nsa.gov" { + t.Fatal("Invalid mail sender:", msg.From) + } + if len(msg.To) != 1 || msg.To[0] != "root@gchq.gov.uk" { + t.Fatal("Invalid mail recipients:", msg.To) + } + if string(msg.Data) != "From: root@nsa.gov\r\n\r\nHey\r <3\r\n.this dot is fine\r\n" { + t.Fatal("Invalid mail data:", string(msg.Data)) + } +}