From 5ec4ac226779fb2756e03cfaf83caeb249a771e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ferdinand=20M=C3=BCtsch?= Date: Sat, 27 Mar 2021 15:25:16 +0100 Subject: [PATCH] feat: spf validation of sender addresses --- README.md | 21 ++++++++++++++++ config.default.yml | 8 ++++-- config/config.go | 13 +++++++--- go.mod | 1 + go.sum | 32 ++++++++++++++++++++++++ service/spf.go | 53 ++++++++++++++++++++++++++++++++++++++++ types/client.go | 2 +- version.txt | 2 +- web/routes/api/client.go | 9 +++++++ 9 files changed, 134 insertions(+), 7 deletions(-) create mode 100644 service/spf.go diff --git a/README.md b/README.md index fef8970..fc5879e 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,27 @@ $ curl -XPOST \ 'http://localhost:3000/api/mail' ``` +## 🔧 Configuration Options +You can specify configuration options either via a config file (`config.yml`) or via environment variables. Here is an overview of all options. + +| YAML Key | Environment Variable | Default | Description | +|---------------------------|---------------------------|--------------|---------------------------------------------------------------------| +| `env` | `MW_ENV` | `dev` | Whether to use development- or production settings | +| `mail.domain` | `MW_MAIL_DOMAIN` | - | Default domain for sending mails | +| `mail.spf.check` | `MW_MAIL_SPF_CHECK` | `false` | Whether to validate sender address domains' SPF records | +| `mail.spf.authorized_ips` | - | - | List of IPs, at least one of which must be included in a sender domain's SPF record to accept the domain in `From` header for outgoing mails | +| `mail.spf.authorized_delegates` | - | - | List of domain names, at least one of which must be included in a sender domain's SPF record as an `include` to accept the domain in `From` header for outgoing mails | +| `web.listen_v4` | `MW_WEB_LISTEN_V4` | `127.0.0.1:3000` | IP and port for the web server to listen on | +| `web.cors_origin` | - | [`http://localhost:5000`] | List of URLs which to accept CORS requests for | +| `smtp.host` | `MW_SMTP_HOST` | - | SMTP relay host name or IP | +| `smtp.port` | `MW_SMTP_PORT` | - | SMTP relay port | +| `smtp.username` | `MW_SMTP_USER` | - | SMTP relay authentication user name | +| `smtp.password` | `MW_SMTP_PASS` | - | SMTP relay authentication password | +| `smtp.tls` | `MW_SMTP_TLS` | `false` | Whether to require full TLS (not to be confused with STARTTLS) for the SMTP relay | +| `store.path` | `MW_STORE_PATH` | `./data.gob.db` | Target location of the database file | +| `security.pepper` | `MW_SECURITY_PEPPER`| - | Pepper to use for hashing user passwords | +| `security.seed_users` | - | - | List of users to initially populate the database with (see above) | + ## 🚀 Features (planned) Right now, this app is very basic. However, there are several cool features on our roadmap. diff --git a/config.default.yml b/config.default.yml index 7c36d29..0064f79 100644 --- a/config.default.yml +++ b/config.default.yml @@ -1,7 +1,11 @@ env: dev # Affects log level and a few other things -app: - default_domain: mailwhale.dev # Your server's domain name +mail: + domain: mailwhale.dev # Your server's domain name + spf: + check: true + authorized_ips: # Option 1: Require specific IPs to be allowed by a sender domain's SPF record + authorized_delegates: # Option 2: Require certain domains to be set as an 'include' in a sender domain's SPF record web: listen_v4: '127.0.0.1:3000' # Where to make the http server listen diff --git a/config/config.go b/config/config.go index 3b99753..c034e7e 100644 --- a/config/config.go +++ b/config/config.go @@ -24,8 +24,15 @@ type EmailPasswordTuple struct { Password string } -type appConfig struct { - DefaultDomain string `yaml:"default_domain" env:"MW_APP_DEFAULT_DOMAIN"` +type mailConfig struct { + Domain string `yaml:"domain" env:"MW_MAIL_DOMAIN"` + SPF spfConfig +} + +type spfConfig struct { + Check bool `env:"MW_MAIL_SPF_CHECK"` + AuthorizedIPs []string `yaml:"authorized_ips"` + AuthorizedDelegates []string `yaml:"authorized_delegates"` } type smtpConfig struct { @@ -53,7 +60,7 @@ type securityConfig struct { type Config struct { Env string `default:"dev" env:"MW_ENV"` Version string - App appConfig + Mail mailConfig Web webConfig Smtp smtpConfig Store storeConfig diff --git a/go.mod b/go.mod index 726a67e..2a7df58 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/gorilla/handlers v1.5.1 github.com/gorilla/mux v1.8.0 github.com/jinzhu/configor v1.2.1 + github.com/mileusna/spf v0.9.1 github.com/rs/cors v1.7.0 github.com/timshannon/bolthold v0.0.0-20200817130212-4a25ab140645 go.etcd.io/bbolt v1.3.5 // indirect diff --git a/go.sum b/go.sum index 8d40787..5eff6e1 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,8 @@ github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/PetriTurunen/spf v0.0.0-20210320221637-59a99bb2f13d h1:6BDPrNVjXJBWSyrSIw6iJwOy7pcnCu6EaVqPzf4pGy8= +github.com/PetriTurunen/spf v0.0.0-20210320221637-59a99bb2f13d/go.mod h1:X3wteT/qG+9WiAaaKFYLJkpnQFu59HrsXr5eUiJXLww= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ= github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= github.com/emersion/go-smtp v0.14.0 h1:RYW203p+EcPjL8Z/ZpT9lZ6iOc8MG1MQzEx1UKEkXlA= @@ -16,22 +19,51 @@ github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/jinzhu/configor v1.2.1 h1:OKk9dsR8i6HPOCZR8BcMtcEImAFjIhbJFZNyn5GCZko= github.com/jinzhu/configor v1.2.1/go.mod h1:nX89/MOmDba7ZX7GCyU/VIaQ2Ar2aizBl2d3JLF/rDc= +github.com/miekg/dns v1.1.40 h1:pyyPFfGMnciYUk/mXpKkVmeMQjfXqt3FAJ2hy7tPiLA= +github.com/miekg/dns v1.1.40/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM= +github.com/miekg/dns v1.1.41 h1:WMszZWJG0XmzbK9FEmzH2TVcqYzFesusSIB41b8KHxY= +github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI= +github.com/mileusna/spf v0.9.1 h1:58+t0O3REOL24R7y9HYE9H9nctPMBztlKdLqX9vc41U= +github.com/mileusna/spf v0.9.1/go.mod h1:iqFhxD8PrmMHifFs6Ihf4jxQbkCzQA/0K5MQI2udIR4= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik= github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= +github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= +github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/timshannon/bolthold v0.0.0-20200817130212-4a25ab140645 h1:fbG8rLkRTpKD2rWD/vtQ6ceXZcnDglJssBieaPvieHw= github.com/timshannon/bolthold v0.0.0-20200817130212-4a25ab140645/go.mod h1:jUigdmrbdCxcIDEFrq82t4X9805XZfwFZoYUap0ET/U= go.etcd.io/bbolt v1.3.5 h1:XAzx9gjCb0Rxj7EoqcClPD1d5ZBxZJk0jbuoPHenBt0= go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad h1:DN0cp81fZ3njFcrLCytUHRSUkqBjfTo4Tx9RJTWs0EY= golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190923162816-aa69164e4478 h1:l5EDrHhldLYb3ZRHDUhXF7Om7MvYXnkV9/iQNo1lX6g= +golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 h1:qWPm9rbaAMKs8Bq/9LRpbMqxWRVUAQwMI9fVrssnTfw= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5 h1:LfCXLvNmTYH9kEmVgqbnsWfruoXZIrh4YBgqVHtDvw0= golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210303074136-134d130e1a04 h1:cEhElsAv9LUt9ZUUocxzWe05oFLVd+AA2nstydTeI8g= +golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= diff --git a/service/spf.go b/service/spf.go new file mode 100644 index 0000000..850f5df --- /dev/null +++ b/service/spf.go @@ -0,0 +1,53 @@ +package service + +import ( + "errors" + "github.com/mileusna/spf" + conf "github.com/muety/mailwhale/config" + "net" + "strings" +) + +type SpfService struct { + config *conf.Config +} + +func NewSpfService() *SpfService { + return &SpfService{ + config: conf.Get(), + } +} + +func (s *SpfService) Validate(senderAddress string) error { + if err := s.checkDelegate(senderAddress); err != nil { + if err := s.checkIp(senderAddress); err != nil { + return err + } + return nil + } + return nil +} + +func (s *SpfService) checkIp(senderAddress string) error { + domain := strings.Split(senderAddress, "@")[1] + for _, ip := range s.config.Mail.SPF.AuthorizedIPs { + if result := spf.CheckHost(net.ParseIP(ip), domain, senderAddress, ""); result == spf.Pass { + return nil + } + } + return errors.New("spf ip check did not pass") +} + +func (s *SpfService) checkDelegate(senderAddress string) error { + domain := strings.Split(senderAddress, "@")[1] + spfRecord, result := spf.LookupSPF(domain) + if result == spf.None || result == spf.TempError || result == spf.PermError { + return errors.New("spf lookup failed") + } + for _, d := range s.config.Mail.SPF.AuthorizedDelegates { + if strings.Contains(spfRecord, "include:"+d) { + return nil + } + } + return errors.New("spf delegate check did not pass") +} diff --git a/types/client.go b/types/client.go index 47de2a3..d472717 100644 --- a/types/client.go +++ b/types/client.go @@ -88,7 +88,7 @@ func (c *Client) DefaultSender() MailAddress { fmt.Sprintf( "%s+user@%s", strings.ToLower(c.ID[0:conf.ClientIdPrefixLength]), - conf.Get().App.DefaultDomain, + conf.Get().Mail.Domain, ), ) } diff --git a/version.txt b/version.txt index 09a3acf..bcaffe1 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -0.6.0 \ No newline at end of file +0.7.0 \ No newline at end of file diff --git a/web/routes/api/client.go b/web/routes/api/client.go index 56b310c..da9b297 100644 --- a/web/routes/api/client.go +++ b/web/routes/api/client.go @@ -17,6 +17,7 @@ type ClientHandler struct { config *conf.Config clientService *service.ClientService userService *service.UserService + spfService *service.SpfService } func NewClientHandler() *ClientHandler { @@ -24,6 +25,7 @@ func NewClientHandler() *ClientHandler { config: conf.Get(), clientService: service.NewClientService(), userService: service.NewUserService(), + spfService: service.NewSpfService(), } } @@ -83,6 +85,13 @@ func (h *ClientHandler) post(w http.ResponseWriter, r *http.Request) { return } + if payload.Sender != "" { + if err := h.spfService.Validate(payload.Sender.Raw()); err != nil { + util.RespondErrorMessage(w, r, http.StatusBadRequest, err) + return + } + } + client, err := h.clientService.Create(&payload) if err != nil { util.RespondError(w, r, http.StatusConflict, err)