diff --git a/backend/app/server.go b/backend/app/server.go index 02817c3..7e3dfb2 100644 --- a/backend/app/server.go +++ b/backend/app/server.go @@ -84,6 +84,7 @@ func NewServer( repositories.Proxy, repositories.Identifier, services.Campaign, + services.Template, cookieName, ) @@ -697,6 +698,12 @@ func (s *Server) checkAndServePhishingPage( 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 @@ -737,19 +744,34 @@ func (s *Server) checkAndServePhishingPage( 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(), - ) + /* + 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 { - if beforePageID != nil || beforeProxyID != nil { + // 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", @@ -785,8 +807,72 @@ func (s *Server) checkAndServePhishingPage( // if there is a page type, then we use that } else { switch pageTypeQuery { - // this is not relevant - already taken care of, ignore it + // 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 { @@ -1030,6 +1116,8 @@ func (s *Server) checkAndServePhishingPage( 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: @@ -1236,6 +1324,7 @@ func (s *Server) checkAndServePhishingPage( return true, fmt.Errorf("failed to encrypt next page type: %s", err) } urlPath := cTemplate.URLPath.MustGet().String() + err = s.renderPageTemplate( c, domain, @@ -1246,6 +1335,7 @@ func (s *Server) checkAndServePhishingPage( cTemplate, encryptedParam, urlPath, + campaign, ) if err != nil { return true, fmt.Errorf("failed to render phishing page: %s", err) @@ -1254,6 +1344,8 @@ func (s *Server) checkAndServePhishingPage( 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: @@ -1543,12 +1635,13 @@ func (s *Server) renderPageTemplate( 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) } - phishingPage, err := s.services.Template.CreatePhishingPage( + phishingPage, err := s.services.Template.CreatePhishingPageWithCampaign( domain, email, campaignRecipientID, @@ -1557,6 +1650,7 @@ func (s *Server) renderPageTemplate( campaignTemplate, stateParam, urlPath, + campaign, ) if err != nil { return fmt.Errorf("failed to create phishing page: %s", err) diff --git a/backend/cache/local.go b/backend/cache/local.go index 4b5bf09..ce75cc2 100644 --- a/backend/cache/local.go +++ b/backend/cache/local.go @@ -37,16 +37,17 @@ func IsUpdateAvailable() bool { // readonly var CampaignEventPriority = map[string]int{ // campaign recipient events - data.EVENT_CAMPAIGN_RECIPIENT_REPORTED: 90, - data.EVENT_CAMPAIGN_RECIPIENT_CANCELLED: 80, - data.EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA: 70, - data.EVENT_CAMPAIGN_RECIPIENT_AFTER_PAGE_VISITED: 60, - data.EVENT_CAMPAIGN_RECIPIENT_BEFORE_PAGE_VISITED: 40, - data.EVENT_CAMPAIGN_RECIPIENT_PAGE_VISITED: 50, - data.EVENT_CAMPAIGN_RECIPIENT_MESSAGE_READ: 30, - data.EVENT_CAMPAIGN_RECIPIENT_MESSAGE_FAILED: 20, - data.EVENT_CAMPAIGN_RECIPIENT_MESSAGE_SENT: 20, - data.EVENT_CAMPAIGN_RECIPIENT_SCHEDULED: 10, + data.EVENT_CAMPAIGN_RECIPIENT_REPORTED: 90, + data.EVENT_CAMPAIGN_RECIPIENT_CANCELLED: 80, + data.EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA: 70, + data.EVENT_CAMPAIGN_RECIPIENT_AFTER_PAGE_VISITED: 60, + data.EVENT_CAMPAIGN_RECIPIENT_BEFORE_PAGE_VISITED: 40, + data.EVENT_CAMPAIGN_RECIPIENT_PAGE_VISITED: 50, + data.EVENT_CAMPAIGN_RECIPIENT_EVASION_PAGE_VISITED: 35, + data.EVENT_CAMPAIGN_RECIPIENT_MESSAGE_READ: 30, + data.EVENT_CAMPAIGN_RECIPIENT_MESSAGE_FAILED: 20, + data.EVENT_CAMPAIGN_RECIPIENT_MESSAGE_SENT: 20, + data.EVENT_CAMPAIGN_RECIPIENT_SCHEDULED: 10, // campaign events data.EVENT_CAMPAIGN_CLOSED: 30, data.EVENT_CAMPAIGN_ACTIVE: 20, diff --git a/backend/controller/campaign.go b/backend/controller/campaign.go index 3e9fb8f..b6f8b79 100644 --- a/backend/controller/campaign.go +++ b/backend/controller/campaign.go @@ -178,6 +178,7 @@ func (c *Campaign) GetByID(g *gin.Context) { WithRecipientGroups: true, WithAllowDeny: true, WithDenyPage: true, + WithEvasionPage: true, }, ) // handle responses @@ -210,6 +211,7 @@ func (c *Campaign) GetByName(g *gin.Context) { WithRecipientGroups: true, WithAllowDeny: true, WithDenyPage: true, + WithEvasionPage: true, }, ) // handle responses diff --git a/backend/data/events.go b/backend/data/events.go index e04950a..579bc8d 100644 --- a/backend/data/events.go +++ b/backend/data/events.go @@ -6,16 +6,17 @@ const ( EVENT_CAMPAIGN_SELF_MANAGED = "campaign_self_managed" EVENT_CAMPAIGN_CLOSED = "campaign_closed" - EVENT_CAMPAIGN_RECIPIENT_SCHEDULED = "campaign_recipient_scheduled" - EVENT_CAMPAIGN_RECIPIENT_MESSAGE_SENT = "campaign_recipient_message_sent" - EVENT_CAMPAIGN_RECIPIENT_MESSAGE_FAILED = "campaign_recipient_message_failed" - EVENT_CAMPAIGN_RECIPIENT_MESSAGE_READ = "campaign_recipient_message_read" - EVENT_CAMPAIGN_RECIPIENT_BEFORE_PAGE_VISITED = "campaign_recipient_before_page_visited" - EVENT_CAMPAIGN_RECIPIENT_PAGE_VISITED = "campaign_recipient_page_visited" - EVENT_CAMPAIGN_RECIPIENT_AFTER_PAGE_VISITED = "campaign_recipient_after_page_visited" - EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA = "campaign_recipient_submitted_data" - EVENT_CAMPAIGN_RECIPIENT_REPORTED = "campaign_recipient_reported" - EVENT_CAMPAIGN_RECIPIENT_CANCELLED = "campaign_recipient_cancelled" + EVENT_CAMPAIGN_RECIPIENT_SCHEDULED = "campaign_recipient_scheduled" + EVENT_CAMPAIGN_RECIPIENT_MESSAGE_SENT = "campaign_recipient_message_sent" + EVENT_CAMPAIGN_RECIPIENT_MESSAGE_FAILED = "campaign_recipient_message_failed" + EVENT_CAMPAIGN_RECIPIENT_MESSAGE_READ = "campaign_recipient_message_read" + EVENT_CAMPAIGN_RECIPIENT_EVASION_PAGE_VISITED = "campaign_recipient_evasion_page_visited" + EVENT_CAMPAIGN_RECIPIENT_BEFORE_PAGE_VISITED = "campaign_recipient_before_page_visited" + EVENT_CAMPAIGN_RECIPIENT_PAGE_VISITED = "campaign_recipient_page_visited" + EVENT_CAMPAIGN_RECIPIENT_AFTER_PAGE_VISITED = "campaign_recipient_after_page_visited" + EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA = "campaign_recipient_submitted_data" + EVENT_CAMPAIGN_RECIPIENT_REPORTED = "campaign_recipient_reported" + EVENT_CAMPAIGN_RECIPIENT_CANCELLED = "campaign_recipient_cancelled" ) var Events = []string{ @@ -29,6 +30,7 @@ var Events = []string{ EVENT_CAMPAIGN_RECIPIENT_MESSAGE_SENT, EVENT_CAMPAIGN_RECIPIENT_MESSAGE_FAILED, EVENT_CAMPAIGN_RECIPIENT_MESSAGE_READ, + EVENT_CAMPAIGN_RECIPIENT_EVASION_PAGE_VISITED, EVENT_CAMPAIGN_RECIPIENT_BEFORE_PAGE_VISITED, EVENT_CAMPAIGN_RECIPIENT_PAGE_VISITED, EVENT_CAMPAIGN_RECIPIENT_AFTER_PAGE_VISITED, diff --git a/backend/data/pageType.go b/backend/data/pageType.go index 9c3a549..5eff017 100644 --- a/backend/data/pageType.go +++ b/backend/data/pageType.go @@ -1,6 +1,7 @@ package data const ( + PAGE_TYPE_EVASION = "evasion" PAGE_TYPE_BEFORE = "before" PAGE_TYPE_LANDING = "landing" PAGE_TYPE_AFTER = "after" diff --git a/backend/database/campaign.go b/backend/database/campaign.go index 18a901c..1187838 100644 --- a/backend/database/campaign.go +++ b/backend/database/campaign.go @@ -48,10 +48,12 @@ type Campaign struct { CampaignTemplate *CampaignTemplate // can has one - CompanyID *uuid.UUID `gorm:"index;type:uuid;index;uniqueIndex:idx_campaigns_unique_name_and_company_id;"` - Company *Company - DenyPageID *uuid.UUID `gorm:"type:uuid;index;"` - DenyPage *Page `gorm:"foreignKey:DenyPageID;references:ID"` + CompanyID *uuid.UUID `gorm:"index;type:uuid;index;uniqueIndex:idx_campaigns_unique_name_and_company_id;"` + Company *Company + DenyPageID *uuid.UUID `gorm:"type:uuid;index;"` + DenyPage *Page `gorm:"foreignKey:DenyPageID;references:ID"` + EvasionPageID *uuid.UUID `gorm:"type:uuid;index;"` + EvasionPage *Page `gorm:"foreignKey:EvasionPageID;references:ID"` // NotableEventID notable event for this campaign NotableEvent *Event `gorm:"foreignKey:NotableEventID;references:ID"` NotableEventID *uuid.UUID `gorm:"type:uuid;index"` diff --git a/backend/model/campaign.go b/backend/model/campaign.go index 2aaa7df..09d53d9 100644 --- a/backend/model/campaign.go +++ b/backend/model/campaign.go @@ -46,6 +46,8 @@ type Campaign struct { AllowDenyIDs nullable.Nullable[[]*uuid.UUID] `json:"allowDenyIDs,omitempty"` DenyPageID nullable.Nullable[uuid.UUID] `json:"denyPageID,omitempty"` DenyPage *Page `json:"denyPage"` + EvasionPageID nullable.Nullable[uuid.UUID] `json:"evasionPageID,omitempty"` + EvasionPage *Page `json:"evasionPage"` WebhookID nullable.Nullable[uuid.UUID] `json:"webhookID"` // must not be set by a user @@ -85,7 +87,7 @@ func (c *Campaign) Validate() error { if len(c.RecipientGroupIDs.MustGet()) == 0 { return validate.WrapErrorWithField(errors.New("must have at least one recipient group"), "RecipientGroupIDs") } - if err := c.ValidateDenyPage(); err != nil { + if err := c.ValidateEvasionAndDenyPages(); err != nil { return err } @@ -208,13 +210,22 @@ func (c *Campaign) ValidateNoSendTimesSet() error { return nil } -// ValidateDenyPage checks that a deny page is set -func (c *Campaign) ValidateDenyPage() error { - if c.DenyPageID.IsSpecified() && !c.DenyPageID.IsNull() { - if c.AllowDenyIDs.IsSpecified() && (c.AllowDenyIDs.IsNull() || len(c.AllowDenyIDs.MustGet()) == 0) { - return validate.WrapErrorWithField(errors.New("requires a allow deny IDs to be set"), "denyPage") +// ValidateEvasionAndDenyPages checks evasion page and deny page requirements +func (c *Campaign) ValidateEvasionAndDenyPages() error { + // if evasion page is set, deny page must also be set + if c.EvasionPageID.IsSpecified() && !c.EvasionPageID.IsNull() { + if !c.DenyPageID.IsSpecified() || c.DenyPageID.IsNull() { + return validate.WrapErrorWithField(errors.New("evasion page requires a deny page to be set"), "evasionPage") } } + + // if allow/deny IDs are set (IP filtering), deny page must be set + if c.AllowDenyIDs.IsSpecified() && !c.AllowDenyIDs.IsNull() && len(c.AllowDenyIDs.MustGet()) > 0 { + if !c.DenyPageID.IsSpecified() || c.DenyPageID.IsNull() { + return validate.WrapErrorWithField(errors.New("IP filtering requires a deny page to be set"), "denyPage") + } + } + return nil } @@ -320,15 +331,17 @@ func (c *Campaign) ToDBMap() map[string]any { m["company_id"] = c.CompanyID.MustGet() } } - allowDenyIsSet := c.AllowDenyIDs.IsSpecified() && !c.AllowDenyIDs.IsNull() && len(c.AllowDenyIDs.MustGet()) > 0 - if allowDenyIsSet { + if c.DenyPageID.IsSpecified() { + m["deny_page_id"] = nil if v, err := c.DenyPageID.Get(); err == nil { m["deny_page_id"] = v.String() - } else { - m["deny_page_id"] = nil } - } else { - m["deny_page_id"] = nil + } + if c.EvasionPageID.IsSpecified() { + m["evasion_page_id"] = nil + if v, err := c.EvasionPageID.Get(); err == nil { + m["evasion_page_id"] = v.String() + } } if c.WebhookID.IsSpecified() { m["webhook_id"] = nil diff --git a/backend/proxy/proxy.go b/backend/proxy/proxy.go index 0601120..2d30cca 100644 --- a/backend/proxy/proxy.go +++ b/backend/proxy/proxy.go @@ -9,6 +9,7 @@ import ( "encoding/json" "fmt" "io" + "net" "net/http" "net/http/cookiejar" "net/url" @@ -113,6 +114,7 @@ type ProxyHandler struct { ProxyRepository *repository.Proxy IdentifierRepository *repository.Identifier CampaignService *service.Campaign + TemplateService *service.Template cookieName string } @@ -126,6 +128,7 @@ func NewProxyHandler( proxyRepository *repository.Proxy, identifierRepository *repository.Identifier, campaignService *service.Campaign, + templateService *service.Template, cookieName string, ) *ProxyHandler { return &ProxyHandler{ @@ -139,6 +142,7 @@ func NewProxyHandler( ProxyRepository: proxyRepository, IdentifierRepository: identifierRepository, CampaignService: campaignService, + TemplateService: templateService, cookieName: cookieName, } } @@ -153,6 +157,22 @@ func (m *ProxyHandler) HandleHTTPRequest(w http.ResponseWriter, req *http.Reques return err } + // check ip filtering if we have a campaign recipient + if reqCtx.CampaignRecipientID != nil { + blocked, resp := m.checkIPFilter(req, reqCtx) + if blocked { + if resp != nil { + return m.writeResponse(w, resp) + } + // if no deny page configured, return 404 + return m.writeResponse(w, &http.Response{ + StatusCode: http.StatusNotFound, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("")), + }) + } + } + // create http client client, err := m.createHTTPClient(req, reqCtx.ProxyConfig) if err != nil { @@ -259,9 +279,20 @@ func (m *ProxyHandler) processRequestWithContext(req *http.Request, reqCtx *Requ reqURL := req.URL.String() createSession := reqCtx.CampaignRecipientID != nil - // handle existing session cleanup if this is an initial request + // check for evasion/deny pages BEFORE session resolution for initial requests if createSession { + // cleanup any existing session first m.cleanupExistingSession(reqCtx.CampaignRecipientID, reqURL) + + // check if this is a deny request from evasion page + if resp := m.checkAndServeDenyPage(req, reqCtx); resp != nil { + return req, resp + } + + // check if we should serve an evasion page first + if resp := m.checkAndServeEvasionPage(req, reqCtx); resp != nil { + return req, resp + } } else { // check for existing session sessionCookie, err := req.Cookie(m.cookieName) @@ -1951,6 +1982,14 @@ func (m *ProxyHandler) getCurrentPageType(req *http.Request, template *model.Cam func (m *ProxyHandler) getNextPageType(currentPageType string, template *model.CampaignTemplate) string { switch currentPageType { + case data.PAGE_TYPE_EVASION: + if _, errPage := template.BeforeLandingPageID.Get(); errPage == nil { + return data.PAGE_TYPE_BEFORE + } + if _, errProxy := template.BeforeLandingProxyID.Get(); errProxy == nil { + return data.PAGE_TYPE_BEFORE + } + return data.PAGE_TYPE_LANDING case data.PAGE_TYPE_BEFORE: return data.PAGE_TYPE_LANDING case data.PAGE_TYPE_LANDING: @@ -2417,7 +2456,7 @@ func (m *ProxyHandler) writeResponse(w http.ResponseWriter, resp *http.Response) // check for nil response if resp == nil { m.logger.Errorw("response is nil in writeResponse") - w.WriteHeader(500) + w.WriteHeader(http.StatusInternalServerError) return errors.New("response is nil") } @@ -2435,6 +2474,7 @@ func (m *ProxyHandler) writeResponse(w http.ResponseWriter, resp *http.Response) // copy body if resp.Body != nil { + defer resp.Body.Close() _, err := io.Copy(w, resp.Body) return err } @@ -2606,6 +2646,8 @@ func (m *ProxyHandler) registerPageVisitEvent(req *http.Request, session *ProxyS // determine event name based on page type var eventName string 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: @@ -2714,3 +2756,391 @@ func (m *ProxyHandler) registerPageVisitEvent(req *http.Request, session *ProxyS "eventName", eventName, ) } + +// checkAndServeEvasionPage checks if an evasion page should be served and returns the response if so +func (m *ProxyHandler) checkAndServeEvasionPage(req *http.Request, reqCtx *RequestContext) *http.Response { + // get campaign info first + campaign, _, _, err := m.getCampaignInfo(req.Context(), reqCtx.CampaignRecipientID) + if err != nil { + return nil + } + + // check if there's an evasion page configured + evasionPageID, err := campaign.EvasionPageID.Get() + if err != nil { + return nil + } + + // get the campaign template + templateID, err := campaign.TemplateID.Get() + if err != nil { + return nil + } + + ctx := req.Context() + cTemplate, err := m.CampaignTemplateRepository.GetByID(ctx, &templateID, &repository.CampaignTemplateOption{ + WithIdentifier: true, + }) + if err != nil { + return nil + } + + // check if this is an initial request (no state parameter) + // we already know we have a campaign recipient ID from reqCtx + if cTemplate.StateIdentifier != nil { + stateParamKey := cTemplate.StateIdentifier.Name.MustGet() + encryptedParam := req.URL.Query().Get(stateParamKey) + + // if there is a state parameter, this is not initial request + if encryptedParam != "" { + return nil + } + } + + // this is initial request with campaign recipient ID and no state parameter, serve evasion page + return m.serveEvasionPageResponseDirect(req, reqCtx, &evasionPageID, campaign, cTemplate) +} + +// checkAndServeDenyPage checks if a deny page should be served and returns the response if so +func (m *ProxyHandler) checkAndServeDenyPage(req *http.Request, reqCtx *RequestContext) *http.Response { + // get campaign info first + campaign, _, _, err := m.getCampaignInfo(req.Context(), reqCtx.CampaignRecipientID) + if err != nil { + return nil + } + + // get the campaign template + templateID, err := campaign.TemplateID.Get() + if err != nil { + return nil + } + + ctx := req.Context() + cTemplate, err := m.CampaignTemplateRepository.GetByID(ctx, &templateID, &repository.CampaignTemplateOption{ + WithIdentifier: true, + }) + if err != nil { + return nil + } + + // check if state parameter indicates deny + if cTemplate.StateIdentifier != nil { + stateParamKey := cTemplate.StateIdentifier.Name.MustGet() + encryptedParam := req.URL.Query().Get(stateParamKey) + if encryptedParam != "" { + campaignID, err := campaign.ID.Get() + if err != nil { + return nil + } + secret := utils.UUIDToSecret(&campaignID) + if decrypted, err := utils.Decrypt(encryptedParam, secret); err == nil { + if decrypted == "deny" { + return m.serveDenyPageResponseDirect(req, reqCtx, campaign) + } + } + } + } + + return nil +} + +func (m *ProxyHandler) serveEvasionPageResponseDirect(req *http.Request, reqCtx *RequestContext, evasionPageID *uuid.UUID, campaign *model.Campaign, cTemplate *model.CampaignTemplate) *http.Response { + ctx := req.Context() + evasionPage, err := m.PageRepository.GetByID(ctx, evasionPageID, &repository.PageOption{}) + if err != nil { + m.logger.Errorw("failed to get evasion page", "error", err, "pageID", evasionPageID) + return nil + } + + campaignID, err := campaign.ID.Get() + if err != nil { + return nil + } + + // determine next page type after evasion + var nextPageType string + if _, err := cTemplate.BeforeLandingPageID.Get(); err == nil { + nextPageType = data.PAGE_TYPE_BEFORE + } else if _, err := cTemplate.BeforeLandingProxyID.Get(); err == nil { + nextPageType = data.PAGE_TYPE_BEFORE + } else { + nextPageType = data.PAGE_TYPE_LANDING + } + + htmlContent, err := m.renderEvasionPageTemplate(req, reqCtx, evasionPage, campaign, cTemplate, nextPageType) + if err != nil { + m.logger.Errorw("failed to render evasion page template", "error", err) + return nil + } + + // create HTTP response + resp := &http.Response{ + StatusCode: 200, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(htmlContent)), + } + + resp.Header.Set("Content-Type", "text/html; charset=utf-8") + resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(htmlContent))) + resp.Header.Set("Cache-Control", "no-cache, no-store, must-revalidate") + + // register evasion page visit event + m.registerEvasionPageVisitEventDirect(req, reqCtx.CampaignRecipientID, &campaignID, campaign) + + return resp +} + +func (m *ProxyHandler) serveDenyPageResponseDirect(req *http.Request, reqCtx *RequestContext, campaign *model.Campaign) *http.Response { + denyPageID, err := campaign.DenyPageID.Get() + if err != nil { + // if no deny page configured, return 403 + resp := &http.Response{ + StatusCode: 403, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("Access denied")), + } + resp.Header.Set("Content-Type", "text/plain") + resp.Header.Set("Content-Length", "13") + return resp + } + + ctx := req.Context() + denyPage, err := m.PageRepository.GetByID(ctx, &denyPageID, &repository.PageOption{}) + if err != nil { + m.logger.Errorw("failed to get deny page", "error", err, "pageID", denyPageID) + return nil + } + + // render the deny page + htmlContent, err := denyPage.Content.Get() + if err != nil { + return nil + } + + content := htmlContent.String() + + // create HTTP response + resp := &http.Response{ + StatusCode: 200, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(content)), + } + + resp.Header.Set("Content-Type", "text/html; charset=utf-8") + resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(content))) + resp.Header.Set("Cache-Control", "no-cache, no-store, must-revalidate") + + return resp +} + +func (m *ProxyHandler) renderEvasionPageTemplate(req *http.Request, reqCtx *RequestContext, page *model.Page, campaign *model.Campaign, cTemplate *model.CampaignTemplate, nextPageType string) (string, error) { + // get recipient + ctx := req.Context() + cRecipient, err := m.CampaignRecipientRepository.GetByID(ctx, reqCtx.CampaignRecipientID, &repository.CampaignRecipientOption{}) + if err != nil { + return "", fmt.Errorf("failed to get campaign recipient: %w", err) + } + recipientID, err := cRecipient.RecipientID.Get() + if err != nil { + return "", fmt.Errorf("failed to get recipient ID: %w", err) + } + + // get recipient details + recipientRepo := repository.Recipient{DB: m.CampaignRecipientRepository.DB} + recipient, err := recipientRepo.GetByID(ctx, &recipientID, &repository.RecipientOption{}) + if err != nil { + return "", fmt.Errorf("failed to get recipient: %w", err) + } + + // get email for template + templateID, err := campaign.TemplateID.Get() + if err != nil { + return "", fmt.Errorf("failed to get template ID: %w", err) + } + + cTemplateWithEmail, err := m.CampaignTemplateRepository.GetByID(ctx, &templateID, &repository.CampaignTemplateOption{ + WithEmail: true, + }) + if err != nil { + return "", fmt.Errorf("failed to get campaign template with email: %w", err) + } + + emailID := cTemplateWithEmail.EmailID.MustGet() + emailRepo := repository.Email{DB: m.CampaignRepository.DB} + email, err := emailRepo.GetByID(ctx, &emailID, &repository.EmailOption{}) + if err != nil { + return "", fmt.Errorf("failed to get email: %w", err) + } + + // get domain + hostVO, err := vo.NewString255(req.Host) + if err != nil { + return "", fmt.Errorf("failed to create host VO: %w", err) + } + domain, err := m.DomainRepository.GetByName(ctx, hostVO, &repository.DomainOption{}) + if err != nil { + return "", fmt.Errorf("failed to get domain: %w", err) + } + + // create encrypted state parameter for next page + campaignID := campaign.ID.MustGet() + encryptedNextState, err := utils.Encrypt(nextPageType, utils.UUIDToSecret(&campaignID)) + if err != nil { + return "", fmt.Errorf("failed to encrypt next state: %w", err) + } + + // get page content + htmlContent, err := page.Content.Get() + if err != nil { + return "", fmt.Errorf("failed to get evasion page HTML content: %w", err) + } + + // convert model.Domain to database.Domain + var proxyID *uuid.UUID + if id, err := domain.ProxyID.Get(); err == nil { + proxyID = &id + } + + dbDomain := &database.Domain{ + ID: domain.ID.MustGet(), + Name: domain.Name.MustGet().String(), + Type: domain.Type.MustGet().String(), + ProxyID: proxyID, + ProxyTargetDomain: domain.ProxyTargetDomain.MustGet().String(), + HostWebsite: domain.HostWebsite.MustGet(), + RedirectURL: domain.RedirectURL.MustGet().String(), + } + + // use template service to render the page + urlPath := req.URL.Path + buf, err := m.TemplateService.CreatePhishingPageWithCampaign( + dbDomain, + email, + reqCtx.CampaignRecipientID, + recipient, + htmlContent.String(), + cTemplate, + encryptedNextState, + urlPath, + campaign, + ) + if err != nil { + return "", fmt.Errorf("failed to render evasion page template: %w", err) + } + + return buf.String(), nil +} + +func (m *ProxyHandler) registerEvasionPageVisitEventDirect(req *http.Request, campaignRecipientID *uuid.UUID, campaignID *uuid.UUID, campaign *model.Campaign) { + // get recipient from campaign recipient + ctx := req.Context() + cRecipient, err := m.CampaignRecipientRepository.GetByID(ctx, campaignRecipientID, &repository.CampaignRecipientOption{}) + if err != nil { + return + } + recipientID, err := cRecipient.RecipientID.Get() + if err != nil { + return + } + + eventID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_EVASION_PAGE_VISITED] + newEventID := uuid.New() + clientIP := vo.NewOptionalString64Must(utils.ExtractClientIP(req)) + userAgent := vo.NewOptionalString255Must(utils.Substring(req.UserAgent(), 0, 1000)) // MAX_USER_AGENT_SAVED equivalent + + var event *model.CampaignEvent + if !campaign.IsAnonymous.MustGet() { + event = &model.CampaignEvent{ + ID: &newEventID, + CampaignID: campaignID, + RecipientID: &recipientID, + IP: clientIP, + UserAgent: userAgent, + EventID: eventID, + Data: vo.NewEmptyOptionalString1MB(), + } + } else { + ua := vo.NewEmptyOptionalString255() + event = &model.CampaignEvent{ + ID: &newEventID, + CampaignID: campaignID, + RecipientID: nil, + IP: vo.NewEmptyOptionalString64(), + UserAgent: ua, + EventID: eventID, + Data: vo.NewEmptyOptionalString1MB(), + } + } + + err = m.CampaignRepository.SaveEvent(req.Context(), event) + if err != nil { + m.logger.Errorw("failed to save evasion page visit event", "error", err) + } +} + +// checkIPFilter checks if the IP is allowed for proxy requests +// returns (blocked, response) where blocked=true means the IP should be blocked +func (m *ProxyHandler) checkIPFilter(req *http.Request, reqCtx *RequestContext) (bool, *http.Response) { + // get campaign info + campaign, _, campaignID, err := m.getCampaignInfo(req.Context(), reqCtx.CampaignRecipientID) + if err != nil { + return false, nil // allow if we can't get campaign info + } + + // extract client IP and strip port if present using net.SplitHostPort for IPv6 safety + ip := utils.ExtractClientIP(req) + if host, _, err := net.SplitHostPort(ip); err == nil { + ip = host + } + + // get allow/deny list entries + allowDenyEntries, err := m.CampaignRepository.GetAllDenyByCampaignID(req.Context(), campaignID) + if err != nil && err != gorm.ErrRecordNotFound { + return false, nil // allow if we can't get the list + } + + // if there are no entries, allow access + if len(allowDenyEntries) == 0 { + return false, nil + } + + // check IP against allow/deny lists + isAllowListing := false + allowed := false // for allow lists, default is deny + + for i, allowDeny := range allowDenyEntries { + if i == 0 { + isAllowListing = allowDeny.Allowed.MustGet() + if !isAllowListing { + // if deny listing, then by default the IP is allowed until proven otherwise + allowed = true + } + } + + ok, err := allowDeny.IsIPAllowed(ip) + if err != nil { + continue + } + + if isAllowListing && ok { + allowed = true + break + } else if !isAllowListing && !ok { + allowed = false + break + } + } + + if !allowed { + // try to serve deny page + if _, err := campaign.DenyPageID.Get(); err == nil { + resp := m.serveDenyPageResponseDirect(req, reqCtx, campaign) + return true, resp + } + + // if no deny page, block with 404 + return true, nil + } + + return false, nil +} diff --git a/backend/repository/campaign.go b/backend/repository/campaign.go index 5b1c567..42c0045 100644 --- a/backend/repository/campaign.go +++ b/backend/repository/campaign.go @@ -59,6 +59,7 @@ type CampaignOption struct { WithRecipientGroupCount bool WithAllowDeny bool WithDenyPage bool + WithEvasionPage bool IncludeTestCampaigns bool } @@ -105,6 +106,9 @@ func (r *Campaign) load(db *gorm.DB, options *CampaignOption) *gorm.DB { if options.WithDenyPage { db = db.Preload("DenyPage") } + if options.WithEvasionPage { + db = db.Preload("EvasionPage") + } return db } @@ -1500,6 +1504,21 @@ func ToCampaign(row *database.Campaign) (*model.Campaign, error) { denyPageID.SetNull() } + var evasionPage *model.Page + if row.EvasionPage != nil { + ep, err := ToPage(row.EvasionPage) + if err != nil { + return nil, errs.Wrap(err) + } + evasionPage = ep + } + evasionPageID := nullable.NewNullNullable[uuid.UUID]() + if row.EvasionPageID != nil { + evasionPageID.Set(*row.EvasionPageID) + } else { + evasionPageID.SetNull() + } + constraintWeekDays := nullable.NewNullNullable[vo.CampaignWeekDays]() if row.ConstraintWeekDays != nil { weekDays, err := vo.NewCampaignWeekDays(*row.ConstraintWeekDays) @@ -1573,6 +1592,8 @@ func ToCampaign(row *database.Campaign) (*model.Campaign, error) { AllowDeny: allowDeny, DenyPage: denyPage, DenyPageID: denyPageID, + EvasionPage: evasionPage, + EvasionPageID: evasionPageID, WebhookID: webhookID, NotableEventID: notableEventID, NotableEventName: notableEventName, diff --git a/backend/service/campaign.go b/backend/service/campaign.go index 0e4d258..54d4c72 100644 --- a/backend/service/campaign.go +++ b/backend/service/campaign.go @@ -1336,6 +1336,15 @@ func (c *Campaign) UpdateByID( } } + + // handle evasion page ID + if incoming.EvasionPageID.IsSpecified() { + if incoming.EvasionPageID.IsNull() { + current.EvasionPageID.SetNull() + } else { + current.EvasionPageID.Set(incoming.EvasionPageID.MustGet()) + } + } // validate and update if err := current.Validate(); err != nil { return errs.Wrap(err) diff --git a/backend/service/templateService.go b/backend/service/templateService.go index 6cbbf39..e4e5c40 100644 --- a/backend/service/templateService.go +++ b/backend/service/templateService.go @@ -188,7 +188,7 @@ func (t *Template) ApplyPageMock(content string) (*bytes.Buffer, error) { URLIdentifier: urlIdentifier, StateIdentifier: stateIdentifier, } - return t.CreatePhishingPage( + return t.CreatePhishingPageWithCampaign( domain, email, &campaignRecipientID, @@ -197,6 +197,7 @@ func (t *Template) ApplyPageMock(content string) (*bytes.Buffer, error) { campaignTemplate, "stateParam", "urlPath", + nil, // no campaign for mock, so DenyURL will be empty string ) } @@ -279,6 +280,21 @@ func (t *Template) CreatePhishingPage( campaignTemplate *model.CampaignTemplate, stateParam string, urlPath string, +) (*bytes.Buffer, error) { + return t.CreatePhishingPageWithCampaign(domain, email, campaignRecipientID, recipient, contentToRender, campaignTemplate, stateParam, urlPath, nil) +} + +// CreatePhishingPageWithCampaign creates a new phishing page with optional campaign for deny URL support +func (t *Template) CreatePhishingPageWithCampaign( + domain *database.Domain, + email *model.Email, + campaignRecipientID *uuid.UUID, + recipient *model.Recipient, + contentToRender string, + campaignTemplate *model.CampaignTemplate, + stateParam string, + urlPath string, + campaign *model.Campaign, ) (*bytes.Buffer, error) { w := bytes.NewBuffer([]byte{}) id := campaignRecipientID.String() @@ -289,6 +305,20 @@ func (t *Template) CreatePhishingPage( urlIdentifier := campaignTemplate.URLIdentifier.Name.MustGet() stateIdentifier := campaignTemplate.StateIdentifier.Name.MustGet() url := fmt.Sprintf("%s?%s=%s&%s=%s", baseURL, urlIdentifier, id, stateIdentifier, stateParam) + + // create deny URL if campaign and deny page exist + denyURL := "" + if campaign != nil { + if _, err := campaign.DenyPageID.Get(); err == nil { + // create a special state param that indicates deny page should be served + campaignID := campaign.ID.MustGet() + denyStateParam, encErr := utils.Encrypt("deny", utils.UUIDToSecret(&campaignID)) + if encErr == nil { + denyURL = fmt.Sprintf("%s?%s=%s&%s=%s", baseURL, urlIdentifier, id, stateIdentifier, denyStateParam) + } + } + } + tmpl, err := template.New("page"). Funcs(TemplateFuncs()). Parse(contentToRender) @@ -296,10 +326,11 @@ func (t *Template) CreatePhishingPage( if err != nil { return w, fmt.Errorf("failed to parse page template: %s", err) } - data := t.newTemplateDataMap( + data := t.newTemplateDataMapWithDenyURL( id, baseURL, url, + denyURL, recipient, "", // trackingPixelPath "", // trackingPixelMarkup @@ -406,6 +437,27 @@ func (t *Template) newTemplateDataMap( return &m } +// newTemplateDataMapWithDenyURL creates a new data map for templates with deny URL for evasion pages +func (t *Template) newTemplateDataMapWithDenyURL( + id string, + baseURL string, + url string, + denyURL string, + recipient *model.Recipient, + trackingPixelPath string, + trackingPixelMarkup string, + email *model.Email, + apiSender *model.APISender, +) *map[string]any { + // get the standard template data + data := t.newTemplateDataMap(id, baseURL, url, recipient, trackingPixelPath, trackingPixelMarkup, email, apiSender) + + // add the deny URL for evasion pages + (*data)["DenyURL"] = denyURL + + return data +} + // TemplateFuncs returns template functions for templates func TemplateFuncs() template.FuncMap { return template.FuncMap{ diff --git a/backend/utils/ip.go b/backend/utils/ip.go index 1338346..048d9d8 100644 --- a/backend/utils/ip.go +++ b/backend/utils/ip.go @@ -1,6 +1,7 @@ package utils import ( + "net" "net/http" "strings" ) @@ -36,6 +37,10 @@ func ExtractClientIP(req *http.Request) string { } } } + // strip port + if host, _, err := net.SplitHostPort(clientIP); err == nil { + clientIP = host + } return clientIP } diff --git a/frontend/src/lib/api/api.js b/frontend/src/lib/api/api.js index 7585798..35b9e6a 100644 --- a/frontend/src/lib/api/api.js +++ b/frontend/src/lib/api/api.js @@ -508,6 +508,7 @@ export class API { * @param {string[]} campaign.recipientGroupIDs []uuid * @param {string[]} campaign.allowDenyIDs []uuid * @param {string} campaign.denyPageID uuid + * @param {string} campaign.evasionPageID uuid * @param {string} campaign.webhookID uuid * @param {Array} [campaign.constraintWeekDays] * @param {string} [campaign.constraintStartTime] @@ -530,6 +531,7 @@ export class API { recipientGroupIDs, allowDenyIDs, denyPageID, + evasionPageID, webhookID, constraintWeekDays, constraintStartTime, @@ -551,6 +553,7 @@ export class API { recipientGroupIDs, allowDenyIDs, denyPageID, + evasionPageID, webhookID, constraintWeekDays, constraintStartTime, @@ -787,6 +790,7 @@ export class API { * @param {string[]} [campaign.recipientGroupIDs] []uuid * @param {string[]} campaign.allowDenyIDs []uuid * @param {string} campaign.denyPageID uuid + * @param {string} campaign.evasionPageID uuid * @param {string} campaign.webhookID uuid * @returns {Promise} */ @@ -810,6 +814,7 @@ export class API { recipientGroupIDs, allowDenyIDs, denyPageID, + evasionPageID, webhookID }) => { return await postJSON(this.getPath(`/campaign/${id}`), { @@ -831,6 +836,7 @@ export class API { recipientGroupIDs, allowDenyIDs, denyPageID, + evasionPageID, webhookID }); }, diff --git a/frontend/src/lib/components/EventTimeline.svelte b/frontend/src/lib/components/EventTimeline.svelte index 7f01cec..17c21cb 100644 --- a/frontend/src/lib/components/EventTimeline.svelte +++ b/frontend/src/lib/components/EventTimeline.svelte @@ -41,6 +41,7 @@ campaign_recipient_message_sent: true, campaign_recipient_message_failed: true, campaign_recipient_message_read: true, + campaign_recipient_evasion_page_visited: true, campaign_recipient_before_page_visited: true, campaign_recipient_page_visited: true, campaign_recipient_after_page_visited: true, diff --git a/frontend/src/lib/utils/events.js b/frontend/src/lib/utils/events.js index 24235c7..318d2f1 100644 --- a/frontend/src/lib/utils/events.js +++ b/frontend/src/lib/utils/events.js @@ -10,6 +10,11 @@ const eventNameMap = { color: 'bg-failed-sending' }, campaign_recipient_message_read: { name: 'Message Read', priority: 40, color: 'bg-message-read' }, + campaign_recipient_evasion_page_visited: { + name: 'Evasion Page Visited', + priority: 45, + color: 'bg-evasion-page-visited' + }, campaign_recipient_before_page_visited: { name: 'Before Page Visited', priority: 50, diff --git a/frontend/src/routes/campaign/+page.svelte b/frontend/src/routes/campaign/+page.svelte index b429f45..a4b1285 100644 --- a/frontend/src/routes/campaign/+page.svelte +++ b/frontend/src/routes/campaign/+page.svelte @@ -94,13 +94,13 @@ icon: '' }, { - label: 'Add allow-list', + label: 'Allow', value: 'allow', icon: '' }, { - label: 'Add deny-list', + label: 'Deny', value: 'deny', icon: '' } @@ -148,6 +148,23 @@ let scheduleType = 'basic'; let allowDenyType = 'none'; let allAllowDeny = []; + let showSecurityOptions = false; + + // reactive statement to enable security options when deny page is set + $: if (formValues.denyPageValue && formValues.denyPageValue.trim() !== '') { + showSecurityOptions = true; + } + + // reactive statement to clear evasion page and IP filtering when deny page is cleared + $: if (!formValues.denyPageValue) { + if (formValues.evasionPageValue) { + formValues.evasionPageValue = null; + } + if (allowDenyType !== 'none') { + allowDenyType = 'none'; + formValues.allowDeny = []; + } + } const defaultSendField = 'Email'; const defaultSendOrder = 'Random'; @@ -228,6 +245,7 @@ recipientGroups: [], allowDeny: [], denyPageValue: null, + evasionPageValue: null, constraintWeekDays: [], contraintStartTime: null, contraintEndTime: null, @@ -406,6 +424,15 @@ }; const validateMisc = () => { + // validate that deny page is selected if evasion page or IP filtering is used + if (formValues.evasionPageValue && !formValues.denyPageValue) { + modalError = 'Deny page is required when using an evasion page'; + return false; + } + if (allowDenyType !== 'none' && !formValues.denyPageValue) { + modalError = 'Deny page is required when using IP filtering'; + return false; + } return checkCurrentStepValidity(); }; @@ -573,6 +600,7 @@ recipientGroupIDs: recipientGroupIDs, allowDenyIDs: allowDenyIDs, denyPageID: denyPageMap.byValueOrNull(formValues.denyPageValue), + evasionPageID: denyPageMap.byValueOrNull(formValues.evasionPageValue), constraintWeekDays: weekDaysAvailableToBinary(formValues.constraintWeekDays), constraintStartTime: contraintStartTimeUTC, constraintEndTime: contraintEndTimeUTC, @@ -636,6 +664,7 @@ recipientGroupIDs: recipientGroupIDs, allowDenyIDs: allowDenyIDs, denyPageID: denyPageMap.byValueOrNull(formValues.denyPageValue), + evasionPageID: denyPageMap.byValueOrNull(formValues.evasionPageValue), webhookID: webhookMap.byValueOrNull(formValues.webhookValue) }); @@ -752,6 +781,7 @@ recipientGroups: [], allowDeny: [], denyPageValue: null, + evasionPageValue: null, constraintWeekDays: [], contraintStartTime: null, contraintEndTime: null, @@ -779,7 +809,6 @@ const onChangeAllowDenyType = () => { formValues.allowDeny = []; - formValues.denyPageValue = null; setAllowDenyType(allAllowDeny); }; @@ -881,6 +910,10 @@ if (campaign.denyPage) { formValues.denyPageValue = campaign.denyPage.name; } + + if (campaign.evasionPage) { + formValues.evasionPageValue = campaign.evasionPage.name; + } }; /* @@ -1462,36 +1495,6 @@
- { - onChangeAllowDenyType(); - }} - /> - - {#if allowDenyType !== 'none'} - Lists - - { - formValues.denyPageValue = page; - }} - options={Array.from(denyPageMap.values())}>Blocked Access Page - {/if} -
- -
Webhook
+ +
+ { + if (!showSecurityOptions) { + formValues.denyPageValue = ''; + formValues.evasionPageValue = ''; + allowDenyType = 'none'; + formValues.allowDeny = []; + } + }} + /> +
+ + {#if showSecurityOptions} +
+ { + formValues.denyPageValue = page; + }} + options={Array.from(denyPageMap.values())}>Deny Page +
+ +
+ {#if formValues.denyPageValue} + { + formValues.evasionPageValue = page; + }} + options={Array.from(denyPageMap.values())} + >Anti-bot / Evasion Page + {:else} +
+

+ Anti-bot / Evasion Page
+ You must select a deny page first to use evasion pages. +

+
+ {/if} +
+ +
+ {#if formValues.denyPageValue} + { + onChangeAllowDenyType(); + }} + /> + + {#if allowDenyType !== 'none'} +
+ Lists +
+ {/if} + {:else} +
+

+ IP filtering mode
+ You must select a deny page first to use IP filtering. +

+
+ {/if} +
+ {/if} {:else if currentStep === 5} @@ -1748,6 +1846,13 @@ > {/if} + {#if formValues.evasionPageValue} + Evasion Page: + {formValues.evasionPageValue} + {/if} + {#if formValues.anonymizeAt} Anonymize at: diff --git a/frontend/src/routes/campaign/[id]/+page.svelte b/frontend/src/routes/campaign/[id]/+page.svelte index 8c7e94a..d5e7099 100644 --- a/frontend/src/routes/campaign/[id]/+page.svelte +++ b/frontend/src/routes/campaign/[id]/+page.svelte @@ -216,6 +216,8 @@ campaign.saveSubmittedData = t.saveSubmittedData; campaign.isAnonymous = t.isAnonymous; campaign.allowDeny = t.allowDeny; + campaign.denyPage = t.denyPage; + campaign.evasionPage = t.evasionPage; campaign.webhookID = t.webhookID; campaign.template = templateMap.byKey(t.templateID); campaign.recipientGroups = t.recipientGroupIDs.map((id) => recipientGroupMap.byKey(id)); @@ -1286,6 +1288,24 @@ {/if} + Deny Page: + + {#if campaign.denyPage} + {campaign.denyPage.name} + {:else} + None + {/if} + + + Evasion Page: + + {#if campaign.evasionPage} + {campaign.evasionPage.name} + {:else} + None + {/if} + + Webhook: {#if campaign.webhookID}