diff --git a/controllers/api_test.go b/controllers/api_test.go index 867963e3..77a4b57e 100644 --- a/controllers/api_test.go +++ b/controllers/api_test.go @@ -24,6 +24,9 @@ type ControllersSuite struct { // as is the Admin Server for our API calls var as *httptest.Server = httptest.NewUnstartedServer(handlers.CombinedLoggingHandler(os.Stdout, CreateAdminRouter())) +// ps is the Phishing Server +var ps *httptest.Server = httptest.NewUnstartedServer(handlers.CombinedLoggingHandler(os.Stdout, CreatePhishingRouter())) + func (s *ControllersSuite) SetupSuite() { config.Conf.DBName = "sqlite3" config.Conf.DBPath = ":memory:" @@ -40,6 +43,63 @@ func (s *ControllersSuite) SetupSuite() { u, err := models.GetUser(1) s.Nil(err) s.ApiKey = u.ApiKey + // Start the phishing server + ps.Config.Addr = config.Conf.PhishConf.ListenURL + ps.Start() + // Move our cwd up to the project root for help with resolving + // static assets + err = os.Chdir("../") + s.Nil(err) +} + +func (s *ControllersSuite) TearDownTest() { + campaigns, _ := models.GetCampaigns(1) + for _, campaign := range campaigns { + models.DeleteCampaign(campaign.Id) + } +} + +func (s *ControllersSuite) SetupTest() { + // Add a group + group := models.Group{Name: "Test Group"} + group.Targets = []models.Target{ + models.Target{Email: "test1@example.com", FirstName: "First", LastName: "Example"}, + models.Target{Email: "test2@example.com", FirstName: "Second", LastName: "Example"}, + } + group.UserId = 1 + models.PostGroup(&group) + + // Add a template + t := models.Template{Name: "Test Template"} + t.Subject = "Test subject" + t.Text = "Text text" + t.HTML = "Test" + t.UserId = 1 + models.PostTemplate(&t) + + // Add a landing page + p := models.Page{Name: "Test Page"} + p.HTML = "Test" + p.UserId = 1 + models.PostPage(&p) + + // Add a sending profile + smtp := models.SMTP{Name: "Test Page"} + smtp.UserId = 1 + smtp.Host = "example.com" + smtp.FromAddress = "test@test.com" + models.PostSMTP(&smtp) + + // Setup and "launch" our campaign + // Set the status such that no emails are attempted + c := models.Campaign{Name: "Test campaign"} + c.UserId = 1 + c.Template = t + c.Page = p + c.SMTP = smtp + c.Groups = []models.Group{group} + models.PostCampaign(&c, c.UserId) + c.UpdateStatus(models.CAMPAIGN_EMAILS_SENT) } func (s *ControllersSuite) TestSiteImportBaseHref() { @@ -65,8 +125,9 @@ func (s *ControllersSuite) TestSiteImportBaseHref() { } func (s *ControllersSuite) TearDownSuite() { - // Tear down the admin server + // Tear down the admin and phishing servers as.Close() + ps.Close() } func TestControllerSuite(t *testing.T) { diff --git a/controllers/phish.go b/controllers/phish.go new file mode 100644 index 00000000..6c35c01f --- /dev/null +++ b/controllers/phish.go @@ -0,0 +1,202 @@ +package controllers + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "html/template" + "net" + "net/http" + "net/mail" + "net/url" + "strings" + + ctx "github.com/gophish/gophish/context" + "github.com/gophish/gophish/models" + "github.com/gorilla/mux" +) + +// ErrInvalidRequest is thrown when a request with an invalid structure is +// received +var ErrInvalidRequest = errors.New("Invalid request") + +// ErrCampaignComplete is thrown when an event is received for a campaign that +// has already been marked as complete. +var ErrCampaignComplete = errors.New("Event received on completed campaign") + +// eventDetails is a struct that wraps common attributes we want to store +// in an event +type eventDetails struct { + Payload url.Values `json:"payload"` + Browser map[string]string `json:"browser"` +} + +// CreatePhishingRouter creates the router that handles phishing connections. +func CreatePhishingRouter() http.Handler { + router := mux.NewRouter() + router.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir("./static/endpoint/")))) + router.HandleFunc("/track", PhishTracker) + router.HandleFunc("/robots.txt", RobotsHandler) + router.HandleFunc("/{path:.*}/track", PhishTracker) + router.HandleFunc("/{path:.*}", PhishHandler) + return router +} + +// PhishTracker tracks emails as they are opened, updating the status for the given Result +func PhishTracker(w http.ResponseWriter, r *http.Request) { + err, r := setupContext(r) + if err != nil { + // Log the error if it wasn't something we can safely ignore + if err != ErrInvalidRequest && err != ErrCampaignComplete { + Logger.Println(err) + } + http.NotFound(w, r) + return + } + rs := ctx.Get(r, "result").(models.Result) + c := ctx.Get(r, "campaign").(models.Campaign) + rj := ctx.Get(r, "details").([]byte) + c.AddEvent(models.Event{Email: rs.Email, Message: models.EVENT_OPENED, Details: string(rj)}) + // Don't update the status if the user already clicked the link + // or submitted data to the campaign + if rs.Status == models.EVENT_CLICKED || rs.Status == models.EVENT_DATA_SUBMIT { + http.ServeFile(w, r, "static/images/pixel.png") + return + } + err = rs.UpdateStatus(models.EVENT_OPENED) + if err != nil { + Logger.Println(err) + } + http.ServeFile(w, r, "static/images/pixel.png") +} + +// PhishHandler handles incoming client connections and registers the associated actions performed +// (such as clicked link, etc.) +func PhishHandler(w http.ResponseWriter, r *http.Request) { + err, r := setupContext(r) + if err != nil { + // Log the error if it wasn't something we can safely ignore + if err != ErrInvalidRequest && err != ErrCampaignComplete { + Logger.Println(err) + } + http.NotFound(w, r) + return + } + rs := ctx.Get(r, "result").(models.Result) + c := ctx.Get(r, "campaign").(models.Campaign) + rj := ctx.Get(r, "details").([]byte) + p, err := models.GetPage(c.PageId, c.UserId) + if err != nil { + Logger.Println(err) + } + switch { + case r.Method == "GET": + if rs.Status != models.EVENT_CLICKED && rs.Status != models.EVENT_DATA_SUBMIT { + rs.UpdateStatus(models.EVENT_CLICKED) + } + err = c.AddEvent(models.Event{Email: rs.Email, Message: models.EVENT_CLICKED, Details: string(rj)}) + if err != nil { + Logger.Println(err) + } + case r.Method == "POST": + // If data was POST'ed, let's record it + rs.UpdateStatus(models.EVENT_DATA_SUBMIT) + // Store the data in an event + c.AddEvent(models.Event{Email: rs.Email, Message: models.EVENT_DATA_SUBMIT, Details: string(rj)}) + if err != nil { + Logger.Println(err) + } + // Redirect to the desired page + if p.RedirectURL != "" { + http.Redirect(w, r, p.RedirectURL, 302) + return + } + } + var htmlBuff bytes.Buffer + tmpl, err := template.New("html_template").Parse(p.HTML) + if err != nil { + Logger.Println(err) + http.NotFound(w, r) + } + f, err := mail.ParseAddress(c.SMTP.FromAddress) + if err != nil { + Logger.Println(err) + } + fn := f.Name + if fn == "" { + fn = f.Address + } + rsf := struct { + models.Result + URL string + From string + }{ + rs, + c.URL + "?rid=" + rs.RId, + fn, + } + err = tmpl.Execute(&htmlBuff, rsf) + if err != nil { + Logger.Println(err) + http.NotFound(w, r) + } + w.Write(htmlBuff.Bytes()) +} + +// RobotsHandler prevents search engines, etc. from indexing phishing materials +func RobotsHandler(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, "User-agent: *\nDisallow: /") +} + +// setupContext handles some of the administrative work around receiving a new request, such as checking the result ID, the campaign, etc. +func setupContext(r *http.Request) (error, *http.Request) { + err := r.ParseForm() + if err != nil { + Logger.Println(err) + return err, r + } + id := r.Form.Get("rid") + if id == "" { + return ErrInvalidRequest, r + } + rs, err := models.GetResult(id) + if err != nil { + return err, r + } + c, err := models.GetCampaign(rs.CampaignId, rs.UserId) + if err != nil { + Logger.Println(err) + return err, r + } + // Don't process events for completed campaigns + if c.Status == models.CAMPAIGN_COMPLETE { + return ErrCampaignComplete, r + } + ip, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + Logger.Println(err) + return err, r + } + // Respect X-Forwarded headers + if fips := r.Header.Get("X-Forwarded-For"); fips != "" { + ip = strings.Split(fips, ", ")[0] + } + // Handle post processing such as GeoIP + err = rs.UpdateGeo(ip) + if err != nil { + Logger.Println(err) + } + d := eventDetails{ + Payload: r.Form, + Browser: make(map[string]string), + } + d.Browser["address"] = ip + d.Browser["user-agent"] = r.Header.Get("User-Agent") + rj, err := json.Marshal(d) + + r = ctx.Set(r, "result", rs) + r = ctx.Set(r, "campaign", c) + r = ctx.Set(r, "details", rj) + return nil, r +} diff --git a/controllers/phish_test.go b/controllers/phish_test.go new file mode 100644 index 00000000..5a18ac4d --- /dev/null +++ b/controllers/phish_test.go @@ -0,0 +1,128 @@ +package controllers + +import ( + "bytes" + "fmt" + "io/ioutil" + "net/http" + + "github.com/gophish/gophish/models" +) + +func (s *ControllersSuite) getFirstCampaign() models.Campaign { + campaigns, err := models.GetCampaigns(1) + s.Nil(err) + return campaigns[0] +} + +func (s *ControllersSuite) openEmail(rid string) { + resp, err := http.Get(fmt.Sprintf("%s/track?rid=%s", ps.URL, rid)) + s.Nil(err) + defer resp.Body.Close() + body, err := ioutil.ReadAll(resp.Body) + s.Nil(err) + expected, err := ioutil.ReadFile("static/images/pixel.png") + s.Nil(err) + s.Equal(bytes.Compare(body, expected), 0) +} + +func (s *ControllersSuite) openEmail404(rid string) { + resp, err := http.Get(fmt.Sprintf("%s/track?rid=%s", ps.URL, rid)) + s.Nil(err) + defer resp.Body.Close() + s.Nil(err) + s.Equal(resp.StatusCode, http.StatusNotFound) +} + +func (s *ControllersSuite) clickLink(rid string, campaign models.Campaign) { + resp, err := http.Get(fmt.Sprintf("%s/?rid=%s", ps.URL, rid)) + s.Nil(err) + defer resp.Body.Close() + body, err := ioutil.ReadAll(resp.Body) + s.Nil(err) + s.Equal(bytes.Compare(body, []byte(campaign.Page.HTML)), 0) +} + +func (s *ControllersSuite) clickLink404(rid string) { + resp, err := http.Get(fmt.Sprintf("%s/?rid=%s", ps.URL, rid)) + s.Nil(err) + defer resp.Body.Close() + s.Nil(err) + s.Equal(resp.StatusCode, http.StatusNotFound) +} + +func (s *ControllersSuite) TestOpenedPhishingEmail() { + campaign := s.getFirstCampaign() + result := campaign.Results[0] + s.Equal(result.Status, models.STATUS_SENDING) + + s.openEmail(result.RId) + + campaign = s.getFirstCampaign() + result = campaign.Results[0] + s.Equal(result.Status, models.EVENT_OPENED) +} + +func (s *ControllersSuite) TestClickedPhishingLinkAfterOpen() { + campaign := s.getFirstCampaign() + result := campaign.Results[0] + s.Equal(result.Status, models.STATUS_SENDING) + + s.openEmail(result.RId) + s.clickLink(result.RId, campaign) + + campaign = s.getFirstCampaign() + result = campaign.Results[0] + s.Equal(result.Status, models.EVENT_CLICKED) +} + +func (s *ControllersSuite) TestNoRecipientID() { + resp, err := http.Get(fmt.Sprintf("%s/track", ps.URL)) + s.Nil(err) + s.Equal(resp.StatusCode, http.StatusNotFound) + + resp, err = http.Get(ps.URL) + s.Nil(err) + s.Equal(resp.StatusCode, http.StatusNotFound) +} + +func (s *ControllersSuite) TestInvalidRecipientID() { + rid := "XXXXXXXXXX" + resp, err := http.Get(fmt.Sprintf("%s/track?rid=%s", ps.URL, rid)) + s.Nil(err) + s.Equal(resp.StatusCode, http.StatusNotFound) + + resp, err = http.Get(fmt.Sprintf("%s/?rid=%s", ps.URL, rid)) + s.Nil(err) + s.Equal(resp.StatusCode, http.StatusNotFound) +} + +func (s *ControllersSuite) TestCompletedCampaignClick() { + campaign := s.getFirstCampaign() + result := campaign.Results[0] + s.Equal(result.Status, models.STATUS_SENDING) + s.openEmail(result.RId) + + campaign = s.getFirstCampaign() + result = campaign.Results[0] + s.Equal(result.Status, models.EVENT_OPENED) + + models.CompleteCampaign(campaign.Id, 1) + s.openEmail404(result.RId) + s.clickLink404(result.RId) + + campaign = s.getFirstCampaign() + result = campaign.Results[0] + s.Equal(result.Status, models.EVENT_OPENED) +} + +func (s *ControllersSuite) TestRobotsHandler() { + expected := []byte("User-agent: *\nDisallow: /\n") + resp, err := http.Get(fmt.Sprintf("%s/robots.txt", ps.URL)) + s.Nil(err) + s.Equal(resp.StatusCode, http.StatusOK) + defer resp.Body.Close() + body, err := ioutil.ReadAll(resp.Body) + s.Nil(err) + s.Equal(bytes.Compare(body, expected), 0) +} diff --git a/controllers/route.go b/controllers/route.go index d4ee13d2..300c973f 100644 --- a/controllers/route.go +++ b/controllers/route.go @@ -1,17 +1,11 @@ package controllers import ( - "bytes" - "encoding/json" "fmt" "html/template" "log" - "net" "net/http" - "net/mail" - "net/url" "os" - "strings" "github.com/gophish/gophish/auth" "github.com/gophish/gophish/config" @@ -79,204 +73,6 @@ func CreateAdminRouter() http.Handler { return Use(csrfRouter.ServeHTTP, mid.CSRFExceptions, mid.GetContext) } -// CreatePhishingRouter creates the router that handles phishing connections. -func CreatePhishingRouter() http.Handler { - router := mux.NewRouter() - router.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir("./static/endpoint/")))) - router.HandleFunc("/track", PhishTracker) - router.HandleFunc("/robots.txt", RobotsHandler) - router.HandleFunc("/{path:.*}/track", PhishTracker) - router.HandleFunc("/{path:.*}", PhishHandler) - return router -} - -// PhishTracker tracks emails as they are opened, updating the status for the given Result -func PhishTracker(w http.ResponseWriter, r *http.Request) { - r.ParseForm() - id := r.Form.Get("rid") - if id == "" { - Logger.Println("Missing Result ID") - http.NotFound(w, r) - return - } - rs, err := models.GetResult(id) - if err != nil { - Logger.Println("No Results found") - http.NotFound(w, r) - return - } - c, err := models.GetCampaign(rs.CampaignId, rs.UserId) - if err != nil { - Logger.Println(err) - } - // Don't process events for completed campaigns - if c.Status == models.CAMPAIGN_COMPLETE { - http.NotFound(w, r) - return - } - ip, _, err := net.SplitHostPort(r.RemoteAddr) - if err != nil { - Logger.Println(err) - return - } - // Respect X-Forwarded headers - if fips := r.Header.Get("X-Forwarded-For"); fips != "" { - ip = strings.Split(fips, ", ")[0] - } - // Handle post processing such as GeoIP - err = rs.UpdateGeo(ip) - if err != nil { - Logger.Println(err) - } - d := struct { - Payload url.Values `json:"payload"` - Browser map[string]string `json:"browser"` - }{ - Payload: r.Form, - Browser: make(map[string]string), - } - d.Browser["address"] = ip - d.Browser["user-agent"] = r.Header.Get("User-Agent") - rj, err := json.Marshal(d) - if err != nil { - Logger.Println(err) - http.NotFound(w, r) - return - } - c.AddEvent(models.Event{Email: rs.Email, Message: models.EVENT_OPENED, Details: string(rj)}) - // Don't update the status if the user already clicked the link - // or submitted data to the campaign - if rs.Status == models.EVENT_CLICKED || rs.Status == models.EVENT_DATA_SUBMIT { - http.ServeFile(w, r, "static/images/pixel.png") - return - } - err = rs.UpdateStatus(models.EVENT_OPENED) - if err != nil { - Logger.Println(err) - } - http.ServeFile(w, r, "static/images/pixel.png") -} - -// PhishHandler handles incoming client connections and registers the associated actions performed -// (such as clicked link, etc.) -func PhishHandler(w http.ResponseWriter, r *http.Request) { - err := r.ParseForm() - if err != nil { - Logger.Println(err) - http.NotFound(w, r) - return - } - id := r.Form.Get("rid") - if id == "" { - http.NotFound(w, r) - return - } - rs, err := models.GetResult(id) - if err != nil { - http.NotFound(w, r) - return - } - c, err := models.GetCampaign(rs.CampaignId, rs.UserId) - if err != nil { - Logger.Println(err) - } - // Don't process events for completed campaigns - if c.Status == models.CAMPAIGN_COMPLETE { - http.NotFound(w, r) - return - } - p, err := models.GetPage(c.PageId, c.UserId) - if err != nil { - Logger.Println(err) - } - d := struct { - Payload url.Values `json:"payload"` - Browser map[string]string `json:"browser"` - }{ - Payload: r.Form, - Browser: make(map[string]string), - } - ip, _, err := net.SplitHostPort(r.RemoteAddr) - if err != nil { - Logger.Println(err) - return - } - // Respect X-Forwarded headers - if fips := r.Header.Get("X-Forwarded-For"); fips != "" { - ip = strings.Split(fips, ", ")[0] - } - // Handle post processing such as GeoIP - err = rs.UpdateGeo(ip) - if err != nil { - Logger.Println(err) - } - d.Browser["address"] = ip - d.Browser["user-agent"] = r.Header.Get("User-Agent") - rj, err := json.Marshal(d) - if err != nil { - Logger.Println(err) - http.NotFound(w, r) - return - } - switch { - case r.Method == "GET": - if rs.Status != models.EVENT_CLICKED && rs.Status != models.EVENT_DATA_SUBMIT { - rs.UpdateStatus(models.EVENT_CLICKED) - } - err = c.AddEvent(models.Event{Email: rs.Email, Message: models.EVENT_CLICKED, Details: string(rj)}) - if err != nil { - Logger.Println(err) - } - case r.Method == "POST": - // If data was POST'ed, let's record it - rs.UpdateStatus(models.EVENT_DATA_SUBMIT) - // Store the data in an event - c.AddEvent(models.Event{Email: rs.Email, Message: models.EVENT_DATA_SUBMIT, Details: string(rj)}) - if err != nil { - Logger.Println(err) - } - // Redirect to the desired page - if p.RedirectURL != "" { - http.Redirect(w, r, p.RedirectURL, 302) - return - } - } - var htmlBuff bytes.Buffer - tmpl, err := template.New("html_template").Parse(p.HTML) - if err != nil { - Logger.Println(err) - http.NotFound(w, r) - } - f, err := mail.ParseAddress(c.SMTP.FromAddress) - if err != nil { - Logger.Println(err) - } - fn := f.Name - if fn == "" { - fn = f.Address - } - rsf := struct { - models.Result - URL string - From string - }{ - rs, - c.URL + "?rid=" + rs.RId, - fn, - } - err = tmpl.Execute(&htmlBuff, rsf) - if err != nil { - Logger.Println(err) - http.NotFound(w, r) - } - w.Write(htmlBuff.Bytes()) -} - -// RobotsHandler prevents search engines, etc. from indexing phishing materials -func RobotsHandler(w http.ResponseWriter, r *http.Request) { - fmt.Fprintln(w, "User-agent: *\nDisallow: /") -} - // Use allows us to stack middleware to process the request // Example taken from https://github.com/gorilla/mux/pull/36#issuecomment-25849172 func Use(handler http.HandlerFunc, mid ...func(http.Handler) http.HandlerFunc) http.HandlerFunc {