diff --git a/api/helpers.go b/api/helpers.go index 6e4839d46..b04e4878f 100644 --- a/api/helpers.go +++ b/api/helpers.go @@ -4,7 +4,9 @@ import ( "context" "encoding/json" "fmt" + "net" "net/http" + "net/http/httptrace" "net/url" "github.com/netlify/gotrue/conf" @@ -12,6 +14,7 @@ import ( "github.com/netlify/gotrue/storage" "github.com/pkg/errors" "github.com/satori/go.uuid" + "github.com/sirupsen/logrus" ) func addRequestID(globalConfig *conf.GlobalConfiguration) middlewareHandler { @@ -107,3 +110,88 @@ func (a *API) getReferrer(r *http.Request) string { } return referrer } + +var privateIPBlocks []*net.IPNet + +func init() { + for _, cidr := range []string{ + "127.0.0.0/8", // IPv4 loopback + "10.0.0.0/8", // RFC1918 + "100.64.0.0/10", // RFC6598 + "172.16.0.0/12", // RFC1918 + "192.0.0.0/24", // RFC6890 + "192.168.0.0/16", // RFC1918 + "169.254.0.0/16", // RFC3927 + "::1/128", // IPv6 loopback + "fe80::/10", // IPv6 link-local + "fc00::/7", // IPv6 unique local addr + } { + _, block, _ := net.ParseCIDR(cidr) + privateIPBlocks = append(privateIPBlocks, block) + } +} + +func isPrivateIP(ip net.IP) bool { + for _, block := range privateIPBlocks { + if block.Contains(ip) { + return true + } + } + return false +} + +type noLocalTransport struct { + inner http.RoundTripper + errlog logrus.FieldLogger +} + +func (no noLocalTransport) RoundTrip(req *http.Request) (*http.Response, error) { + ctx, cancel := context.WithCancel(req.Context()) + + ctx = httptrace.WithClientTrace(ctx, &httptrace.ClientTrace{ + ConnectStart: func(network, addr string) { + fmt.Printf("Checking network %v\n", addr) + host, _, err := net.SplitHostPort(addr) + if err != nil { + cancel() + fmt.Printf("Canceleing dur to error in addr parsing %v", err) + return + } + ip := net.ParseIP(host) + if ip == nil { + cancel() + fmt.Printf("Canceleing dur to error in ip parsing %v", host) + return + } + + if isPrivateIP(ip) { + cancel() + fmt.Println("Canceleing dur to private ip range") + return + } + + }, + }) + + req = req.WithContext(ctx) + return no.inner.RoundTrip(req) +} + +func SafeRountripper(trans http.RoundTripper, log logrus.FieldLogger) http.RoundTripper { + if trans == nil { + trans = http.DefaultTransport + } + + ret := &noLocalTransport{ + inner: trans, + errlog: log.WithField("transport", "local_blocker"), + } + + return ret +} + +func SafeHTTPClient(client *http.Client, log logrus.FieldLogger) *http.Client { + client.Transport = SafeRountripper(client.Transport, log) + + return client +} diff --git a/api/hooks.go b/api/hooks.go index 098126e4c..0554a8e08 100644 --- a/api/hooks.go +++ b/api/hooks.go @@ -66,16 +66,16 @@ func (w *Webhook) trigger() (io.ReadCloser, error) { w.Retries = defaultHookRetries } - client := http.Client{ - Timeout: timeout, - } - hooklog := logrus.WithFields(logrus.Fields{ "component": "webhook", "url": w.URL, "signed": w.jwtSecret != "", "instance_id": w.instanceID, }) + client := http.Client{ + Timeout: timeout, + } + client.Transport = SafeRountripper(client.Transport, hooklog) for i := 0; i < w.Retries; i++ { hooklog = hooklog.WithField("attempt", i+1) diff --git a/mailer/mailer.go b/mailer/mailer.go index 3c25f7407..c9071de05 100644 --- a/mailer/mailer.go +++ b/mailer/mailer.go @@ -2,10 +2,10 @@ package mailer import ( "net/url" + "regexp" "github.com/netlify/gotrue/conf" "github.com/netlify/gotrue/models" - "github.com/netlify/mailme" ) // Mailer defines the interface a mailer must implement. @@ -27,7 +27,7 @@ func NewMailer(instanceConfig *conf.Configuration) Mailer { return &TemplateMailer{ SiteURL: instanceConfig.SiteURL, Config: instanceConfig, - Mailer: &mailme.Mailer{ + Mailer: &MailmeMailer{ Host: instanceConfig.SMTP.Host, Port: instanceConfig.SMTP.Port, User: instanceConfig.SMTP.User, @@ -65,3 +65,9 @@ func getSiteURL(referrerURL, siteURL, filepath, fragment string) (string, error) site.Fragment = fragment return site.String(), nil } + +var urlRegexp = regexp.MustCompile(`^https?://[^/]+`) + +func enforceRelativeURL(url string) string { + return urlRegexp.ReplaceAllString(url, "") +} diff --git a/mailer/mailer_test.go b/mailer/mailer_test.go index a24dc0880..bfedeaf70 100644 --- a/mailer/mailer_test.go +++ b/mailer/mailer_test.go @@ -29,3 +29,21 @@ func TestGetSiteURL(t *testing.T) { assert.Equal(t, c.Expected, act) } } + +func TestRelativeURL(t *testing.T) { + cases := []struct { + URL string + Expected string + }{ + {"https://test.example.com", ""}, + {"http://test.example.com", ""}, + {"test.example.com", "test.example.com"}, + {"/some/path#fragment", "/some/path#fragment"}, + } + + for _, c := range cases { + res := enforceRelativeURL(c.URL) + assert.Equal(t, c.Expected, res, c.URL) + } +} + diff --git a/mailer/mailme.go b/mailer/mailme.go new file mode 100644 index 000000000..0a6ef5cbc --- /dev/null +++ b/mailer/mailme.go @@ -0,0 +1,271 @@ +package mailer + +import ( + "bytes" + "context" + "errors" + "fmt" + "html/template" + "io/ioutil" + "log" + "net" + "net/http" + "net/http/httptrace" + "strings" + "sync" + "time" + + "gopkg.in/gomail.v2" + + "github.com/sirupsen/logrus" +) + +// TemplateRetries is the amount of time MailMe will try to fetch a URL before giving up +const TemplateRetries = 3 + +// TemplateExpiration is the time period that the template will be cached for +const TemplateExpiration = 10 * time.Second + +// Mailer lets MailMe send templated mails +type MailmeMailer struct { + From string + Host string + Port int + User string + Pass string + BaseURL string + FuncMap template.FuncMap + cache *TemplateCache +} + +// Mail sends a templated mail. It will try to load the template from a URL, and +// otherwise fall back to the default +func (m *MailmeMailer) Mail(to, subjectTemplate, templateURL, defaultTemplate string, templateData map[string]interface{}) error { + if m.FuncMap == nil { + m.FuncMap = map[string]interface{}{} + } + if m.cache == nil { + m.cache = &TemplateCache{templates: map[string]*MailTemplate{}, funcMap: m.FuncMap} + } + + tmp, err := template.New("Subject").Funcs(template.FuncMap(m.FuncMap)).Parse(subjectTemplate) + if err != nil { + return err + } + + subject := &bytes.Buffer{} + err = tmp.Execute(subject, templateData) + if err != nil { + return err + } + body, err := m.MailBody(templateURL, defaultTemplate, templateData) + if err != nil { + return err + } + + mail := gomail.NewMessage() + mail.SetHeader("From", m.From) + mail.SetHeader("To", to) + mail.SetHeader("Subject", subject.String()) + mail.SetBody("text/html", body) + + dial := gomail.NewPlainDialer(m.Host, m.Port, m.User, m.Pass) + return dial.DialAndSend(mail) + +} + +type MailTemplate struct { + tmp *template.Template + expiresAt time.Time +} + +type TemplateCache struct { + templates map[string]*MailTemplate + mutex sync.Mutex + funcMap template.FuncMap +} + +func (t *TemplateCache) Get(url string) (*template.Template, error) { + cached, ok := t.templates[url] + if ok && (cached.expiresAt.Before(time.Now())) { + return cached.tmp, nil + } + data, err := t.fetchTemplate(url, TemplateRetries) + if err != nil { + return nil, err + } + return t.Set(url, data, TemplateExpiration) +} + +func (t *TemplateCache) Set(key, value string, expirationTime time.Duration) (*template.Template, error) { + parsed, err := template.New(key).Funcs(t.funcMap).Parse(value) + if err != nil { + return nil, err + } + + cached := &MailTemplate{ + tmp: parsed, + expiresAt: time.Now().Add(expirationTime), + } + t.mutex.Lock() + t.templates[key] = cached + t.mutex.Unlock() + return parsed, nil +} + +func (t *TemplateCache) fetchTemplate(url string, triesLeft int) (string, error) { + client := &http.Client{} + client.Transport = SafeRountripper(client.Transport, logrus.New()) + + resp, err := client.Get(url) + if err != nil && triesLeft > 0 { + return t.fetchTemplate(url, triesLeft-1) + } + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode == 200 { // OK + bodyBytes, err := ioutil.ReadAll(resp.Body) + if err != nil && triesLeft > 0 { + return t.fetchTemplate(url, triesLeft-1) + } + if err != nil { + return "", err + } + return string(bodyBytes), err + } + if triesLeft > 0 { + return t.fetchTemplate(url, triesLeft-1) + } + return "", errors.New("Unable to fetch mail template") +} + +func (m *MailmeMailer) MailBody(url string, defaultTemplate string, data map[string]interface{}) (string, error) { + if m.FuncMap == nil { + m.FuncMap = map[string]interface{}{} + } + if m.cache == nil { + m.cache = &TemplateCache{templates: map[string]*MailTemplate{}, funcMap: m.FuncMap} + } + + var temp *template.Template + var err error + + if url != "" { + var absoluteURL string + if strings.HasPrefix(url, "http") { + absoluteURL = url + } else { + absoluteURL = m.BaseURL + url + } + temp, err = m.cache.Get(absoluteURL) + if err != nil { + log.Printf("Error loading template from %v: %v\n", url, err) + } + } + + if temp == nil { + cached, ok := m.cache.templates[url] + if ok { + temp = cached.tmp + } else { + temp, err = m.cache.Set(url, defaultTemplate, 0) + if err != nil { + return "", err + } + } + } + + buf := &bytes.Buffer{} + err = temp.Execute(buf, data) + if err != nil { + return "", err + } + return buf.String(), nil +} + +var privateIPBlocks []*net.IPNet + +func init() { + for _, cidr := range []string{ + "127.0.0.0/8", // IPv4 loopback + "10.0.0.0/8", // RFC1918 + "100.64.0.0/10", // RFC6598 + "172.16.0.0/12", // RFC1918 + "192.0.0.0/24", // RFC6890 + "192.168.0.0/16", // RFC1918 + "169.254.0.0/16", // RFC3927 + "::1/128", // IPv6 loopback + "fe80::/10", // IPv6 link-local + "fc00::/7", // IPv6 unique local addr + } { + _, block, _ := net.ParseCIDR(cidr) + privateIPBlocks = append(privateIPBlocks, block) + } +} + +func isPrivateIP(ip net.IP) bool { + for _, block := range privateIPBlocks { + if block.Contains(ip) { + return true + } + } + return false +} + +type noLocalTransport struct { + inner http.RoundTripper + errlog logrus.FieldLogger +} + +func (no noLocalTransport) RoundTrip(req *http.Request) (*http.Response, error) { + ctx, cancel := context.WithCancel(req.Context()) + + ctx = httptrace.WithClientTrace(ctx, &httptrace.ClientTrace{ + ConnectStart: func(network, addr string) { + fmt.Printf("Checking network %v\n", addr) + host, _, err := net.SplitHostPort(addr) + if err != nil { + cancel() + fmt.Printf("Canceleing dur to error in addr parsing %v", err) + return + } + ip := net.ParseIP(host) + if ip == nil { + cancel() + fmt.Printf("Canceleing dur to error in ip parsing %v", host) + return + } + + if isPrivateIP(ip) { + cancel() + fmt.Println("Canceleing dur to private ip range") + return + } + + }, + }) + + req = req.WithContext(ctx) + return no.inner.RoundTrip(req) +} + +func SafeRountripper(trans http.RoundTripper, log logrus.FieldLogger) http.RoundTripper { + if trans == nil { + trans = http.DefaultTransport + } + + ret := &noLocalTransport{ + inner: trans, + errlog: log.WithField("transport", "local_blocker"), + } + + return ret +} + +func SafeHTTPClient(client *http.Client, log logrus.FieldLogger) *http.Client { + client.Transport = SafeRountripper(client.Transport, log) + + return client +} diff --git a/mailer/template.go b/mailer/template.go index 3e7c9a2d7..da0964d0e 100644 --- a/mailer/template.go +++ b/mailer/template.go @@ -4,14 +4,13 @@ import ( "github.com/badoux/checkmail" "github.com/netlify/gotrue/conf" "github.com/netlify/gotrue/models" - "github.com/netlify/mailme" ) // TemplateMailer will send mail and use templates from the site for easy mail styling type TemplateMailer struct { SiteURL string Config *conf.Configuration - Mailer *mailme.Mailer + Mailer *MailmeMailer } const defaultInviteMail = `<h2>You have been invited</h2> @@ -57,7 +56,7 @@ func (m *TemplateMailer) InviteMail(user *models.User, referrerURL string) error return m.Mailer.Mail( user.Email, string(withDefault(m.Config.Mailer.Subjects.Invite, "You have been invited")), - m.Config.Mailer.Templates.Invite, + enforceRelativeURL(m.Config.Mailer.Templates.Invite), defaultInviteMail, data, ) @@ -80,7 +79,7 @@ func (m *TemplateMailer) ConfirmationMail(user *models.User, referrerURL string) return m.Mailer.Mail( user.Email, string(withDefault(m.Config.Mailer.Subjects.Confirmation, "Confirm Your Signup")), - m.Config.Mailer.Templates.Confirmation, + enforceRelativeURL(m.Config.Mailer.Templates.Confirmation), defaultConfirmationMail, data, ) @@ -104,7 +103,7 @@ func (m *TemplateMailer) EmailChangeMail(user *models.User, referrerURL string) return m.Mailer.Mail( user.EmailChange, string(withDefault(m.Config.Mailer.Subjects.EmailChange, "Confirm Email Change")), - m.Config.Mailer.Templates.EmailChange, + enforceRelativeURL(m.Config.Mailer.Templates.EmailChange), defaultEmailChangeMail, data, ) @@ -127,7 +126,7 @@ func (m *TemplateMailer) RecoveryMail(user *models.User, referrerURL string) err return m.Mailer.Mail( user.Email, string(withDefault(m.Config.Mailer.Subjects.Recovery, "Reset Your Password")), - m.Config.Mailer.Templates.Recovery, + enforceRelativeURL(m.Config.Mailer.Templates.Recovery), defaultRecoveryMail, data, )