diff --git a/.gitignore b/.gitignore index 5635b923..409440e3 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ node_modules # Architecture specific extensions/prefixes *.[568vq] [568vq].out +.DS_Store *.cgo1.go *.cgo2.c @@ -27,4 +28,4 @@ gophish_admin.key *.exe gophish.db* -gophish \ No newline at end of file +gophish diff --git a/controllers/.DS_Store b/controllers/.DS_Store new file mode 100644 index 00000000..9d4f2c95 Binary files /dev/null and b/controllers/.DS_Store differ diff --git a/controllers/api/server.go b/controllers/api/server.go index 9f2bec65..40fd03a3 100644 --- a/controllers/api/server.go +++ b/controllers/api/server.go @@ -71,6 +71,9 @@ func (as *Server) registerRoutes() { router.HandleFunc("/import/group", as.ImportGroup) router.HandleFunc("/import/email", as.ImportEmail) router.HandleFunc("/import/site", as.ImportSite) + router.HandleFunc("/webhooks/", mid.Use(as.Webhooks, mid.RequirePermission(models.PermissionModifySystem))) + router.HandleFunc("/webhooks/{id:[0-9]+}/validate", mid.Use(as.ValidateWebhook, mid.RequirePermission(models.PermissionModifySystem))) + router.HandleFunc("/webhooks/{id:[0-9]+}", mid.Use(as.Webhook, mid.RequirePermission(models.PermissionModifySystem))) as.handler = router } diff --git a/controllers/api/webhook.go b/controllers/api/webhook.go new file mode 100644 index 00000000..e835777e --- /dev/null +++ b/controllers/api/webhook.go @@ -0,0 +1,96 @@ +package api + +import ( + "encoding/json" + "net/http" + "strconv" + + log "github.com/gophish/gophish/logger" + "github.com/gophish/gophish/models" + "github.com/gophish/gophish/webhook" + "github.com/gorilla/mux" +) + +// Webhooks returns a list of webhooks, both active and disabled +func (as *Server) Webhooks(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET": + whs, err := models.GetWebhooks() + if err != nil { + log.Error(err) + JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError) + return + } + JSONResponse(w, whs, http.StatusOK) + + case r.Method == "POST": + wh := models.Webhook{} + err := json.NewDecoder(r.Body).Decode(&wh) + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: "Invalid JSON structure"}, http.StatusBadRequest) + return + } + err = models.PostWebhook(&wh) + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest) + return + } + JSONResponse(w, wh, http.StatusCreated) + } +} + +// Webhook returns details of a single webhook specified by "id" parameter +func (as *Server) Webhook(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id, _ := strconv.ParseInt(vars["id"], 0, 64) + wh, err := models.GetWebhook(id) + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: "Webhook not found"}, http.StatusNotFound) + return + } + switch { + case r.Method == "GET": + JSONResponse(w, wh, http.StatusOK) + + case r.Method == "DELETE": + err = models.DeleteWebhook(id) + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError) + return + } + log.Infof("Deleted webhook with id: %d", id) + JSONResponse(w, models.Response{Success: true, Message: "Webhook deleted Successfully!"}, http.StatusOK) + + case r.Method == "PUT": + wh2 := models.Webhook{} + err = json.NewDecoder(r.Body).Decode(&wh2) + wh2.Id = id + err = models.PutWebhook(&wh2) + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest) + return + } + JSONResponse(w, wh2, http.StatusOK) + } +} + +// ValidateWebhook makes an HTTP request to a specified remote url to ensure that it's valid. +func (as *Server) ValidateWebhook(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "POST": + vars := mux.Vars(r) + id, _ := strconv.ParseInt(vars["id"], 0, 64) + wh, err := models.GetWebhook(id) + if err != nil { + log.Error(err) + JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError) + return + } + err = webhook.Send(webhook.EndPoint{URL: wh.URL, Secret: wh.Secret}, "") + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest) + return + } + JSONResponse(w, wh, http.StatusOK) + } +} diff --git a/controllers/route.go b/controllers/route.go index 72cf791b..576e7dd3 100644 --- a/controllers/route.go +++ b/controllers/route.go @@ -110,6 +110,7 @@ func (as *AdminServer) registerRoutes() { router.HandleFunc("/sending_profiles", mid.Use(as.SendingProfiles, mid.RequireLogin)) router.HandleFunc("/settings", mid.Use(as.Settings, mid.RequireLogin)) router.HandleFunc("/users", mid.Use(as.UserManagement, mid.RequirePermission(models.PermissionModifySystem), mid.RequireLogin)) + router.HandleFunc("/webhooks", mid.Use(as.Webhooks, mid.RequirePermission(models.PermissionModifySystem), mid.RequireLogin)) // Create the API routes api := api.NewServer(api.WithWorker(as.worker)) router.PathPrefix("/api/").Handler(api) @@ -238,6 +239,13 @@ func (as *AdminServer) UserManagement(w http.ResponseWriter, r *http.Request) { getTemplate(w, "users").ExecuteTemplate(w, "base", params) } +// Webhooks is an admin-only handler that handles webhooks +func (as *AdminServer) Webhooks(w http.ResponseWriter, r *http.Request) { + params := newTemplateParams(r) + params.Title = "Webhooks" + getTemplate(w, "webhooks").ExecuteTemplate(w, "base", params) +} + // Login handles the authentication flow for a user. If credentials are valid, // a session is created func (as *AdminServer) Login(w http.ResponseWriter, r *http.Request) { diff --git a/db/db_mysql/migrations/20191104103306_0.9.0_create_webhooks.sql b/db/db_mysql/migrations/20191104103306_0.9.0_create_webhooks.sql new file mode 100644 index 00000000..d13f39e8 --- /dev/null +++ b/db/db_mysql/migrations/20191104103306_0.9.0_create_webhooks.sql @@ -0,0 +1,15 @@ + +-- +goose Up +-- SQL in section 'Up' is executed when this migration is applied +CREATE TABLE IF NOT EXISTS "webhooks" ( + "id" integer primary key autoincrement, + "name" varchar(255), + "url" varchar(1000), + "secret" varchar(255), + "is_active" boolean default 0 +); + + +-- +goose Down +-- SQL section 'Down' is executed when this migration is rolled back + diff --git a/db/db_sqlite3/migrations/20191104103306_0.9.0_create_webhooks.sql b/db/db_sqlite3/migrations/20191104103306_0.9.0_create_webhooks.sql new file mode 100644 index 00000000..d13f39e8 --- /dev/null +++ b/db/db_sqlite3/migrations/20191104103306_0.9.0_create_webhooks.sql @@ -0,0 +1,15 @@ + +-- +goose Up +-- SQL in section 'Up' is executed when this migration is applied +CREATE TABLE IF NOT EXISTS "webhooks" ( + "id" integer primary key autoincrement, + "name" varchar(255), + "url" varchar(1000), + "secret" varchar(255), + "is_active" boolean default 0 +); + + +-- +goose Down +-- SQL section 'Down' is executed when this migration is rolled back + diff --git a/models/campaign.go b/models/campaign.go index c97f6a99..3ce313fb 100644 --- a/models/campaign.go +++ b/models/campaign.go @@ -6,6 +6,7 @@ import ( "time" log "github.com/gophish/gophish/logger" + "github.com/gophish/gophish/webhook" "github.com/jinzhu/gorm" "github.com/sirupsen/logrus" ) @@ -157,6 +158,21 @@ func (c *Campaign) UpdateStatus(s string) error { func (c *Campaign) AddEvent(e *Event) error { e.CampaignId = c.Id e.Time = time.Now().UTC() + + whs, err := GetActiveWebhooks() + if err == nil { + whEndPoints := []webhook.EndPoint{} + for _, wh := range whs { + whEndPoints = append(whEndPoints, webhook.EndPoint{ + URL: wh.URL, + Secret: wh.Secret, + }) + } + webhook.SendAll(whEndPoints, e) + } else { + log.Errorf("error getting active webhooks: %v", err) + } + return db.Save(e).Error } diff --git a/models/models.go b/models/models.go index e929af02..3384aad5 100644 --- a/models/models.go +++ b/models/models.go @@ -2,12 +2,12 @@ package models import ( "crypto/rand" - "fmt" - "io" - "time" "crypto/tls" "crypto/x509" + "fmt" + "io" "io/ioutil" + "time" "bitbucket.org/liamstask/goose/lib/goose" diff --git a/models/webhook.go b/models/webhook.go new file mode 100644 index 00000000..11ba1c7e --- /dev/null +++ b/models/webhook.go @@ -0,0 +1,86 @@ +package models + +import ( + "errors" + + log "github.com/gophish/gophish/logger" +) + +// Webhook represents the webhook model +type Webhook struct { + Id int64 `json:"id" gorm:"column:id; primary_key:yes"` + Name string `json:"name"` + URL string `json:"url"` + Secret string `json:"secret"` + IsActive bool `json:"is_active"` +} + +// ErrURLNotSpecified indicates there was no URL specified +var ErrURLNotSpecified = errors.New("URL can't be empty") + +// ErrNameNotSpecified indicates there was no name specified +var ErrNameNotSpecified = errors.New("Name can't be empty") + +// GetWebhooks returns the webhooks +func GetWebhooks() ([]Webhook, error) { + whs := []Webhook{} + err := db.Find(&whs).Error + return whs, err +} + +// GetActiveWebhooks returns the active webhooks +func GetActiveWebhooks() ([]Webhook, error) { + whs := []Webhook{} + err := db.Where("is_active=?", true).Find(&whs).Error + return whs, err +} + +// GetWebhook returns the webhook that the given id corresponds to. +// If no webhook is found, an error is returned. +func GetWebhook(id int64) (Webhook, error) { + wh := Webhook{} + err := db.Where("id=?", id).First(&wh).Error + return wh, err +} + +// PostWebhook creates a new webhook in the database. +func PostWebhook(wh *Webhook) error { + err := wh.Validate() + if err != nil { + log.Error(err) + return err + } + err = db.Save(wh).Error + if err != nil { + log.Error(err) + } + return err +} + +// PutWebhook edits an existing webhook in the database. +func PutWebhook(wh *Webhook) error { + err := wh.Validate() + if err != nil { + log.Error(err) + return err + } + err = db.Save(wh).Error + return err +} + +// DeleteWebhook deletes an existing webhook in the database. +// An error is returned if a webhook with the given id isn't found. +func DeleteWebhook(id int64) error { + err := db.Where("id=?", id).Delete(&Webhook{}).Error + return err +} + +func (wh *Webhook) Validate() error { + if wh.URL == "" { + return ErrURLNotSpecified + } + if wh.Name == "" { + return ErrNameNotSpecified + } + return nil +} diff --git a/static/js/src/app/gophish.js b/static/js/src/app/gophish.js index dea47c89..8f22f5a3 100644 --- a/static/js/src/app/gophish.js +++ b/static/js/src/app/gophish.js @@ -223,6 +223,28 @@ var api = { return query("/users/" + id, "DELETE", {}, true) } }, + webhooks: { + get: function() { + return query("/webhooks/", "GET", {}, false) + }, + post: function(webhook) { + return query("/webhooks/", "POST", webhook, false) + }, + }, + webhookId: { + get: function(id) { + return query("/webhooks/" + id, "GET", {}, false) + }, + put: function(webhook) { + return query("/webhooks/" + webhook.id, "PUT", webhook, true) + }, + delete: function(id) { + return query("/webhooks/" + id, "DELETE", {}, false) + }, + ping: function(id) { + return query("/webhooks/" + id + "/validate", "POST", {}, true) + }, + }, // import handles all of the "import" functions in the api import_email: function (req) { return query("/import/email", "POST", req, false) diff --git a/static/js/src/app/webhooks.js b/static/js/src/app/webhooks.js new file mode 100644 index 00000000..ed7a5da4 --- /dev/null +++ b/static/js/src/app/webhooks.js @@ -0,0 +1,182 @@ +let webhooks = []; + +const dismiss = () => { + $("#name").val(""); + $("#url").val(""); + $("#secret").val(""); + $("#is_active").prop("checked", false); + $("#flashes").empty(); +}; + +const saveWebhook = (id) => { + let wh = { + name: $("#name").val(), + url: $("#url").val(), + secret: $("#secret").val(), + is_active: $("#is_active").is(":checked"), + }; + if (id != -1) { + wh.id = id; + api.webhookId.put(wh) + .success(function(data) { + dismiss(); + load(); + $("#modal").modal("hide"); + successFlash(`Webhook "${escape(wh.name)}" has been updated successfully!`); + }) + .error(function(data) { + modalError(data.responseJSON.message) + }) + } else { + api.webhooks.post(wh) + .success(function(data) { + load(); + dismiss(); + $("#modal").modal("hide"); + successFlash(`Webhook "${escape(wh.name)}" has been created successfully!`); + }) + .error(function(data) { + modalError(data.responseJSON.message) + }) + } +}; + +const load = () => { + $("#webhookTable").hide(); + $("#loading").show(); + api.webhooks.get() + .success((whs) => { + webhooks = whs; + $("#loading").hide() + $("#webhookTable").show() + let webhookTable = $("#webhookTable").DataTable({ + destroy: true, + columnDefs: [{ + orderable: false, + targets: "no-sort" + }] + }); + webhookTable.clear(); + $.each(webhooks, (i, webhook) => { + webhookTable.row.add([ + escapeHtml(webhook.name), + escapeHtml(webhook.url), + escapeHtml(webhook.is_active), + ` +