mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-02-12 16:12:44 +00:00
2363 lines
68 KiB
Go
2363 lines
68 KiB
Go
package app
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"mime"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime/debug"
|
|
"strings"
|
|
textTmpl "text/template"
|
|
"time"
|
|
|
|
"github.com/go-errors/errors"
|
|
"gopkg.in/yaml.v3"
|
|
|
|
"github.com/caddyserver/certmagic"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"github.com/phishingclub/phishingclub/cache"
|
|
"github.com/phishingclub/phishingclub/config"
|
|
"github.com/phishingclub/phishingclub/data"
|
|
"github.com/phishingclub/phishingclub/database"
|
|
"github.com/phishingclub/phishingclub/errs"
|
|
"github.com/phishingclub/phishingclub/geoip"
|
|
"github.com/phishingclub/phishingclub/middleware"
|
|
"github.com/phishingclub/phishingclub/model"
|
|
"github.com/phishingclub/phishingclub/proxy"
|
|
"github.com/phishingclub/phishingclub/repository"
|
|
"github.com/phishingclub/phishingclub/server"
|
|
"github.com/phishingclub/phishingclub/service"
|
|
"github.com/phishingclub/phishingclub/utils"
|
|
"github.com/phishingclub/phishingclub/vo"
|
|
"go.uber.org/zap"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
const MAX_USER_AGENT_SAVED = 255
|
|
|
|
// Server is the public phishing server
|
|
type Server struct {
|
|
HTTPServer *http.Server
|
|
HTTPSServer *http.Server
|
|
db *gorm.DB
|
|
logger *zap.SugaredLogger
|
|
certMagicConfig *certmagic.Config
|
|
staticPath string
|
|
ownManagedTLSCertPath string
|
|
controllers *Controllers
|
|
services *Services
|
|
repositories *Repositories
|
|
proxyServer *proxy.ProxyHandler
|
|
ja4Middleware *middleware.JA4Middleware
|
|
}
|
|
|
|
// NewServer returns a new server
|
|
func NewServer(
|
|
staticPath string,
|
|
ownManagedTLSCertPath string,
|
|
db *gorm.DB,
|
|
controllers *Controllers,
|
|
services *Services,
|
|
repositories *Repositories,
|
|
logger *zap.SugaredLogger,
|
|
certMagicConfig *certmagic.Config,
|
|
) *Server {
|
|
// setup ja4 middleware for tls fingerprinting
|
|
ja4Middleware := middleware.NewJA4Middleware(logger)
|
|
// setup goproxy-based proxy server
|
|
proxyServer := proxy.NewProxyHandler(
|
|
logger,
|
|
services.ProxySessionManager,
|
|
repositories.Page,
|
|
repositories.CampaignRecipient,
|
|
repositories.Campaign,
|
|
repositories.CampaignTemplate,
|
|
repositories.Domain,
|
|
repositories.Proxy,
|
|
repositories.Identifier,
|
|
services.Campaign,
|
|
services.Template,
|
|
services.IPAllowList,
|
|
repositories.Option,
|
|
services.Option,
|
|
)
|
|
|
|
// setup proxy session cleanup routine
|
|
go func() {
|
|
ticker := time.NewTicker(5 * time.Minute)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
proxyServer.CleanupExpiredSessions()
|
|
}
|
|
}
|
|
}()
|
|
|
|
return &Server{
|
|
staticPath: staticPath,
|
|
ownManagedTLSCertPath: ownManagedTLSCertPath,
|
|
db: db,
|
|
controllers: controllers,
|
|
services: services,
|
|
repositories: repositories,
|
|
logger: logger,
|
|
certMagicConfig: certMagicConfig,
|
|
proxyServer: proxyServer,
|
|
ja4Middleware: ja4Middleware,
|
|
}
|
|
}
|
|
|
|
// defaultServer creates a new default HTTP server
|
|
// skipFirstTLS sets a writer that ignores the first TLS handshake error and then
|
|
// replaces the logger with the normal logger, this is a hack to fix a annoying output
|
|
// created from the port ready probing done while booting the application
|
|
func (s *Server) defaultServer(handler http.Handler, skipFirstTLS bool) *http.Server {
|
|
server := &http.Server{
|
|
Handler: handler,
|
|
// The maximum duration for reading the entire request, including the request line, headers, and body
|
|
ReadTimeout: 15 * time.Second,
|
|
// The maximum duration for writing the entire response, including the response headers and body
|
|
WriteTimeout: 15 * time.Second, // Timeout for writing the response
|
|
// The maximum duration to wait for the next request when the connection is in the idle state
|
|
IdleTimeout: 10 * time.Second,
|
|
// The maximum duration for reading the request headers.
|
|
ReadHeaderTimeout: 2 * time.Second,
|
|
// Maximum size of request headers (512 KB)
|
|
MaxHeaderBytes: 1 << 19,
|
|
ErrorLog: log.New(&fwdToZapWriter{logger: s.logger}, "", 0),
|
|
}
|
|
if skipFirstTLS {
|
|
server.ErrorLog = log.New(
|
|
&SkipFirstTlsToZapWriter{
|
|
logger: s.logger,
|
|
serverPtr: server,
|
|
}, "", 0,
|
|
)
|
|
}
|
|
return server
|
|
}
|
|
|
|
// host extract the host part of the request
|
|
func (s *Server) getHostOnly(host string) (string, error) {
|
|
if strings.Contains(host, ":") {
|
|
hostOnly, _, err := net.SplitHostPort(host)
|
|
if err != nil {
|
|
return "", errs.Wrap(err)
|
|
}
|
|
return hostOnly, nil
|
|
}
|
|
return host, nil
|
|
}
|
|
|
|
// testConnection tests the connection to the server
|
|
// it starts a gorutine that attempts to connect via. tcp 3 times and
|
|
// it returns a channel that will be called with the result
|
|
func (s *Server) testTCPConnection(identifier string, addr string) chan server.StartupMessage {
|
|
c := server.NewStartupMessageChannel()
|
|
go func() {
|
|
s.logger.Debugw("testing connection",
|
|
"server", identifier,
|
|
)
|
|
attempts := 1
|
|
for {
|
|
dialer := &net.Dialer{
|
|
Timeout: time.Second,
|
|
KeepAlive: time.Second,
|
|
}
|
|
conn, err := dialer.Dial("tcp", addr)
|
|
if err != nil {
|
|
s.logger.Debugw(
|
|
"failed to connect to server",
|
|
"server", identifier,
|
|
"attempt", attempts,
|
|
"error", err,
|
|
)
|
|
time.Sleep(1 * time.Second)
|
|
if attempts == 3 {
|
|
c <- server.NewStartupMessage(
|
|
false,
|
|
fmt.Errorf("failed to connect to %s server", identifier),
|
|
)
|
|
break
|
|
}
|
|
attempts += 1
|
|
continue
|
|
}
|
|
// #nosec
|
|
conn.Close()
|
|
c <- server.NewStartupMessage(true, nil)
|
|
break
|
|
}
|
|
|
|
}()
|
|
return c
|
|
}
|
|
|
|
// checkAndServeAssets checks if the request is for static content
|
|
// and serves it if it is
|
|
// return true if the request was for static content
|
|
func (s *Server) checkAndServeAssets(c *gin.Context, host string) bool {
|
|
// create root filesystem for asset validation
|
|
root, err := os.OpenRoot(s.staticPath)
|
|
if err != nil {
|
|
s.logger.Infow("failed to open static path root",
|
|
"error", err,
|
|
)
|
|
return false
|
|
}
|
|
defer root.Close()
|
|
|
|
// validate host folder path is safe
|
|
_, err = root.Stat(host)
|
|
if err != nil && !os.IsNotExist(err) {
|
|
s.logger.Infow("insecure host path attempted",
|
|
"host", host,
|
|
"error", err,
|
|
)
|
|
return false
|
|
}
|
|
|
|
// clean path and remove leading slash
|
|
cleanPath := strings.TrimPrefix(filepath.Clean(c.Request.URL.Path), "/")
|
|
|
|
// validate full path is safe by checking it against root
|
|
fullRelativePath := filepath.Join(host, cleanPath)
|
|
_, err = root.Stat(fullRelativePath)
|
|
if err != nil && !os.IsNotExist(err) {
|
|
s.logger.Infow("insecure path attempted on asset",
|
|
"path", fullRelativePath,
|
|
"error", err,
|
|
)
|
|
return false
|
|
}
|
|
|
|
// check if file exists and get info through root
|
|
fullRelativePathForFile := filepath.Join(host, cleanPath)
|
|
info, err := root.Stat(fullRelativePathForFile)
|
|
if err != nil {
|
|
s.logger.Debugw("not found on domain: %s",
|
|
"path", fullRelativePathForFile,
|
|
)
|
|
// check if this is a global asset
|
|
return s.checkAndServeSharedAsset(c)
|
|
}
|
|
if info.IsDir() {
|
|
return false
|
|
}
|
|
|
|
// open and serve file through root to maintain security boundaries
|
|
file, err := root.Open(fullRelativePathForFile)
|
|
if err != nil {
|
|
s.logger.Infow("failed to open file through root",
|
|
"path", fullRelativePathForFile,
|
|
"error", err,
|
|
)
|
|
return false
|
|
}
|
|
defer file.Close()
|
|
|
|
c.Header("Content-Type", mime.TypeByExtension(filepath.Ext(cleanPath)))
|
|
c.DataFromReader(http.StatusOK, info.Size(), mime.TypeByExtension(filepath.Ext(cleanPath)), file, nil)
|
|
return true
|
|
}
|
|
|
|
func (s *Server) checkAndServeSharedAsset(c *gin.Context) bool {
|
|
// create root filesystem for secure shared asset validation
|
|
root, err := os.OpenRoot(s.staticPath)
|
|
if err != nil {
|
|
s.logger.Infow("failed to open static path root",
|
|
"error", err,
|
|
)
|
|
return false
|
|
}
|
|
defer root.Close()
|
|
|
|
// validate shared folder path is safe
|
|
_, err = root.Stat("shared")
|
|
if err != nil && !os.IsNotExist(err) {
|
|
s.logger.Infow("insecure shared path",
|
|
"error", err,
|
|
)
|
|
return false
|
|
}
|
|
|
|
// clean path and remove leading slash
|
|
cleanPath := strings.TrimPrefix(filepath.Clean(c.Request.URL.Path), "/")
|
|
|
|
// validate full path is safe by checking it against root
|
|
fullRelativePath := filepath.Join("shared", cleanPath)
|
|
_, err = root.Stat(fullRelativePath)
|
|
if err != nil && !os.IsNotExist(err) {
|
|
s.logger.Infow("insecure shared asset path",
|
|
"path", fullRelativePath,
|
|
"error", err,
|
|
)
|
|
return false
|
|
}
|
|
|
|
// check if file exists and get info through root
|
|
sharedRelativePath := filepath.Join("shared", cleanPath)
|
|
info, err := root.Stat(sharedRelativePath)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
if info.IsDir() {
|
|
return false
|
|
}
|
|
|
|
// open and serve file through root to maintain security boundaries
|
|
file, err := root.Open(sharedRelativePath)
|
|
if err != nil {
|
|
s.logger.Infow("failed to open shared file through root",
|
|
"path", sharedRelativePath,
|
|
"error", err,
|
|
)
|
|
return false
|
|
}
|
|
defer file.Close()
|
|
|
|
c.Header("Content-Type", mime.TypeByExtension(filepath.Ext(cleanPath)))
|
|
c.DataFromReader(http.StatusOK, info.Size(), mime.TypeByExtension(filepath.Ext(cleanPath)), file, nil)
|
|
return true
|
|
}
|
|
|
|
// Handler is middleware that takes care of everything related to incoming phishing requests
|
|
// checks if the domain is valid and usable
|
|
// checks if the request is for a phishing page
|
|
// checks if the request is for a assets
|
|
// checks if the request should be redirected
|
|
// checks if the request is for a static page or static not found page
|
|
func (s *Server) Handler(c *gin.Context) {
|
|
if cacheEntry, ok := s.ja4Middleware.ConnectionFingerprints.Load(c.Request.RemoteAddr); ok {
|
|
if entry, ok := cacheEntry.(*middleware.FingerprintEntry); ok {
|
|
fingerprint := entry.Fingerprint
|
|
entry.LastAccess = time.Now()
|
|
c.Request.Header.Set(middleware.HeaderJA4, fingerprint)
|
|
c.Set(middleware.ContextKeyJA4, fingerprint)
|
|
s.logger.Debugw("ja4 fingerprint injected",
|
|
"fingerprint", fingerprint,
|
|
"remoteAddr", c.Request.RemoteAddr,
|
|
"path", c.Request.URL.Path,
|
|
"host", c.Request.Host,
|
|
)
|
|
}
|
|
}
|
|
|
|
// add error recovery for handler
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
s.logger.Errorw("panic in handler",
|
|
"panic", r,
|
|
"host", c.Request.Host,
|
|
"url", c.Request.URL.String(),
|
|
"method", c.Request.Method,
|
|
"userAgent", c.Request.UserAgent(),
|
|
"remoteAddr", c.Request.RemoteAddr,
|
|
"stack", string(debug.Stack()),
|
|
)
|
|
c.Status(http.StatusInternalServerError)
|
|
c.Abort()
|
|
}
|
|
}()
|
|
|
|
host, err := s.getHostOnly(c.Request.Host)
|
|
if err != nil {
|
|
s.logger.Debugw("failed to parse host",
|
|
"rawHost", c.Request.Host,
|
|
"error", err,
|
|
)
|
|
c.Status(http.StatusNotFound)
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
// check if the domain is valid
|
|
// use DB directly here to avoid getting unnecessary data
|
|
// as a domain contains big blobs for static content
|
|
var domain *database.Domain
|
|
res := s.db.
|
|
Select("id, name, type, proxy_id, proxy_target_domain, host_website, redirect_url").
|
|
Where("name = ?", host).
|
|
First(&domain)
|
|
|
|
if res.RowsAffected == 0 {
|
|
s.logger.Debug("domain not found")
|
|
c.Status(http.StatusNotFound)
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
// check if this is a proxy domain - if so, handle it with proxy server
|
|
if domain.Type == "proxy" {
|
|
s.logger.Debugw("handling proxy domain request",
|
|
"host", host,
|
|
"targetDomain", domain.ProxyTargetDomain,
|
|
"path", c.Request.URL.Path,
|
|
)
|
|
err = s.proxyServer.HandleHTTPRequest(c.Writer, c.Request, domain)
|
|
if err != nil {
|
|
s.logger.Errorw("failed to handle proxy request",
|
|
"error", err,
|
|
"host", host,
|
|
)
|
|
c.Status(http.StatusInternalServerError)
|
|
}
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
// check if the request is for a tacking pixel
|
|
if c.Request.URL.Path == "/wf/open" {
|
|
s.controllers.Campaign.TrackingPixel(c)
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
// check if the request is for a phishing page or is denied by allow/deny list
|
|
// this must come BEFORE proxy cookie check to ensure initial requests with campaign recipient IDs
|
|
// are treated as initial requests even if they have existing proxy cookies
|
|
isRequestForPhishingPageOrDenied, err := s.checkAndServePhishingPage(c, domain)
|
|
if err != nil {
|
|
s.logger.Errorw("failed to serve phishing page",
|
|
"error", err,
|
|
)
|
|
c.Status(http.StatusInternalServerError)
|
|
c.Abort()
|
|
return
|
|
}
|
|
// if this was a request for the phishing page and there was no error
|
|
if isRequestForPhishingPageOrDenied {
|
|
return
|
|
}
|
|
|
|
// check for proxy cookie - only if this wasn't a phishing page request
|
|
// this ensures that requests with campaign recipient IDs are handled as initial requests
|
|
if s.proxyServer.IsValidProxyCookie(s.getProxyCookieValue(c)) {
|
|
err = s.proxyServer.HandleHTTPRequest(c.Writer, c.Request, domain)
|
|
if err != nil {
|
|
s.logger.Errorw("failed to handle proxy request",
|
|
"error", err,
|
|
)
|
|
c.Status(http.StatusInternalServerError)
|
|
}
|
|
c.Abort()
|
|
return
|
|
}
|
|
// check if the request is for assets
|
|
servedAssets := s.checkAndServeAssets(c, host)
|
|
if servedAssets {
|
|
s.logger.Debug("served static asset")
|
|
c.Abort()
|
|
return
|
|
}
|
|
// check if the request should be redirected
|
|
if domain.RedirectURL != "" {
|
|
c.Redirect(http.StatusMovedPermanently, domain.RedirectURL)
|
|
c.Abort()
|
|
return
|
|
}
|
|
// check if the domain should serve static content
|
|
if !domain.HostWebsite {
|
|
s.logger.Debugw("404 - Domain does not serve static content",
|
|
"host", host,
|
|
)
|
|
c.Status(http.StatusNotFound)
|
|
c.Abort()
|
|
return
|
|
}
|
|
// check if the path is a OK page or not found
|
|
if c.Request.URL.Path != "/" {
|
|
res := s.db.
|
|
Select("page_not_found_content").
|
|
Where("name = ?", host).
|
|
First(&domain)
|
|
|
|
if res.RowsAffected == 0 {
|
|
s.logger.Errorw("domain page unexpectedly not found",
|
|
"host", host,
|
|
)
|
|
c.Status(http.StatusInternalServerError)
|
|
c.Abort()
|
|
return
|
|
}
|
|
// TODO extract this into another method, maybe file
|
|
t, err := textTmpl.
|
|
New("staticContent").
|
|
Funcs(service.TemplateFuncs()).
|
|
Parse(string(domain.PageNotFoundContent))
|
|
|
|
if err != nil {
|
|
s.logger.Errorw("failed to parse static content template",
|
|
"error", err,
|
|
)
|
|
c.Status(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
var buf bytes.Buffer
|
|
err = t.Execute(&buf, map[string]any{
|
|
"Domain": host,
|
|
"BaseURL": "https://" + host + "/",
|
|
"URL": c.Request.URL.String(),
|
|
})
|
|
if err != nil {
|
|
s.logger.Errorw("failed to execute static content template",
|
|
"error", err,
|
|
)
|
|
c.Status(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
c.Data(
|
|
http.StatusNotFound,
|
|
"text/html; charset=utf-8",
|
|
[]byte(buf.Bytes()),
|
|
)
|
|
c.Abort()
|
|
return
|
|
}
|
|
// serve the static page
|
|
res = s.db.
|
|
Select("page_content").
|
|
Where("name = ?", host).
|
|
First(&domain)
|
|
|
|
if res.RowsAffected == 0 {
|
|
s.logger.Errorw("static page was unexpectedly not found",
|
|
"host", host,
|
|
)
|
|
c.Status(http.StatusInternalServerError)
|
|
c.Abort()
|
|
return
|
|
}
|
|
t, err := textTmpl.
|
|
New("staticContent").
|
|
Funcs(service.TemplateFuncs()).
|
|
Parse(domain.PageContent)
|
|
|
|
if err != nil {
|
|
s.logger.Errorw("failed to parse static content template",
|
|
"error", errs.Wrap(err),
|
|
)
|
|
c.Status(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
buf := &bytes.Buffer{}
|
|
err = t.Execute(buf, map[string]any{
|
|
"Domain": host,
|
|
"BaseURL": "https://" + host + "/",
|
|
"URL": "https://" + host + c.Request.URL.String(),
|
|
})
|
|
if err != nil {
|
|
s.logger.Errorw("failed to execute static content template",
|
|
"error", errs.Wrap(err),
|
|
)
|
|
c.Status(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
c.Data(
|
|
http.StatusOK,
|
|
"text/html; charset=utf-8",
|
|
buf.Bytes(),
|
|
)
|
|
c.Abort()
|
|
}
|
|
|
|
// handlerNotFound handles the request for a not found page
|
|
func (s *Server) handlerNotFound(c *gin.Context) {
|
|
host, err := s.getHostOnly(c.Request.Host)
|
|
if err != nil {
|
|
s.logger.Debugw("failed to parse host",
|
|
"host", c.Request.Host,
|
|
"error", err,
|
|
)
|
|
c.Status(http.StatusNotFound)
|
|
return
|
|
}
|
|
var domain *database.Domain
|
|
res := s.db.
|
|
Select("page_not_found_content").
|
|
Where("name = ?", host).
|
|
Find(&domain)
|
|
|
|
if res.RowsAffected == 0 {
|
|
s.logger.Debugw("host not found",
|
|
"host", host,
|
|
)
|
|
c.Status(http.StatusNotFound)
|
|
return
|
|
}
|
|
t := textTmpl.New("staticContent")
|
|
t = t.Funcs(service.TemplateFuncs())
|
|
tmpl, err := t.Parse(string(domain.PageNotFoundContent))
|
|
if err != nil {
|
|
s.logger.Errorw("failed to parse static content template",
|
|
"error", errs.Wrap(err),
|
|
)
|
|
c.Status(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
var buf bytes.Buffer
|
|
err = tmpl.Execute(&buf, map[string]any{
|
|
"Domain": host,
|
|
"BaseURL": "https://" + host + "/",
|
|
"URL": c.Request.URL.String(),
|
|
})
|
|
if err != nil {
|
|
s.logger.Errorw("failed to execute static content template",
|
|
"error", err,
|
|
)
|
|
c.Status(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
c.Data(
|
|
http.StatusNotFound,
|
|
"text/html; charset=utf-8",
|
|
[]byte(buf.Bytes()),
|
|
)
|
|
}
|
|
|
|
// checkAndServePhishingPage serves a phishing page
|
|
// returns a bool if the request was for a phishing page
|
|
// and an error if there was an error
|
|
func (s *Server) checkAndServePhishingPage(
|
|
c *gin.Context,
|
|
domain *database.Domain,
|
|
) (bool, error) {
|
|
// get campaign recipient from URL parameters
|
|
campaignRecipient, _, err := server.GetCampaignRecipientFromURLParams(
|
|
c,
|
|
c.Request,
|
|
s.repositories.Identifier,
|
|
s.repositories.CampaignRecipient,
|
|
)
|
|
if err != nil {
|
|
s.logger.Debugw("failed to get campaign recipient from URL parameters",
|
|
"error", err,
|
|
)
|
|
return false, errs.Wrap(err)
|
|
}
|
|
if campaignRecipient == nil {
|
|
s.logger.Debugw("'campaignrecipient' not found")
|
|
return false, nil
|
|
}
|
|
|
|
campaignRecipientID := campaignRecipient.ID.MustGet()
|
|
campaignRecipientIDPtr := &campaignRecipientID
|
|
// at this point we know which url param matched the campaignrecipientID, however
|
|
// it could have been any available identifier and not the one matching the campaign template
|
|
// it is possible now to check if it is correct, however it does not matter as the campaign
|
|
// recipient is already found
|
|
campaignID := campaignRecipient.CampaignID.MustGet()
|
|
campaign, err := s.repositories.Campaign.GetByID(
|
|
c,
|
|
&campaignID,
|
|
&repository.CampaignOption{},
|
|
)
|
|
// if there was an error
|
|
if err != nil {
|
|
s.logger.Debugw("failed to get active campaign",
|
|
"error", err,
|
|
)
|
|
return false, fmt.Errorf("failed to get active campaign and campaign recipient by public id: %s", err)
|
|
}
|
|
// check if the campaign is active
|
|
if !campaign.IsActive() {
|
|
s.logger.Debugw("campaign is not active",
|
|
"campaignID", campaign.ID.MustGet(),
|
|
)
|
|
return false, nil
|
|
}
|
|
templateID, err := campaign.TemplateID.Get()
|
|
if err != nil {
|
|
s.logger.Debugw("campaign has no template",
|
|
"error", err,
|
|
)
|
|
return false, nil
|
|
}
|
|
cTemplate, err := s.repositories.CampaignTemplate.GetByID(
|
|
c,
|
|
&templateID,
|
|
&repository.CampaignTemplateOption{
|
|
WithIdentifier: true,
|
|
},
|
|
)
|
|
if err != nil {
|
|
s.logger.Debugw("failed to get campaign template",
|
|
"templateID", templateID.String(),
|
|
"error", err,
|
|
)
|
|
return false, fmt.Errorf("failed to get campaign template: %s", err)
|
|
}
|
|
// check that the requesters IP is allow listed
|
|
ip := utils.ExtractClientIP(c.Request)
|
|
servedByIPFilter, err := s.checkIPFilter(c, ip, campaign, domain, &campaignID)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if servedByIPFilter {
|
|
return true, nil
|
|
}
|
|
// get the recipient
|
|
// if the recipient has been anonymized or removed, stop
|
|
recipientID, err := campaignRecipient.RecipientID.Get()
|
|
if err != nil {
|
|
return false, nil
|
|
}
|
|
recipient, err := s.repositories.Recipient.GetByID(
|
|
c,
|
|
&recipientID,
|
|
&repository.RecipientOption{},
|
|
)
|
|
if err != nil {
|
|
return false, fmt.Errorf("failed to get recipient: %s", err)
|
|
}
|
|
// check for evasion page first
|
|
var evasionPageID *uuid.UUID
|
|
if v, err := campaign.EvasionPageID.Get(); err == nil {
|
|
evasionPageID = &v
|
|
}
|
|
|
|
// figure out which page types this template has
|
|
var beforePageID *uuid.UUID
|
|
var beforeProxyID *uuid.UUID
|
|
if v, err := cTemplate.BeforeLandingPageID.Get(); err == nil {
|
|
beforePageID = &v
|
|
} else if v, err := cTemplate.BeforeLandingProxyID.Get(); err == nil {
|
|
beforeProxyID = &v
|
|
}
|
|
|
|
var landingPageID *uuid.UUID
|
|
var landingProxyID *uuid.UUID
|
|
if v, err := cTemplate.LandingPageID.Get(); err == nil {
|
|
landingPageID = &v
|
|
} else if v, err := cTemplate.LandingProxyID.Get(); err == nil {
|
|
landingProxyID = &v
|
|
} else {
|
|
return false, fmt.Errorf("Template is incomplete, missing landing page or Proxy ID")
|
|
}
|
|
|
|
var afterPageID *uuid.UUID
|
|
var afterProxyID *uuid.UUID
|
|
if v, err := cTemplate.AfterLandingPageID.Get(); err == nil {
|
|
afterPageID = &v
|
|
} else if v, err := cTemplate.AfterLandingProxyID.Get(); err == nil {
|
|
afterProxyID = &v
|
|
}
|
|
|
|
stateParamKey := cTemplate.StateIdentifier.Name.MustGet()
|
|
pageTypeQuery := ""
|
|
encryptedParam := c.Query(stateParamKey)
|
|
secret := utils.UUIDToSecret(&campaignID)
|
|
if v, err := utils.Decrypt(encryptedParam, secret); err == nil {
|
|
pageTypeQuery = v
|
|
}
|
|
// if there is no page type then this is the before landing page or the landing page
|
|
var pageID *uuid.UUID
|
|
var proxyID *uuid.UUID
|
|
nextPageType := ""
|
|
currentPageType := ""
|
|
|
|
/*
|
|
s.logger.Debugw("determining page flow",
|
|
"pageTypeQuery", pageTypeQuery,
|
|
"hasBeforePage", beforePageID != nil,
|
|
"hasBeforeProxy", beforeProxyID != nil,
|
|
"hasLandingPage", landingPageID != nil,
|
|
"hasLandingProxy", landingProxyID != nil,
|
|
"hasAfterPage", afterPageID != nil,
|
|
"hasAfterProxy", afterProxyID != nil,
|
|
"campaignRecipientID", campaignRecipientID.String(),
|
|
)
|
|
*/
|
|
|
|
if len(pageTypeQuery) == 0 {
|
|
// check if there's an evasion page to serve first
|
|
if evasionPageID != nil {
|
|
pageID = evasionPageID
|
|
s.logger.Debugw("initial request - serving evasion page",
|
|
"pageID", pageID.String(),
|
|
)
|
|
currentPageType = data.PAGE_TYPE_EVASION
|
|
// determine next page type based on template structure
|
|
if beforePageID != nil || beforeProxyID != nil {
|
|
nextPageType = data.PAGE_TYPE_BEFORE
|
|
} else {
|
|
nextPageType = data.PAGE_TYPE_LANDING
|
|
}
|
|
} else if beforePageID != nil || beforeProxyID != nil {
|
|
if beforePageID != nil {
|
|
pageID = beforePageID
|
|
s.logger.Debugw("initial request - serving before landing page",
|
|
"pageID", pageID.String(),
|
|
)
|
|
} else {
|
|
proxyID = beforeProxyID
|
|
s.logger.Debugw("initial request - serving before landing Proxy",
|
|
"proxyID", proxyID.String(),
|
|
)
|
|
}
|
|
currentPageType = data.PAGE_TYPE_BEFORE
|
|
nextPageType = data.PAGE_TYPE_LANDING
|
|
} else {
|
|
if landingPageID != nil {
|
|
pageID = landingPageID
|
|
s.logger.Debugw("initial request - serving landing page",
|
|
"pageID", pageID.String(),
|
|
)
|
|
} else {
|
|
proxyID = landingProxyID
|
|
s.logger.Debugw("initial request - serving landing Proxy",
|
|
"proxyID", proxyID.String(),
|
|
)
|
|
}
|
|
currentPageType = data.PAGE_TYPE_LANDING
|
|
if afterPageID != nil || afterProxyID != nil {
|
|
nextPageType = data.PAGE_TYPE_AFTER
|
|
} else {
|
|
nextPageType = data.PAGE_TYPE_DONE // landing page is final page
|
|
}
|
|
}
|
|
// if there is a page type, then we use that
|
|
} else {
|
|
switch pageTypeQuery {
|
|
// this is set if the previous page was an evasion page
|
|
case data.PAGE_TYPE_EVASION:
|
|
// after evasion page, go to before page or landing page
|
|
if beforePageID != nil || beforeProxyID != nil {
|
|
if beforePageID != nil {
|
|
pageID = beforePageID
|
|
s.logger.Debugw("serving before landing page from evasion state",
|
|
"pageID", pageID.String(),
|
|
)
|
|
} else {
|
|
proxyID = beforeProxyID
|
|
s.logger.Debugw("serving before landing Proxy from evasion state",
|
|
"proxyID", proxyID.String(),
|
|
)
|
|
}
|
|
currentPageType = data.PAGE_TYPE_BEFORE
|
|
nextPageType = data.PAGE_TYPE_LANDING
|
|
} else {
|
|
if landingPageID != nil {
|
|
pageID = landingPageID
|
|
s.logger.Debugw("serving landing page from evasion state",
|
|
"pageID", pageID.String(),
|
|
)
|
|
} else {
|
|
proxyID = landingProxyID
|
|
s.logger.Debugw("serving landing Proxy from evasion state",
|
|
"proxyID", proxyID.String(),
|
|
)
|
|
}
|
|
currentPageType = data.PAGE_TYPE_LANDING
|
|
if afterPageID != nil || afterProxyID != nil {
|
|
nextPageType = data.PAGE_TYPE_AFTER
|
|
} else {
|
|
nextPageType = data.PAGE_TYPE_DONE
|
|
}
|
|
}
|
|
// special case for deny page access from evasion page
|
|
case "deny":
|
|
// serve the deny page if one is configured
|
|
if denyPageID, err := campaign.DenyPageID.Get(); err == nil {
|
|
err = s.renderDenyPage(c, domain, &denyPageID)
|
|
if err != nil {
|
|
return true, fmt.Errorf("failed to render deny page from evasion: %s", err)
|
|
}
|
|
return true, nil
|
|
} else {
|
|
// if no deny page configured, return 403
|
|
c.String(http.StatusForbidden, "Access denied")
|
|
c.Abort()
|
|
return true, nil
|
|
}
|
|
// this is set when transitioning to the before page
|
|
case data.PAGE_TYPE_BEFORE:
|
|
if beforePageID != nil {
|
|
pageID = beforePageID
|
|
s.logger.Debugw("serving before landing page from state",
|
|
"pageID", pageID.String(),
|
|
)
|
|
} else {
|
|
proxyID = beforeProxyID
|
|
s.logger.Debugw("serving before landing Proxy from state",
|
|
"proxyID", proxyID.String(),
|
|
)
|
|
}
|
|
currentPageType = data.PAGE_TYPE_BEFORE
|
|
nextPageType = data.PAGE_TYPE_LANDING
|
|
// this is set if the previous page was a before page
|
|
case data.PAGE_TYPE_LANDING:
|
|
if landingPageID != nil {
|
|
pageID = landingPageID
|
|
s.logger.Debugw("serving landing page from state",
|
|
"pageID", pageID.String(),
|
|
)
|
|
} else {
|
|
proxyID = landingProxyID
|
|
s.logger.Debugw("serving landing Proxy from state",
|
|
"proxyID", proxyID.String(),
|
|
)
|
|
}
|
|
currentPageType = data.PAGE_TYPE_LANDING
|
|
if afterPageID != nil || afterProxyID != nil {
|
|
nextPageType = data.PAGE_TYPE_AFTER
|
|
} else {
|
|
nextPageType = data.PAGE_TYPE_DONE // landing page is final page
|
|
}
|
|
// this is set if the previous page was a landing page
|
|
case data.PAGE_TYPE_AFTER:
|
|
if afterPageID != nil {
|
|
pageID = afterPageID
|
|
s.logger.Debugw("serving after landing page from state",
|
|
"pageID", pageID.String(),
|
|
)
|
|
} else if afterProxyID != nil {
|
|
proxyID = afterProxyID
|
|
s.logger.Debugw("serving after landing Proxy from state",
|
|
"proxyID", proxyID.String(),
|
|
)
|
|
} else if landingPageID != nil {
|
|
pageID = landingPageID
|
|
s.logger.Debugw("fallback to landing page for after state",
|
|
"pageID", pageID.String(),
|
|
)
|
|
} else {
|
|
proxyID = landingProxyID
|
|
s.logger.Debugw("fallback to landing Proxy for after state",
|
|
"proxyID", proxyID.String(),
|
|
)
|
|
}
|
|
// next page after a after landing page, is the same page
|
|
currentPageType = data.PAGE_TYPE_AFTER
|
|
nextPageType = data.PAGE_TYPE_DONE
|
|
case data.PAGE_TYPE_DONE:
|
|
if afterPageID != nil {
|
|
pageID = afterPageID
|
|
s.logger.Debugw("serving after landing page for done state",
|
|
"pageID", pageID.String(),
|
|
)
|
|
} else if afterProxyID != nil {
|
|
proxyID = afterProxyID
|
|
s.logger.Debugw("serving after landing Proxy for done state",
|
|
"proxyID", proxyID.String(),
|
|
)
|
|
} else if landingPageID != nil {
|
|
pageID = landingPageID
|
|
s.logger.Debugw("fallback to landing page for done state",
|
|
"pageID", pageID.String(),
|
|
)
|
|
} else {
|
|
proxyID = landingProxyID
|
|
s.logger.Debugw("fallback to landing Proxy for done state",
|
|
"proxyID", proxyID.String(),
|
|
)
|
|
}
|
|
currentPageType = data.PAGE_TYPE_DONE
|
|
nextPageType = data.PAGE_TYPE_DONE
|
|
}
|
|
}
|
|
// support POST, PUT, and PATCH methods for data submission
|
|
isDataSubmission := c.Request.Method == http.MethodPost ||
|
|
c.Request.Method == http.MethodPut ||
|
|
c.Request.Method == http.MethodPatch
|
|
|
|
// if this is a data submission request, then save the submitted data
|
|
if isDataSubmission {
|
|
submitDataEventID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA]
|
|
newEventID := uuid.New()
|
|
campaignID := campaign.ID.MustGet()
|
|
clientIP := vo.NewOptionalString64Must(utils.ExtractClientIP(c.Request))
|
|
userAgent := vo.NewOptionalString255Must(utils.Substring(c.Request.UserAgent(), 0, MAX_USER_AGENT_SAVED))
|
|
submittedData := vo.NewEmptyOptionalString1MB()
|
|
|
|
// prepare submitted data for webhook
|
|
var webhookData map[string]interface{}
|
|
var rawData string
|
|
|
|
if campaign.SaveSubmittedData.MustGet() {
|
|
// parse based on content type
|
|
contentType := c.Request.Header.Get("Content-Type")
|
|
mediaType, _, _ := mime.ParseMediaType(contentType)
|
|
|
|
switch {
|
|
case strings.Contains(mediaType, "application/json"):
|
|
// handle json content type
|
|
body, err := io.ReadAll(c.Request.Body)
|
|
if err != nil {
|
|
return true, fmt.Errorf("failed to read json request body: %s", err)
|
|
}
|
|
c.Request.Body.Close()
|
|
|
|
rawData = string(body)
|
|
|
|
// parse json for webhook
|
|
webhookData = make(map[string]interface{})
|
|
if err := json.Unmarshal(body, &webhookData); err != nil {
|
|
s.logger.Warnw("failed to parse json for webhook", "error", err)
|
|
// store raw data if json parsing fails
|
|
webhookData = map[string]interface{}{
|
|
"_raw": rawData,
|
|
}
|
|
}
|
|
|
|
case strings.Contains(mediaType, "multipart/form-data"):
|
|
// handle multipart form data
|
|
err = c.Request.ParseMultipartForm(32 << 20) // 32 MB max
|
|
if err != nil {
|
|
return true, fmt.Errorf("failed to parse multipart form data: %s", err)
|
|
}
|
|
|
|
// encode multipart data
|
|
if c.Request.MultipartForm != nil {
|
|
values := url.Values{}
|
|
for key, vals := range c.Request.MultipartForm.Value {
|
|
for _, val := range vals {
|
|
values.Add(key, val)
|
|
}
|
|
}
|
|
// include file information and content in saved data
|
|
for key, files := range c.Request.MultipartForm.File {
|
|
for i, file := range files {
|
|
prefix := key
|
|
if len(files) > 1 {
|
|
prefix = fmt.Sprintf("%s[%d]", key, i)
|
|
}
|
|
values.Add(prefix+"[filename]", file.Filename)
|
|
values.Add(prefix+"[size]", fmt.Sprintf("%d", file.Size))
|
|
values.Add(prefix+"[content_type]", file.Header.Get("Content-Type"))
|
|
|
|
// read and encode file content
|
|
f, err := file.Open()
|
|
if err != nil {
|
|
s.logger.Warnw("failed to open uploaded file", "filename", file.Filename, "error", err)
|
|
continue
|
|
}
|
|
fileContent, err := io.ReadAll(f)
|
|
f.Close()
|
|
if err != nil {
|
|
s.logger.Warnw("failed to read uploaded file", "filename", file.Filename, "error", err)
|
|
continue
|
|
}
|
|
// encode file content as base64
|
|
encodedContent := base64.StdEncoding.EncodeToString(fileContent)
|
|
values.Add(prefix+"[content]", encodedContent)
|
|
}
|
|
}
|
|
rawData = values.Encode()
|
|
|
|
// convert to map for webhook
|
|
webhookData = make(map[string]interface{})
|
|
for key, vals := range c.Request.MultipartForm.Value {
|
|
if len(vals) == 1 {
|
|
webhookData[key] = vals[0]
|
|
} else {
|
|
webhookData[key] = vals
|
|
}
|
|
}
|
|
// add file metadata and content for webhook
|
|
fileData := make(map[string]interface{})
|
|
for key, files := range c.Request.MultipartForm.File {
|
|
fileList := make([]map[string]interface{}, 0, len(files))
|
|
for _, file := range files {
|
|
fileInfo := map[string]interface{}{
|
|
"filename": file.Filename,
|
|
"size": file.Size,
|
|
}
|
|
if contentType := file.Header.Get("Content-Type"); contentType != "" {
|
|
fileInfo["content_type"] = contentType
|
|
}
|
|
|
|
// read and encode file content
|
|
f, err := file.Open()
|
|
if err != nil {
|
|
s.logger.Warnw("failed to open uploaded file for webhook", "filename", file.Filename, "error", err)
|
|
fileList = append(fileList, fileInfo)
|
|
continue
|
|
}
|
|
fileContent, err := io.ReadAll(f)
|
|
f.Close()
|
|
if err != nil {
|
|
s.logger.Warnw("failed to read uploaded file for webhook", "filename", file.Filename, "error", err)
|
|
fileList = append(fileList, fileInfo)
|
|
continue
|
|
}
|
|
// encode file content as base64
|
|
fileInfo["content"] = base64.StdEncoding.EncodeToString(fileContent)
|
|
fileInfo["encoding"] = "base64"
|
|
|
|
fileList = append(fileList, fileInfo)
|
|
}
|
|
if len(fileList) == 1 {
|
|
fileData[key] = fileList[0]
|
|
} else {
|
|
fileData[key] = fileList
|
|
}
|
|
}
|
|
if len(fileData) > 0 {
|
|
webhookData["_files"] = fileData
|
|
}
|
|
}
|
|
|
|
default:
|
|
// handle url-encoded and other form data
|
|
err = c.Request.ParseForm()
|
|
if err != nil {
|
|
return true, fmt.Errorf("failed to parse submitted form data: %s", err)
|
|
}
|
|
|
|
rawData = c.Request.PostForm.Encode()
|
|
|
|
// convert form data to map for webhook
|
|
webhookData = make(map[string]interface{})
|
|
for key, values := range c.Request.PostForm {
|
|
if len(values) == 1 {
|
|
webhookData[key] = values[0]
|
|
} else {
|
|
webhookData[key] = values
|
|
}
|
|
}
|
|
}
|
|
|
|
submittedData, err = vo.NewOptionalString1MB(rawData)
|
|
if err != nil {
|
|
return true, fmt.Errorf("user submitted phishing data too large: %s", err)
|
|
}
|
|
}
|
|
var event *model.CampaignEvent
|
|
// only save data if red team flag is set
|
|
if !campaign.IsAnonymous.MustGet() {
|
|
metadata := model.ExtractCampaignEventMetadata(c, campaign)
|
|
event = &model.CampaignEvent{
|
|
ID: &newEventID,
|
|
CampaignID: &campaignID,
|
|
RecipientID: &recipientID,
|
|
IP: clientIP,
|
|
UserAgent: userAgent,
|
|
EventID: submitDataEventID,
|
|
Data: submittedData,
|
|
Metadata: metadata,
|
|
}
|
|
} else {
|
|
ua := vo.NewEmptyOptionalString255()
|
|
data := vo.NewEmptyOptionalString1MB()
|
|
event = &model.CampaignEvent{
|
|
ID: &newEventID,
|
|
CampaignID: &campaignID,
|
|
RecipientID: nil,
|
|
IP: vo.NewEmptyOptionalString64(),
|
|
UserAgent: ua,
|
|
EventID: submitDataEventID,
|
|
Data: data,
|
|
Metadata: vo.NewEmptyOptionalString1MB(),
|
|
}
|
|
}
|
|
err = s.repositories.Campaign.SaveEvent(c, event)
|
|
if err != nil {
|
|
return true, fmt.Errorf("failed to save campaign event: %s", err)
|
|
}
|
|
// check and update if most notable event for recipient
|
|
currentNotableEventID, _ := campaignRecipient.NotableEventID.Get()
|
|
if cache.IsMoreNotableCampaignRecipientEventID(
|
|
¤tNotableEventID,
|
|
submitDataEventID,
|
|
) {
|
|
campaignRecipient.NotableEventID.Set(*submitDataEventID)
|
|
err := s.repositories.CampaignRecipient.UpdateByID(
|
|
c,
|
|
campaignRecipientIDPtr,
|
|
campaignRecipient,
|
|
)
|
|
if err != nil {
|
|
s.logger.Errorw(
|
|
"failed to update notable event",
|
|
"campaignRecipientID", campaignRecipientID.String(),
|
|
"error", err,
|
|
)
|
|
return true, errs.Wrap(err)
|
|
}
|
|
}
|
|
// handle webhook
|
|
webhookID, err := s.repositories.Campaign.GetWebhookIDByCampaignID(
|
|
c,
|
|
&campaignID,
|
|
)
|
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
s.logger.Errorw("failed to get webhook id by campaign id",
|
|
"campaignID", campaignID.String(),
|
|
"error", err,
|
|
)
|
|
return true, errs.Wrap(err)
|
|
}
|
|
if webhookID != nil {
|
|
err = s.services.Campaign.HandleWebhook(
|
|
// TODO this should be tied to a application wide context not the request
|
|
context.TODO(),
|
|
webhookID,
|
|
&campaignID,
|
|
&recipientID,
|
|
data.EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA,
|
|
webhookData,
|
|
)
|
|
if err != nil {
|
|
return true, fmt.Errorf("failed to handle webhook: %s", err)
|
|
}
|
|
}
|
|
}
|
|
// if redirect && data submission && final page
|
|
if isDataSubmission {
|
|
if redirectURL, err := cTemplate.AfterLandingPageRedirectURL.Get(); err == nil {
|
|
if v := redirectURL.String(); len(v) > 0 {
|
|
// if the current page is landing and there is no after, redirect
|
|
if currentPageType == data.PAGE_TYPE_DONE {
|
|
c.Redirect(http.StatusSeeOther, v)
|
|
c.Abort()
|
|
return true, nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// handle Proxy pages
|
|
if proxyID != nil {
|
|
// this is a Proxy page - redirect to the phishing domain
|
|
proxy, err := s.repositories.Proxy.GetByID(
|
|
c,
|
|
proxyID,
|
|
&repository.ProxyOption{},
|
|
)
|
|
if err != nil {
|
|
return true, fmt.Errorf("failed to get Proxy page: %s", err)
|
|
}
|
|
|
|
startURL, err := proxy.StartURL.Get()
|
|
if err != nil {
|
|
return true, fmt.Errorf("Proxy page has no start URL: %s", err)
|
|
}
|
|
|
|
// parse proxy config to find the phishing domain
|
|
proxyConfig, err := proxy.ProxyConfig.Get()
|
|
if err != nil {
|
|
return true, fmt.Errorf("Proxy page has no configuration: %s", err)
|
|
}
|
|
|
|
// extract the phishing domain from Proxy configuration
|
|
var rawConfig map[string]interface{}
|
|
err = yaml.Unmarshal([]byte(proxyConfig.String()), &rawConfig)
|
|
if err != nil {
|
|
return true, fmt.Errorf("invalid Proxy configuration YAML: %s", err)
|
|
}
|
|
|
|
// parse the start URL to get the target domain
|
|
parsedStartURL, err := url.Parse(startURL.String())
|
|
if err != nil {
|
|
return true, fmt.Errorf("invalid proxy start URL: %s", err)
|
|
}
|
|
startDomain := parsedStartURL.Host
|
|
|
|
// find the phishing domain mapping for the start URL domain
|
|
phishingDomain := ""
|
|
for originalHost, domainData := range rawConfig {
|
|
if originalHost == "proxy" || originalHost == "global" {
|
|
continue
|
|
}
|
|
if originalHost == startDomain {
|
|
if domainMap, ok := domainData.(map[string]interface{}); ok {
|
|
if to, exists := domainMap["to"]; exists {
|
|
if toStr, ok := to.(string); ok {
|
|
phishingDomain = toStr
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if phishingDomain == "" {
|
|
return true, fmt.Errorf("no phishing domain mapping found for start URL domain: %s", startDomain)
|
|
}
|
|
|
|
// create synthetic message_read event for landing/before/after pages
|
|
// this ensures that "emails read" stat is always >= "website visits" stat
|
|
// only create if recipient doesn't already have a message_read event
|
|
if currentPageType == data.PAGE_TYPE_LANDING ||
|
|
currentPageType == data.PAGE_TYPE_BEFORE ||
|
|
currentPageType == data.PAGE_TYPE_AFTER {
|
|
|
|
messageReadEventID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_MESSAGE_READ]
|
|
|
|
// check if recipient already has a message_read event for this campaign
|
|
hasMessageRead, err := s.repositories.Campaign.HasMessageReadEvent(
|
|
c,
|
|
&campaignID,
|
|
&recipientID,
|
|
messageReadEventID,
|
|
)
|
|
if err != nil {
|
|
s.logger.Errorw("failed to check for existing message read event",
|
|
"error", err,
|
|
"proxyID", proxyID.String(),
|
|
)
|
|
// continue anyway to attempt creating the event
|
|
}
|
|
|
|
// only create synthetic event if no message_read event exists
|
|
if !hasMessageRead {
|
|
syntheticReadEventID := uuid.New()
|
|
clientIP := vo.NewOptionalString64Must(utils.ExtractClientIP(c.Request))
|
|
userAgent := vo.NewOptionalString255Must(utils.Substring(c.Request.UserAgent(), 0, MAX_USER_AGENT_SAVED))
|
|
syntheticData := vo.NewOptionalString1MBMust("synthetic_from_page_visit")
|
|
|
|
var syntheticReadEvent *model.CampaignEvent
|
|
if !campaign.IsAnonymous.MustGet() {
|
|
metadata := model.ExtractCampaignEventMetadata(c, campaign)
|
|
syntheticReadEvent = &model.CampaignEvent{
|
|
ID: &syntheticReadEventID,
|
|
CampaignID: &campaignID,
|
|
RecipientID: &recipientID,
|
|
IP: clientIP,
|
|
UserAgent: userAgent,
|
|
EventID: messageReadEventID,
|
|
Data: syntheticData,
|
|
Metadata: metadata,
|
|
}
|
|
} else {
|
|
ua := vo.NewEmptyOptionalString255()
|
|
syntheticReadEvent = &model.CampaignEvent{
|
|
ID: &syntheticReadEventID,
|
|
CampaignID: &campaignID,
|
|
RecipientID: nil,
|
|
IP: vo.NewEmptyOptionalString64(),
|
|
UserAgent: ua,
|
|
EventID: messageReadEventID,
|
|
Data: syntheticData,
|
|
Metadata: vo.NewEmptyOptionalString1MB(),
|
|
}
|
|
}
|
|
|
|
// save the synthetic message read event
|
|
err = s.repositories.Campaign.SaveEvent(c, syntheticReadEvent)
|
|
if err != nil {
|
|
s.logger.Errorw("failed to save synthetic message read event",
|
|
"error", err,
|
|
"proxyID", proxyID.String(),
|
|
"pageType", currentPageType,
|
|
)
|
|
// continue anyway to save the page visit event
|
|
} else {
|
|
s.logger.Debugw("created synthetic message read event from page visit",
|
|
"proxyID", proxyID.String(),
|
|
"pageType", currentPageType,
|
|
)
|
|
}
|
|
} else {
|
|
s.logger.Debugw("skipping synthetic message read event - already exists",
|
|
"proxyID", proxyID.String(),
|
|
"pageType", currentPageType,
|
|
)
|
|
}
|
|
}
|
|
|
|
// save the event of Proxy page being accessed
|
|
visitEventID := uuid.New()
|
|
eventName := ""
|
|
switch currentPageType {
|
|
case data.PAGE_TYPE_EVASION:
|
|
eventName = data.EVENT_CAMPAIGN_RECIPIENT_EVASION_PAGE_VISITED
|
|
case data.PAGE_TYPE_BEFORE:
|
|
eventName = data.EVENT_CAMPAIGN_RECIPIENT_BEFORE_PAGE_VISITED
|
|
case data.PAGE_TYPE_LANDING:
|
|
eventName = data.EVENT_CAMPAIGN_RECIPIENT_PAGE_VISITED
|
|
case data.PAGE_TYPE_AFTER:
|
|
eventName = data.EVENT_CAMPAIGN_RECIPIENT_AFTER_PAGE_VISITED
|
|
default:
|
|
eventName = data.EVENT_CAMPAIGN_RECIPIENT_PAGE_VISITED
|
|
}
|
|
eventID := cache.EventIDByName[eventName]
|
|
clientIP := vo.NewOptionalString64Must(utils.ExtractClientIP(c.Request))
|
|
userAgent := vo.NewOptionalString255Must(utils.Substring(c.Request.UserAgent(), 0, MAX_USER_AGENT_SAVED))
|
|
var visitEvent *model.CampaignEvent
|
|
if !campaign.IsAnonymous.MustGet() {
|
|
metadata := model.ExtractCampaignEventMetadata(c, campaign)
|
|
visitEvent = &model.CampaignEvent{
|
|
ID: &visitEventID,
|
|
CampaignID: &campaignID,
|
|
RecipientID: &recipientID,
|
|
IP: clientIP,
|
|
UserAgent: userAgent,
|
|
EventID: eventID,
|
|
Data: vo.NewEmptyOptionalString1MB(),
|
|
Metadata: metadata,
|
|
}
|
|
} else {
|
|
ua := vo.NewEmptyOptionalString255()
|
|
visitEvent = &model.CampaignEvent{
|
|
ID: &visitEventID,
|
|
CampaignID: &campaignID,
|
|
RecipientID: nil,
|
|
IP: vo.NewEmptyOptionalString64(),
|
|
UserAgent: ua,
|
|
EventID: eventID,
|
|
Data: vo.NewEmptyOptionalString1MB(),
|
|
Metadata: vo.NewEmptyOptionalString1MB(),
|
|
}
|
|
}
|
|
|
|
// save the visit event unless it's the final page repeat
|
|
if currentPageType != data.PAGE_TYPE_DONE {
|
|
err = s.repositories.Campaign.SaveEvent(
|
|
c,
|
|
visitEvent,
|
|
)
|
|
if err != nil {
|
|
s.logger.Errorw("failed to save proxy visit event",
|
|
"error", err,
|
|
"proxyID", proxyID.String(),
|
|
)
|
|
}
|
|
|
|
// check and update if most notable event for recipient
|
|
currentNotableEventID, _ := campaignRecipient.NotableEventID.Get()
|
|
if cache.IsMoreNotableCampaignRecipientEventID(
|
|
¤tNotableEventID,
|
|
eventID,
|
|
) {
|
|
campaignRecipient.NotableEventID.Set(*eventID)
|
|
err := s.repositories.CampaignRecipient.UpdateByID(
|
|
c,
|
|
campaignRecipientIDPtr,
|
|
campaignRecipient,
|
|
)
|
|
if err != nil {
|
|
s.logger.Errorw("failed to update notable event for proxy",
|
|
"campaignRecipientID", campaignRecipientID.String(),
|
|
"eventID", eventID.String(),
|
|
"error", err,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// handle webhook for Proxy page visit
|
|
webhookID, err := s.repositories.Campaign.GetWebhookIDByCampaignID(
|
|
c,
|
|
&campaignID,
|
|
)
|
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
s.logger.Errorw("failed to get webhook id by campaign id for proxy",
|
|
"campaignID", campaignID.String(),
|
|
"error", err,
|
|
)
|
|
}
|
|
if webhookID != nil && currentPageType != data.PAGE_TYPE_DONE {
|
|
err = s.services.Campaign.HandleWebhook(
|
|
// TODO this should be tied to a application wide context not the request
|
|
context.TODO(),
|
|
webhookID,
|
|
&campaignID,
|
|
&recipientID,
|
|
eventName,
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
s.logger.Errorw("failed to handle webhook for Proxy page",
|
|
"error", err,
|
|
"proxyID", proxyID.String(),
|
|
)
|
|
}
|
|
}
|
|
|
|
// validate phishing domain format
|
|
if strings.Contains(phishingDomain, "://") || strings.Contains(phishingDomain, "/") {
|
|
return true, fmt.Errorf("invalid phishing domain format: %s", phishingDomain)
|
|
}
|
|
|
|
// validate that the phishing domain is configured as a proxy domain
|
|
var phishingDomainRecord *database.Domain
|
|
res := s.db.
|
|
Select("id, name, type, proxy_id, proxy_target_domain").
|
|
Where("name = ?", phishingDomain).
|
|
First(&phishingDomainRecord)
|
|
|
|
if res.RowsAffected == 0 {
|
|
return true, fmt.Errorf("phishing domain '%s' is not configured in the system", phishingDomain)
|
|
}
|
|
|
|
if phishingDomainRecord.Type != "proxy" {
|
|
return true, fmt.Errorf("phishing domain '%s' is not configured as proxy type", phishingDomain)
|
|
}
|
|
|
|
s.logger.Debugw("redirecting to Proxy phishing domain",
|
|
"proxyID", proxyID.String(),
|
|
"startURL", startURL.String(),
|
|
"phishingDomain", phishingDomain,
|
|
"currentPageType", currentPageType,
|
|
"phishingDomainType", phishingDomainRecord.Type,
|
|
)
|
|
|
|
// build the redirect URL to the phishing domain with campaign recipient ID
|
|
urlParam := cTemplate.URLIdentifier.Name.MustGet()
|
|
|
|
// construct the redirect URL properly
|
|
u := &url.URL{
|
|
Scheme: "https",
|
|
Host: phishingDomain,
|
|
Path: parsedStartURL.Path,
|
|
}
|
|
|
|
q := u.Query()
|
|
q.Set(urlParam, campaignRecipientID.String())
|
|
if encryptedParam != "" {
|
|
q.Set(stateParamKey, encryptedParam)
|
|
}
|
|
// preserve any existing query params from start URL
|
|
if parsedStartURL.RawQuery != "" {
|
|
startQuery, _ := url.ParseQuery(parsedStartURL.RawQuery)
|
|
for key, values := range startQuery {
|
|
for _, value := range values {
|
|
q.Add(key, value)
|
|
}
|
|
}
|
|
}
|
|
u.RawQuery = q.Encode()
|
|
|
|
s.logger.Debugw("built proxy redirect URL",
|
|
"redirectURL", u.String(),
|
|
"phishingDomain", phishingDomain,
|
|
"originalPath", parsedStartURL.Path,
|
|
)
|
|
|
|
// validate the final URL
|
|
finalURL := u.String()
|
|
if !strings.HasPrefix(finalURL, "https://") {
|
|
return true, fmt.Errorf("invalid redirect URL scheme: %s", finalURL)
|
|
}
|
|
|
|
s.logger.Infow("redirecting to proxy domain",
|
|
"from", c.Request.Host+c.Request.URL.Path,
|
|
"to", finalURL,
|
|
"campaignRecipientID", campaignRecipientID.String(),
|
|
)
|
|
|
|
c.Redirect(http.StatusSeeOther, finalURL)
|
|
c.Abort()
|
|
return true, nil
|
|
}
|
|
|
|
// ensure we have a page ID if we're not handling a proxy
|
|
if pageID == nil {
|
|
return true, fmt.Errorf("no page or proxy configured for current step")
|
|
}
|
|
|
|
// fetch the regular page
|
|
page, err := s.repositories.Page.GetByID(
|
|
c,
|
|
pageID,
|
|
&repository.PageOption{},
|
|
)
|
|
if err != nil {
|
|
return true, fmt.Errorf("failed to get landing page: %s", err)
|
|
}
|
|
|
|
// fetch the sender email to use for the template
|
|
emailID := cTemplate.EmailID.MustGet()
|
|
email, err := s.repositories.Email.GetByID(
|
|
c,
|
|
&emailID,
|
|
&repository.EmailOption{},
|
|
)
|
|
if err != nil {
|
|
return true, fmt.Errorf("failed to get email: %s", err)
|
|
}
|
|
encryptedParam, err = utils.Encrypt(nextPageType, secret)
|
|
if err != nil {
|
|
return true, fmt.Errorf("failed to encrypt next page type: %s", err)
|
|
}
|
|
urlPath := cTemplate.URLPath.MustGet().String()
|
|
|
|
err = s.renderPageTemplate(
|
|
c,
|
|
domain,
|
|
email,
|
|
campaignRecipientIDPtr,
|
|
recipient,
|
|
page,
|
|
cTemplate,
|
|
encryptedParam,
|
|
urlPath,
|
|
campaign,
|
|
)
|
|
if err != nil {
|
|
return true, fmt.Errorf("failed to render phishing page: %s", err)
|
|
}
|
|
|
|
// create synthetic message_read event for landing/before/after pages
|
|
// this ensures that "emails read" stat is always >= "website visits" stat
|
|
// only create if recipient doesn't already have a message_read event
|
|
if currentPageType == data.PAGE_TYPE_LANDING ||
|
|
currentPageType == data.PAGE_TYPE_BEFORE ||
|
|
currentPageType == data.PAGE_TYPE_AFTER {
|
|
|
|
messageReadEventID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_MESSAGE_READ]
|
|
|
|
// check if recipient already has a message_read event for this campaign
|
|
hasMessageRead, err := s.repositories.Campaign.HasMessageReadEvent(
|
|
c,
|
|
&campaignID,
|
|
&recipientID,
|
|
messageReadEventID,
|
|
)
|
|
if err != nil {
|
|
s.logger.Errorw("failed to check for existing message read event",
|
|
"error", err,
|
|
"campaignRecipientID", campaignRecipientID.String(),
|
|
)
|
|
// continue anyway to attempt creating the event
|
|
}
|
|
|
|
// only create synthetic event if no message_read event exists
|
|
if !hasMessageRead {
|
|
syntheticReadEventID := uuid.New()
|
|
clientIP := vo.NewOptionalString64Must(utils.ExtractClientIP(c.Request))
|
|
userAgent := vo.NewOptionalString255Must(utils.Substring(c.Request.UserAgent(), 0, MAX_USER_AGENT_SAVED))
|
|
syntheticData := vo.NewOptionalString1MBMust("synthetic_from_page_visit")
|
|
|
|
var syntheticReadEvent *model.CampaignEvent
|
|
if !campaign.IsAnonymous.MustGet() {
|
|
metadata := model.ExtractCampaignEventMetadata(c, campaign)
|
|
syntheticReadEvent = &model.CampaignEvent{
|
|
ID: &syntheticReadEventID,
|
|
CampaignID: &campaignID,
|
|
RecipientID: &recipientID,
|
|
IP: clientIP,
|
|
UserAgent: userAgent,
|
|
EventID: messageReadEventID,
|
|
Data: syntheticData,
|
|
Metadata: metadata,
|
|
}
|
|
} else {
|
|
ua := vo.NewEmptyOptionalString255()
|
|
syntheticReadEvent = &model.CampaignEvent{
|
|
ID: &syntheticReadEventID,
|
|
CampaignID: &campaignID,
|
|
RecipientID: nil,
|
|
IP: vo.NewEmptyOptionalString64(),
|
|
UserAgent: ua,
|
|
EventID: messageReadEventID,
|
|
Data: syntheticData,
|
|
Metadata: vo.NewEmptyOptionalString1MB(),
|
|
}
|
|
}
|
|
|
|
// save the synthetic message read event
|
|
err = s.repositories.Campaign.SaveEvent(c, syntheticReadEvent)
|
|
if err != nil {
|
|
s.logger.Errorw("failed to save synthetic message read event",
|
|
"error", err,
|
|
"campaignRecipientID", campaignRecipientID.String(),
|
|
"pageType", currentPageType,
|
|
)
|
|
// continue anyway to save the page visit event
|
|
} else {
|
|
s.logger.Debugw("created synthetic message read event from page visit",
|
|
"campaignRecipientID", campaignRecipientID.String(),
|
|
"pageType", currentPageType,
|
|
)
|
|
}
|
|
} else {
|
|
s.logger.Debugw("skipping synthetic message read event - already exists",
|
|
"campaignRecipientID", campaignRecipientID.String(),
|
|
"pageType", currentPageType,
|
|
)
|
|
}
|
|
}
|
|
|
|
// save the event of page has been visited
|
|
eventName := ""
|
|
switch currentPageType {
|
|
case data.PAGE_TYPE_EVASION:
|
|
eventName = data.EVENT_CAMPAIGN_RECIPIENT_EVASION_PAGE_VISITED
|
|
case data.PAGE_TYPE_BEFORE:
|
|
eventName = data.EVENT_CAMPAIGN_RECIPIENT_BEFORE_PAGE_VISITED
|
|
case data.PAGE_TYPE_LANDING:
|
|
eventName = data.EVENT_CAMPAIGN_RECIPIENT_PAGE_VISITED
|
|
case data.PAGE_TYPE_AFTER:
|
|
eventName = data.EVENT_CAMPAIGN_RECIPIENT_AFTER_PAGE_VISITED
|
|
}
|
|
|
|
campaignEventID := cache.EventIDByName[eventName]
|
|
eventID := uuid.New()
|
|
clientIP := vo.NewOptionalString64Must(utils.ExtractClientIP(c.Request))
|
|
userAgent := vo.NewOptionalString255Must(utils.Substring(c.Request.UserAgent(), 0, MAX_USER_AGENT_SAVED))
|
|
var event *model.CampaignEvent
|
|
if !campaign.IsAnonymous.MustGet() {
|
|
metadata := model.ExtractCampaignEventMetadata(c, campaign)
|
|
event = &model.CampaignEvent{
|
|
ID: &eventID,
|
|
CampaignID: &campaignID,
|
|
RecipientID: &recipientID,
|
|
IP: clientIP,
|
|
UserAgent: userAgent,
|
|
EventID: campaignEventID,
|
|
Data: vo.NewEmptyOptionalString1MB(),
|
|
Metadata: metadata,
|
|
}
|
|
} else {
|
|
ua := vo.NewEmptyOptionalString255()
|
|
event = &model.CampaignEvent{
|
|
ID: &eventID,
|
|
CampaignID: &campaignID,
|
|
RecipientID: nil,
|
|
IP: vo.NewEmptyOptionalString64(),
|
|
UserAgent: ua,
|
|
EventID: campaignEventID,
|
|
Data: vo.NewEmptyOptionalString1MB(),
|
|
Metadata: vo.NewEmptyOptionalString1MB(),
|
|
}
|
|
}
|
|
// only log the page visit if it is not after the final page
|
|
if currentPageType != data.PAGE_TYPE_DONE {
|
|
err = s.repositories.Campaign.SaveEvent(
|
|
c,
|
|
event,
|
|
)
|
|
if err != nil {
|
|
return true, fmt.Errorf("failed to save campaign event: %s", err)
|
|
}
|
|
}
|
|
// check and update if most notable event for recipient
|
|
currentNotableEventID, _ := campaignRecipient.NotableEventID.Get()
|
|
if cache.IsMoreNotableCampaignRecipientEventID(
|
|
¤tNotableEventID,
|
|
campaignEventID,
|
|
) {
|
|
campaignRecipient.NotableEventID.Set(*campaignEventID)
|
|
err := s.repositories.CampaignRecipient.UpdateByID(
|
|
c,
|
|
campaignRecipientIDPtr,
|
|
campaignRecipient,
|
|
)
|
|
if err != nil {
|
|
s.logger.Errorw("failed to update notable event",
|
|
"campaignRecipientID", campaignRecipientID.String(),
|
|
"eventID", campaignEventID.String(),
|
|
"error", err,
|
|
)
|
|
return true, errs.Wrap(err)
|
|
}
|
|
}
|
|
// handle webhook
|
|
webhookID, err := s.repositories.Campaign.GetWebhookIDByCampaignID(
|
|
c,
|
|
&campaignID,
|
|
)
|
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
s.logger.Errorw("failed to get webhook id by campaign id %s",
|
|
"campaignID", campaignID.String(),
|
|
"error", err,
|
|
)
|
|
return true, errs.Wrap(err)
|
|
}
|
|
if webhookID == nil {
|
|
return true, nil
|
|
}
|
|
// do not notify on visiting the page done as it is a repeat of the flow
|
|
if currentPageType != data.PAGE_TYPE_DONE {
|
|
err = s.services.Campaign.HandleWebhook(
|
|
// TODO this should be tied to a application wide context not the request
|
|
context.TODO(),
|
|
webhookID,
|
|
&campaignID,
|
|
&recipientID,
|
|
eventName,
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
return true, fmt.Errorf("failed to handle webhook: %s", err)
|
|
}
|
|
}
|
|
|
|
return true, nil
|
|
}
|
|
|
|
func (s *Server) renderDenyPage(
|
|
c *gin.Context,
|
|
domain *database.Domain,
|
|
pageID *uuid.UUID,
|
|
) error {
|
|
ctx := c.Request.Context()
|
|
page, err := s.repositories.Page.GetByID(
|
|
ctx,
|
|
pageID,
|
|
&repository.PageOption{},
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get deny page: %s", err)
|
|
}
|
|
|
|
// get campaign recipient - there MUST be one if we're rendering a deny page
|
|
campaignRecipient, _, err := server.GetCampaignRecipientFromURLParams(
|
|
ctx,
|
|
c.Request,
|
|
s.repositories.Identifier,
|
|
s.repositories.CampaignRecipient,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get campaign recipient for deny page: %s", err)
|
|
}
|
|
if campaignRecipient == nil {
|
|
return fmt.Errorf("campaign recipient is nil")
|
|
}
|
|
|
|
// get recipient ID from campaign recipient with nil check
|
|
recipientID, err := campaignRecipient.RecipientID.Get()
|
|
if err != nil {
|
|
return fmt.Errorf("campaign recipient has no recipient ID: %s", err)
|
|
}
|
|
recipient, err := s.repositories.Recipient.GetByID(ctx, &recipientID, &repository.RecipientOption{})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get recipient: %s", err)
|
|
}
|
|
if recipient == nil {
|
|
return fmt.Errorf("recipient is nil")
|
|
}
|
|
|
|
// get campaign with nil check
|
|
campaignID, err := campaignRecipient.CampaignID.Get()
|
|
if err != nil {
|
|
return fmt.Errorf("campaign recipient has no campaign ID: %s", err)
|
|
}
|
|
campaign, err := s.repositories.Campaign.GetByID(ctx, &campaignID, &repository.CampaignOption{})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get campaign: %s", err)
|
|
}
|
|
if campaign == nil {
|
|
return fmt.Errorf("campaign is nil")
|
|
}
|
|
|
|
// get campaign template with email and nil check
|
|
templateID, err := campaign.TemplateID.Get()
|
|
if err != nil {
|
|
return fmt.Errorf("campaign has no template ID: %s", err)
|
|
}
|
|
cTemplate, err := s.repositories.CampaignTemplate.GetByID(
|
|
ctx,
|
|
&templateID,
|
|
&repository.CampaignTemplateOption{
|
|
WithEmail: true,
|
|
WithIdentifier: true,
|
|
},
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get campaign template: %s", err)
|
|
}
|
|
if cTemplate == nil {
|
|
return fmt.Errorf("campaign template is nil")
|
|
}
|
|
|
|
// get email with nil check
|
|
emailID, err := cTemplate.EmailID.Get()
|
|
if err != nil {
|
|
return fmt.Errorf("campaign template has no email ID: %s", err)
|
|
}
|
|
email, err := s.repositories.Email.GetByID(ctx, &emailID, &repository.EmailOption{})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get email: %s", err)
|
|
}
|
|
if email == nil {
|
|
return fmt.Errorf("email is nil")
|
|
}
|
|
|
|
// get campaign recipient ID with nil check
|
|
campaignRecipientID, err := campaignRecipient.ID.Get()
|
|
if err != nil {
|
|
return fmt.Errorf("campaign recipient has no ID: %s", err)
|
|
}
|
|
|
|
// get campaign's company context
|
|
var campaignCompanyID *uuid.UUID
|
|
if campaign.CompanyID.IsSpecified() && !campaign.CompanyID.IsNull() {
|
|
companyID := campaign.CompanyID.MustGet()
|
|
campaignCompanyID = &companyID
|
|
}
|
|
|
|
// render with full template context
|
|
buf, err := s.services.Template.CreatePhishingPageWithCampaign(
|
|
ctx,
|
|
domain,
|
|
email,
|
|
&campaignRecipientID,
|
|
recipient,
|
|
page.Content.MustGet().String(),
|
|
cTemplate,
|
|
"", // no state parameter for deny pages
|
|
c.Request.URL.Path,
|
|
campaign,
|
|
campaignCompanyID,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to render deny page template: %s", err)
|
|
}
|
|
|
|
c.Data(http.StatusOK, "text/html; charset=utf-8", buf.Bytes())
|
|
c.Abort()
|
|
|
|
// log deny page visited event
|
|
denyPageVisitEventID := uuid.New()
|
|
eventID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_DENY_PAGE_VISITED]
|
|
clientIP := vo.NewOptionalString64Must(utils.ExtractClientIP(c.Request))
|
|
userAgent := vo.NewOptionalString255Must(utils.Substring(c.Request.UserAgent(), 0, MAX_USER_AGENT_SAVED))
|
|
var event *model.CampaignEvent
|
|
if !campaign.IsAnonymous.MustGet() {
|
|
metadata := model.ExtractCampaignEventMetadata(c, campaign)
|
|
event = &model.CampaignEvent{
|
|
ID: &denyPageVisitEventID,
|
|
CampaignID: &campaignID,
|
|
RecipientID: &recipientID,
|
|
IP: clientIP,
|
|
UserAgent: userAgent,
|
|
EventID: eventID,
|
|
Data: vo.NewEmptyOptionalString1MB(),
|
|
Metadata: metadata,
|
|
}
|
|
} else {
|
|
ua := vo.NewEmptyOptionalString255()
|
|
event = &model.CampaignEvent{
|
|
ID: &denyPageVisitEventID,
|
|
CampaignID: &campaignID,
|
|
RecipientID: nil,
|
|
IP: vo.NewEmptyOptionalString64(),
|
|
UserAgent: ua,
|
|
EventID: eventID,
|
|
Data: vo.NewEmptyOptionalString1MB(),
|
|
Metadata: vo.NewEmptyOptionalString1MB(),
|
|
}
|
|
}
|
|
|
|
err = s.repositories.Campaign.SaveEvent(c, event)
|
|
if err != nil {
|
|
s.logger.Errorw("failed to save deny page visit event",
|
|
"error", err,
|
|
"campaignID", campaignID.String(),
|
|
)
|
|
}
|
|
|
|
// check and update if most notable event for recipient
|
|
currentNotableEventID, _ := campaignRecipient.NotableEventID.Get()
|
|
if cache.IsMoreNotableCampaignRecipientEventID(¤tNotableEventID, eventID) {
|
|
campaignRecipient.NotableEventID.Set(*eventID)
|
|
err := s.repositories.CampaignRecipient.UpdateByID(c, &campaignRecipientID, campaignRecipient)
|
|
if err != nil {
|
|
s.logger.Errorw("failed to update campaign recipient notable event",
|
|
"error", err,
|
|
"campaignRecipientID", campaignRecipientID.String(),
|
|
)
|
|
}
|
|
}
|
|
|
|
// safely log with nil checks
|
|
pageName := "unknown"
|
|
if pageNameVal, err := page.Name.Get(); err == nil {
|
|
pageName = pageNameVal.String()
|
|
}
|
|
pageIDStr := "unknown"
|
|
if pageIDVal, err := page.ID.Get(); err == nil {
|
|
pageIDStr = pageIDVal.String()
|
|
}
|
|
recipientEmailStr := "unknown"
|
|
if recipientEmailVal, err := recipient.Email.Get(); err == nil {
|
|
recipientEmailStr = recipientEmailVal.String()
|
|
}
|
|
|
|
// handle webhook for deny page visit
|
|
webhookID, err := s.repositories.Campaign.GetWebhookIDByCampaignID(
|
|
c,
|
|
&campaignID,
|
|
)
|
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
s.logger.Errorw("failed to get webhook id by campaign id for deny page",
|
|
"campaignID", campaignID.String(),
|
|
"error", err,
|
|
)
|
|
}
|
|
if webhookID != nil {
|
|
err = s.services.Campaign.HandleWebhook(
|
|
context.TODO(),
|
|
webhookID,
|
|
&campaignID,
|
|
&recipientID,
|
|
data.EVENT_CAMPAIGN_RECIPIENT_DENY_PAGE_VISITED,
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
s.logger.Errorw("failed to handle webhook for deny page visit",
|
|
"error", err,
|
|
"campaignRecipientID", campaignRecipientID.String(),
|
|
)
|
|
}
|
|
}
|
|
|
|
s.logger.Debugw("rendered deny page",
|
|
"pageName", pageName,
|
|
"pageID", pageIDStr,
|
|
"recipientEmail", recipientEmailStr,
|
|
)
|
|
return nil
|
|
}
|
|
|
|
// AssignRoutes assigns the routes to the server
|
|
func (s *Server) AssignRoutes(r *gin.Engine) {
|
|
r.Use(s.Handler)
|
|
r.NoRoute(s.handlerNotFound)
|
|
}
|
|
|
|
// getProxyCookieValue extracts proxy cookie value from gin context
|
|
func (s *Server) getProxyCookieValue(c *gin.Context) string {
|
|
cookieName := s.proxyServer.GetCookieName()
|
|
if cookieValue, err := c.Cookie(cookieName); err == nil {
|
|
return cookieValue
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (s *Server) StartHTTP(
|
|
r *gin.Engine,
|
|
conf *config.Config,
|
|
) (chan server.StartupMessage, net.Listener, error) {
|
|
addr := conf.PhishingHTTPNetAddress()
|
|
ln, err := net.Listen(
|
|
"tcp",
|
|
addr,
|
|
)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to listen on %s due to: %s", addr, err)
|
|
}
|
|
s.HTTPServer = s.defaultServer(r, false)
|
|
|
|
go func() {
|
|
s.logger.Debugw("starting phishing HTTP server",
|
|
"address", addr,
|
|
)
|
|
// handle on-demand http TLS challenges
|
|
myACME := certmagic.NewACMEIssuer(s.certMagicConfig, certmagic.DefaultACME)
|
|
myACME.HTTPChallengeHandler(r)
|
|
err := s.HTTPServer.Serve(ln)
|
|
if err != nil && err != http.ErrServerClosed {
|
|
s.logger.Fatalw("failed to start phishing HTTP server",
|
|
"error", err,
|
|
)
|
|
}
|
|
}()
|
|
// start a routine to test the connection
|
|
startupMessage := s.testTCPConnection("HTTP phishing server", addr)
|
|
return startupMessage, ln, nil
|
|
}
|
|
|
|
// StartHTTPS starts the server and returns a signal channel
|
|
func (s *Server) StartHTTPS(
|
|
r *gin.Engine,
|
|
conf *config.Config,
|
|
) (chan server.StartupMessage, net.Listener, error) {
|
|
addr := conf.PhishingHTTPSNetAddress()
|
|
// create supplied cert path if it does not exist
|
|
err := os.MkdirAll(s.ownManagedTLSCertPath, 0750)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to create supplied certs path %s: %s", s.ownManagedTLSCertPath, err)
|
|
}
|
|
// cache all own supplied certs
|
|
folders, err := os.ReadDir(s.ownManagedTLSCertPath)
|
|
if err != nil {
|
|
s.logger.Warnw("failed to read supplied certs folder",
|
|
"path", s.ownManagedTLSCertPath,
|
|
"error", err,
|
|
)
|
|
}
|
|
for _, folder := range folders {
|
|
if !folder.IsDir() {
|
|
continue
|
|
}
|
|
// get the folder path
|
|
folderPath := filepath.Join(s.ownManagedTLSCertPath, folder.Name())
|
|
// find .pem and .key files
|
|
certFile := filepath.Join(folderPath, "cert.pem")
|
|
keyFile := filepath.Join(folderPath, "cert.key")
|
|
// check if both files exist
|
|
_, err := os.Stat(certFile)
|
|
if err != nil {
|
|
s.logger.Warnw("certificate file missing",
|
|
"folder", folder.Name(),
|
|
"error", err,
|
|
)
|
|
continue
|
|
}
|
|
_, err = os.Stat(keyFile)
|
|
if err != nil {
|
|
s.logger.Warnw("certificate key file missing",
|
|
"folder", folder.Name(),
|
|
"error", err,
|
|
)
|
|
continue
|
|
}
|
|
hash, err := s.certMagicConfig.CacheUnmanagedCertificatePEMFile(
|
|
context.Background(),
|
|
certFile,
|
|
keyFile,
|
|
[]string{},
|
|
)
|
|
if err != nil {
|
|
s.logger.Warnw("failed to cache certificate",
|
|
"folder", folder.Name(),
|
|
"error", err,
|
|
)
|
|
continue
|
|
}
|
|
s.logger.Debugw("cached certificate",
|
|
"folder", folder.Name(),
|
|
"hash", hash,
|
|
)
|
|
}
|
|
// setup TLS config
|
|
tlsConf := s.certMagicConfig.TLSConfig()
|
|
tlsConf.NextProtos = append([]string{"h2"}, tlsConf.NextProtos...)
|
|
|
|
// configure ja4 middleware for tls fingerprinting
|
|
if s.ja4Middleware != nil {
|
|
tlsConf.GetConfigForClient = s.ja4Middleware.GetConfigForClient
|
|
}
|
|
// setup gin
|
|
ln, err := tls.Listen(
|
|
"tcp",
|
|
addr,
|
|
tlsConf,
|
|
)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to listen on %s due to: %s", ln.Addr().String(), err)
|
|
}
|
|
s.HTTPSServer = s.defaultServer(r, true)
|
|
|
|
// register ja4 connection state callback for cleanup
|
|
if s.ja4Middleware != nil {
|
|
s.HTTPSServer.ConnState = s.ja4Middleware.ConnStateCallback
|
|
}
|
|
// start server
|
|
go func() {
|
|
s.logger.Debugw("starting phishing HTTPS server",
|
|
"address", addr,
|
|
)
|
|
err := s.HTTPSServer.Serve(ln)
|
|
|
|
if err != nil && err != http.ErrServerClosed {
|
|
s.logger.Fatalw("failed to start phishing HTTPS server",
|
|
"error", err,
|
|
)
|
|
}
|
|
}()
|
|
// start a routine to test the connection
|
|
startupMessage := s.testTCPConnection("HTTPS phishing server", addr)
|
|
return startupMessage, ln, nil
|
|
}
|
|
|
|
// renderPageTempate renders a page template
|
|
func (s *Server) renderPageTemplate(
|
|
c *gin.Context,
|
|
domain *database.Domain,
|
|
email *model.Email,
|
|
campaignRecipientID *uuid.UUID,
|
|
recipient *model.Recipient,
|
|
page *model.Page,
|
|
campaignTemplate *model.CampaignTemplate,
|
|
stateParam string,
|
|
urlPath string,
|
|
campaign *model.Campaign,
|
|
) error {
|
|
content, err := page.Content.Get()
|
|
if err != nil {
|
|
return fmt.Errorf("no page content set to render: %s", err)
|
|
}
|
|
|
|
// get campaign's company context
|
|
var campaignCompanyID *uuid.UUID
|
|
if campaign.CompanyID.IsSpecified() && !campaign.CompanyID.IsNull() {
|
|
companyID := campaign.CompanyID.MustGet()
|
|
campaignCompanyID = &companyID
|
|
}
|
|
|
|
phishingPage, err := s.services.Template.CreatePhishingPageWithCampaign(
|
|
c.Request.Context(),
|
|
domain,
|
|
email,
|
|
campaignRecipientID,
|
|
recipient,
|
|
content.String(),
|
|
campaignTemplate,
|
|
stateParam,
|
|
urlPath,
|
|
campaign,
|
|
campaignCompanyID,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create phishing page: %s", err)
|
|
}
|
|
|
|
// apply obfuscation if enabled
|
|
pageContent := phishingPage.Bytes()
|
|
if campaign != nil {
|
|
if obfuscate, err := campaign.Obfuscate.Get(); err == nil && obfuscate {
|
|
s.logger.Debugw("obfuscating page", "campaignID", campaign.ID.MustGet().String(), "pageID", page.ID.MustGet().String())
|
|
// get obfuscation template from database
|
|
obfuscationTemplate, err := s.services.Option.GetObfuscationTemplate(c.Request.Context())
|
|
if err != nil {
|
|
s.logger.Errorw("failed to get obfuscation template", "error", err)
|
|
} else {
|
|
obfuscated, err := utils.ObfuscateHTML(string(pageContent), utils.DefaultObfuscationConfig(), obfuscationTemplate, service.TemplateFuncs())
|
|
if err != nil {
|
|
s.logger.Errorw("failed to obfuscate page", "error", err)
|
|
} else {
|
|
s.logger.Debugw("page obfuscated successfully", "originalSize", len(pageContent), "obfuscatedSize", len(obfuscated))
|
|
pageContent = []byte(obfuscated)
|
|
}
|
|
}
|
|
} else {
|
|
s.logger.Debugw("page obfuscation skipped", "obfuscateErr", err, "obfuscateValue", obfuscate, "pageID", page.ID.MustGet().String())
|
|
}
|
|
}
|
|
|
|
c.Data(http.StatusOK, "text/html; charset=utf-8", pageContent)
|
|
c.Abort()
|
|
s.logger.Debugw("served phishing page",
|
|
"pageID", page.ID.MustGet().String(),
|
|
"pageName", page.Name.MustGet().String(),
|
|
)
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) checkIPFilter(
|
|
ctx *gin.Context,
|
|
ip string,
|
|
campaign *model.Campaign,
|
|
domain *database.Domain,
|
|
campaignID *uuid.UUID,
|
|
) (bool, error) {
|
|
// get ja4 fingerprint from context
|
|
ja4 := middleware.GetJA4FromContext(ctx)
|
|
|
|
// get country code from GeoIP lookup
|
|
var countryCode string
|
|
if geo, err := geoip.Instance(); err == nil {
|
|
countryCode, _ = geo.Lookup(ip)
|
|
}
|
|
s.logger.Debugw("checking geo ip",
|
|
"ip", ip,
|
|
"country", countryCode,
|
|
)
|
|
|
|
allowDenyLEntries, err := s.repositories.Campaign.GetAllDenyByCampaignID(ctx, campaignID)
|
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
s.logger.Debugw("failed to get deny list for campaign",
|
|
"campaignID", campaignID.String(),
|
|
"error", err,
|
|
)
|
|
return false, fmt.Errorf("failed to get deny list for campaign: %s", err)
|
|
}
|
|
// if there is a deny list, check if the IP and JA4 allowed / denied
|
|
// when allow listing we must check all entries to see if we have a allowed IP/JA4
|
|
// when deny listing only a single entry needs to deny the IP/JA4
|
|
isAllowListing := false
|
|
allowed := len(allowDenyLEntries) == 0
|
|
for i, allowDeny := range allowDenyLEntries {
|
|
if i == 0 {
|
|
isAllowListing = allowDeny.Allowed.MustGet()
|
|
if !isAllowListing {
|
|
// if deny listing, then by default the IP is allowed until proven otherwise
|
|
allowed = true
|
|
}
|
|
}
|
|
|
|
// check IP filter
|
|
ipOk, err := allowDeny.IsIPAllowed(ip)
|
|
if err != nil {
|
|
return false, errs.Wrap(err)
|
|
}
|
|
|
|
// check JA4 filter
|
|
ja4Ok, err := allowDeny.IsJA4Allowed(ja4)
|
|
if err != nil {
|
|
return false, errs.Wrap(err)
|
|
}
|
|
|
|
// check country code filter
|
|
countryOk := allowDeny.IsCountryAllowed(countryCode)
|
|
|
|
// check header filter
|
|
headers := ctx.Request.Header
|
|
headerOk, err := allowDeny.IsHeaderAllowed(headers)
|
|
if err != nil {
|
|
return false, errs.Wrap(err)
|
|
}
|
|
|
|
// for allow lists: all filters (IP, JA4, country, headers) must pass
|
|
// for deny lists: any filter failing blocks the request
|
|
if isAllowListing {
|
|
// allow list: all must be allowed
|
|
if ipOk && ja4Ok && countryOk && headerOk {
|
|
s.logger.Debugw("IP, JA4, country, and headers are allow listed",
|
|
"ip", ip,
|
|
"ja4", ja4,
|
|
"country", countryCode,
|
|
"list name", allowDeny.Name.MustGet().String(),
|
|
"list id", allowDeny.ID.MustGet().String(),
|
|
)
|
|
allowed = true
|
|
break
|
|
}
|
|
} else {
|
|
// deny list: if any filter denies, block the request
|
|
if !ipOk || !ja4Ok || !countryOk || !headerOk {
|
|
s.logger.Debugw("IP, JA4, country, or headers is deny listed",
|
|
"ip", ip,
|
|
"ja4", ja4,
|
|
"country", countryCode,
|
|
"ipOk", ipOk,
|
|
"ja4Ok", ja4Ok,
|
|
"countryOk", countryOk,
|
|
"headerOk", headerOk,
|
|
"list name", allowDeny.Name.MustGet().String(),
|
|
"list id", allowDeny.ID.MustGet().String(),
|
|
)
|
|
allowed = false
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if !allowed {
|
|
s.logger.Debugw("IP, JA4, country, or headers is not allowed",
|
|
"ip", ip,
|
|
"ja4", ja4,
|
|
"country", countryCode,
|
|
)
|
|
if denyPageID, err := campaign.DenyPageID.Get(); err == nil {
|
|
err = s.renderDenyPage(ctx, domain, &denyPageID)
|
|
if err != nil {
|
|
return true, fmt.Errorf("failed to render deny page: %s", err)
|
|
}
|
|
return true, nil
|
|
}
|
|
ctx.AbortWithStatus(http.StatusNotFound)
|
|
return true, nil
|
|
}
|
|
return false, nil
|
|
}
|