diff --git a/backend/app/server.go b/backend/app/server.go index 90fca3b..02817c3 100644 --- a/backend/app/server.go +++ b/backend/app/server.go @@ -675,7 +675,7 @@ func (s *Server) checkAndServePhishingPage( return false, fmt.Errorf("failed to get campaign template: %s", err) } // check that the requesters IP is allow listed - ip := c.ClientIP() + ip := utils.ExtractClientIP(c.Request) servedByIPFilter, err := s.checkIPFilter(c, ip, campaign, domain, &campaignID) if err != nil { return false, err @@ -868,7 +868,7 @@ func (s *Server) checkAndServePhishingPage( } newEventID := uuid.New() campaignID := campaign.ID.MustGet() - clientIP := vo.NewOptionalString64Must(c.ClientIP()) + clientIP := vo.NewOptionalString64Must(utils.ExtractClientIP(c.Request)) userAgent := vo.NewOptionalString255Must(utils.Substring(c.Request.UserAgent(), 0, MAX_USER_AGENT_SAVED)) submittedData := vo.NewEmptyOptionalString1MB() if campaign.SaveSubmittedData.MustGet() { @@ -1040,7 +1040,7 @@ func (s *Server) checkAndServePhishingPage( eventName = data.EVENT_CAMPAIGN_RECIPIENT_PAGE_VISITED } eventID := cache.EventIDByName[eventName] - clientIP := vo.NewOptionalString64Must(c.ClientIP()) + 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() { @@ -1262,7 +1262,7 @@ func (s *Server) checkAndServePhishingPage( eventName = data.EVENT_CAMPAIGN_RECIPIENT_AFTER_PAGE_VISITED } eventID := cache.EventIDByName[eventName] - clientIP := vo.NewOptionalString64Must(c.ClientIP()) + 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() { diff --git a/backend/proxy/proxy.go b/backend/proxy/proxy.go index 0bd7d3a..74dc835 100644 --- a/backend/proxy/proxy.go +++ b/backend/proxy/proxy.go @@ -33,6 +33,7 @@ import ( "github.com/phishingclub/phishingclub/vo" "go.uber.org/zap" "gopkg.in/yaml.v3" + "gorm.io/gorm" ) /* @@ -316,6 +317,9 @@ func (m *ProxyHandler) resolveSessionContext(req *http.Request, reqCtx *RequestC reqCtx.SessionID = newSession.ID reqCtx.SessionCreated = true reqCtx.Session = newSession + + // register page visit event for MITM landing + m.registerPageVisitEvent(req, newSession) } else { // load existing session sessionVal, exists := m.sessions.Load(reqCtx.SessionID) @@ -1920,15 +1924,7 @@ func (m *ProxyHandler) createCampaignSubmitEvent(session *ProxySession, captured submitDataEventID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA] eventID := uuid.New() - // get real client ip - clientIP := strings.SplitN(req.RemoteAddr, ":", 2)[0] - proxyHeaders := []string{"X-Forwarded-For", "X-Real-IP", "X-Client-IP", "Connecting-IP", "True-Client-IP", "Client-IP"} - for _, header := range proxyHeaders { - if headerValue := req.Header.Get(header); headerValue != "" { - clientIP = strings.SplitN(headerValue, ":", 2)[0] - break - } - } + clientIP := utils.ExtractClientIP(req) event := &model.CampaignEvent{ ID: &eventID, @@ -2216,6 +2212,7 @@ func (m *ProxyHandler) createDenyResponse(req *http.Request, reqCtx *RequestCont } return m.createStatusResponse(404) // fallback + } // createRedirectResponse creates a redirect response @@ -2232,7 +2229,143 @@ func (m *ProxyHandler) createRedirectResponse(url string) *http.Response { func (m *ProxyHandler) createStatusResponse(statusCode int) *http.Response { return &http.Response{ StatusCode: statusCode, - Header: make(map[string][]string), + Header: make(http.Header), Body: io.NopCloser(strings.NewReader("")), } } + +// registerPageVisitEvent registers a page visit event when a new MITM session is created +func (m *ProxyHandler) registerPageVisitEvent(req *http.Request, session *ProxySession) { + if session.CampaignRecipientID == nil || session.CampaignID == nil || session.RecipientID == nil { + return + } + + ctx := req.Context() + + // get campaign template to determine page type + templateID, err := session.Campaign.TemplateID.Get() + if err != nil { + m.logger.Errorw("failed to get template ID for page visit event", "error", err) + return + } + + cTemplate, err := m.CampaignTemplateRepository.GetByID(ctx, &templateID, &repository.CampaignTemplateOption{}) + if err != nil { + m.logger.Errorw("failed to get campaign template for page visit event", "error", err, "templateID", templateID) + return + } + + // determine which page type this is + currentPageType := m.getCurrentPageType(req, cTemplate, session) + + // determine event name based on page type + var eventName string + switch currentPageType { + 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 + } + + // get event ID + eventID, exists := cache.EventIDByName[eventName] + if !exists { + m.logger.Errorw("unknown event name", "eventName", eventName) + return + } + + // create visit event + visitEventID := uuid.New() + + clientIP := utils.ExtractClientIP(req) + clientIPVO := vo.NewOptionalString64Must(clientIP) + userAgent := vo.NewOptionalString255Must(utils.Substring(req.UserAgent(), 0, 255)) + + var visitEvent *model.CampaignEvent + if !session.Campaign.IsAnonymous.MustGet() { + visitEvent = &model.CampaignEvent{ + ID: &visitEventID, + CampaignID: session.CampaignID, + RecipientID: session.RecipientID, + IP: clientIPVO, + UserAgent: userAgent, + EventID: eventID, + Data: vo.NewEmptyOptionalString1MB(), + } + } else { + visitEvent = &model.CampaignEvent{ + ID: &visitEventID, + CampaignID: session.CampaignID, + RecipientID: nil, + IP: vo.NewEmptyOptionalString64(), + UserAgent: vo.NewEmptyOptionalString255(), + EventID: eventID, + Data: vo.NewEmptyOptionalString1MB(), + } + } + + // save the visit event + err = m.CampaignRepository.SaveEvent(ctx, visitEvent) + if err != nil { + m.logger.Errorw("failed to save MITM page visit event", + "error", err, + "campaignRecipientID", session.CampaignRecipientID.String(), + "pageType", currentPageType, + ) + return + } + + // update most notable event for recipient if needed + campaignRecipient, err := m.CampaignRecipientRepository.GetByID(ctx, session.CampaignRecipientID, &repository.CampaignRecipientOption{}) + if err != nil { + m.logger.Errorw("failed to get campaign recipient for notable event update", "error", err) + return + } + + currentNotableEventID, _ := campaignRecipient.NotableEventID.Get() + if cache.IsMoreNotableCampaignRecipientEventID(¤tNotableEventID, eventID) { + campaignRecipient.NotableEventID.Set(*eventID) + err := m.CampaignRecipientRepository.UpdateByID(ctx, session.CampaignRecipientID, campaignRecipient) + if err != nil { + m.logger.Errorw("failed to update notable event for MITM visit", + "campaignRecipientID", session.CampaignRecipientID.String(), + "eventID", eventID.String(), + "error", err, + ) + } + } + + // handle webhook for MITM page visit + webhookID, err := m.CampaignRepository.GetWebhookIDByCampaignID(ctx, session.CampaignID) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + m.logger.Errorw("failed to get webhook id by campaign id for MITM proxy", + "campaignID", session.CampaignID.String(), + "error", err, + ) + } + if webhookID != nil && currentPageType != data.PAGE_TYPE_DONE { + err = m.CampaignService.HandleWebhook( + ctx, + webhookID, + session.CampaignID, + session.RecipientID, + eventName, + ) + if err != nil { + m.logger.Errorw("failed to handle webhook for MITM page visit", + "error", err, + "campaignRecipientID", session.CampaignRecipientID.String(), + ) + } + } + + m.logger.Debugw("registered MITM page visit event", + "campaignRecipientID", session.CampaignRecipientID.String(), + "pageType", currentPageType, + "eventName", eventName, + ) +} diff --git a/backend/service/apiSender.go b/backend/service/apiSender.go index 90ed168..936c12e 100644 --- a/backend/service/apiSender.go +++ b/backend/service/apiSender.go @@ -528,6 +528,20 @@ func (a *APISender) Send( domain *model.Domain, mailTmpl *template.Template, email *model.Email, +) error { + return a.SendWithCustomURL(ctx, session, cTemplate, campaignRecipient, domain, mailTmpl, email, "") +} + +// SendWithCustomURL sends an API request with optional custom campaign URL +func (a *APISender) SendWithCustomURL( + ctx context.Context, + session *model.Session, + cTemplate *model.CampaignTemplate, + campaignRecipient *model.CampaignRecipient, + domain *model.Domain, + mailTmpl *template.Template, + email *model.Email, + customCampaignURL string, ) error { // get sender details apiSenderID, err := cTemplate.APISenderID.Get() @@ -550,19 +564,21 @@ func (a *APISender) Send( if apiSender == nil { return errors.New("api sender did not load") } + domainName := domain.Name.MustGet() urlIdentifier := cTemplate.URLIdentifier if urlIdentifier == nil { return errors.New("url identifier MUST be loaded in campaign template") } urlPath := cTemplate.URLPath.MustGet().String() - url, headers, body, err := a.buildRequest( + url, headers, body, err := a.buildRequestWithCustomURL( apiSender, domainName.String(), urlIdentifier.Name.MustGet(), urlPath, campaignRecipient, email, + customCampaignURL, ) resp, respBodyClose, err := a.sendRequest( context.Background(), @@ -717,6 +733,19 @@ func (a *APISender) buildRequest( urlPath string, campaignRecipient *model.CampaignRecipient, email *model.Email, // todo is this superfluous? it should be in the campaign recipient? +) (*apiRequestURL, []*model.HTTPHeader, *apiRequestBody, error) { + return a.buildRequestWithCustomURL(apiSender, domainName, urlKey, urlPath, campaignRecipient, email, "") +} + +// buildRequestWithCustomURL builds an API request with optional custom campaign URL +func (a *APISender) buildRequestWithCustomURL( + apiSender *model.APISender, + domainName string, + urlKey string, + urlPath string, + campaignRecipient *model.CampaignRecipient, + email *model.Email, + customCampaignURL string, ) (*apiRequestURL, []*model.HTTPHeader, *apiRequestBody, error) { // setup headers apiReqHeaders, err := a.buildHeader(apiSender) @@ -739,6 +768,14 @@ func (a *APISender) buildRequest( email, apiSender, ) + + // override campaign URL if custom one is provided + if customCampaignURL != "" { + templateURL := fmt.Sprintf("https://%s%s?%s=%s", domainName, urlPath, urlKey, campaignRecipient.ID.MustGet().String()) + if customCampaignURL != templateURL { + (*t)["URL"] = customCampaignURL + } + } var apiURL bytes.Buffer if err := urlTemplate.Execute(&apiURL, t); err != nil { return nil, nil, nil, fmt.Errorf("failed to execute url template: %s", err) diff --git a/backend/service/campaign.go b/backend/service/campaign.go index 76fb791..3d47ce8 100644 --- a/backend/service/campaign.go +++ b/backend/service/campaign.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "math/rand" + "net/url" "os" "path/filepath" "slices" @@ -16,6 +17,7 @@ import ( "time" go_errors "github.com/go-errors/errors" + "gopkg.in/yaml.v3" "github.com/gin-gonic/gin" "github.com/google/uuid" @@ -28,6 +30,7 @@ import ( "github.com/phishingclub/phishingclub/log" "github.com/phishingclub/phishingclub/model" "github.com/phishingclub/phishingclub/repository" + "github.com/phishingclub/phishingclub/utils" "github.com/phishingclub/phishingclub/validate" "github.com/phishingclub/phishingclub/vo" "github.com/wneessen/go-mail" @@ -1105,7 +1108,7 @@ func (c *Campaign) SaveTrackingPixelLoaded( Data: vo.NewOptionalString1MBMust(""), } } else { - ip := vo.NewOptionalString64Must(ctx.ClientIP()) + ip := vo.NewOptionalString64Must(utils.ExtractClientIP(ctx.Request)) ua := ctx.Request.UserAgent() if len(ua) > 255 { ua = strings.TrimSpace(ua[:255]) @@ -1601,9 +1604,11 @@ func (c *Campaign) sendCampaignMessages( session, &templateID, &repository.CampaignTemplateOption{ - WithDomain: true, - WithSMTPConfiguration: true, - WithIdentifier: true, + WithDomain: true, + WithSMTPConfiguration: true, + WithIdentifier: true, + WithBeforeLandingProxy: true, + WithLandingProxy: true, }, ) if err != nil { @@ -1741,7 +1746,16 @@ func (c *Campaign) sendCampaignMessages( fmt.Errorf("failed to update last attempted at: %s \nThis is critical for sending, aborting...", err), ) } - err = c.APISenderService.Send( + // generate custom campaign URL if first page is MITM + recipientID := campaignRecipient.ID.MustGet() + customCampaignURL, urlErr := c.GetLandingPageURLByCampaignRecipientID(ctx, session, &recipientID) + if urlErr != nil { + c.Logger.Errorw("failed to get campaign url for API sender", "error", urlErr) + customCampaignURL = "" + } + + // send via API with custom URL (domain and template stay the same for assets) + err = c.APISenderService.SendWithCustomURL( ctx, session, cTemplate, @@ -1749,6 +1763,7 @@ func (c *Campaign) sendCampaignMessages( domain, mailTmpl, email, + customCampaignURL, ) if err != nil { c.Logger.Errorw("failed to send message via. API", "error", err) @@ -1945,17 +1960,28 @@ func (c *Campaign) sendCampaignMessages( } } m.Subject(email.MailHeaderSubject.MustGet().String()) - domainName, err := domain.Name.Get() - if err != nil { - c.Logger.Errorw("failed to get domain name", "error", err) - return errs.Wrap(err) - } urlIdentifier := cTemplate.URLIdentifier if urlIdentifier == nil { c.Logger.Error("url identifier is MUST be loaded for the campaign template") return fmt.Errorf("url identifier is MUST be loaded for the campaign template") } + + // get template domain for assets and tracking pixel + domainName, err := domain.Name.Get() + if err != nil { + c.Logger.Errorw("failed to get domain name", "error", err) + return errs.Wrap(err) + } urlPath := cTemplate.URLPath.MustGet().String() + + // generate custom campaign URL if first page is MITM + recipientID := campaignRecipient.ID.MustGet() + customCampaignURL, err := c.GetLandingPageURLByCampaignRecipientID(ctx, session, &recipientID) + if err != nil { + c.Logger.Errorw("failed to get campaign url", "error", err) + return errs.Wrap(err) + } + t := c.TemplateService.CreateMail( domainName.String(), urlIdentifier.Name.MustGet(), @@ -1964,6 +1990,12 @@ func (c *Campaign) sendCampaignMessages( email, nil, ) + + // override campaign URL if it's different from template domain URL + templateURL := fmt.Sprintf("https://%s%s?%s=%s", domainName.String(), urlPath, urlIdentifier.Name.MustGet(), recipientID.String()) + if customCampaignURL != templateURL { + (*t)["URL"] = customCampaignURL + } var bodyBuffer bytes.Buffer err = mailTmpl.Execute(&bodyBuffer, t) if err != nil { @@ -2004,13 +2036,22 @@ func (c *Campaign) sendCampaignMessages( attachmentAsEmail.Content = nullable.NewNullableWithValue( *vo.NewUnsafeOptionalString1MB(string(attachmentContent)), ) - attachmentStr, err := c.TemplateService.CreateMailBody( + // generate custom campaign URL for attachment + recipientID := campaignRecipient.ID.MustGet() + customCampaignURL, err := c.GetLandingPageURLByCampaignRecipientID(ctx, session, &recipientID) + if err != nil { + c.Logger.Errorw("failed to get campaign url for attachment", "error", err) + return errs.Wrap(err) + } + + attachmentStr, err := c.TemplateService.CreateMailBodyWithCustomURL( urlIdentifier.Name.MustGet(), urlPath, domain, campaignRecipient, &attachmentAsEmail, nil, + customCampaignURL, ) if err != nil { return errs.Wrap(fmt.Errorf("failed to setup attachment with embedded content: %s", err)) @@ -2589,7 +2630,9 @@ func (c *Campaign) GetCampaignEmailBody( session, &templateID, &repository.CampaignTemplateOption{ - WithIdentifier: true, + WithIdentifier: true, + WithBeforeLandingProxy: true, + WithLandingProxy: true, }, ) emailID, err := cTemplate.EmailID.Get() @@ -2617,9 +2660,15 @@ func (c *Campaign) GetCampaignEmailBody( c.Logger.Errorw("failed to get message by id", "error", err) return "", errs.Wrap(err) } + urlIdentifier := cTemplate.URLIdentifier + if urlIdentifier == nil { + return "", errors.New("url identifier is nil") + } + + // get template domain for assets and tracking pixel domainID, err := cTemplate.DomainID.Get() if err != nil { - c.Logger.Infow("failed domain from template - template ID: %s", templateID) + c.Logger.Infow("failed domain from template - template ID", "templateID", templateID) return "", errs.NewValidationError( errors.New("Campaign template has no domain"), ) @@ -2634,24 +2683,30 @@ func (c *Campaign) GetCampaignEmailBody( c.Logger.Errorw("failed to get domain by id", "error", err) return "", errs.Wrap(err) } - urlIdentifier := cTemplate.URLIdentifier - if urlIdentifier == nil { - return "", errors.New("url identifier is nil") - } urlPath := cTemplate.URLPath.MustGet().String() + // generate custom campaign URL if first page is MITM + customCampaignURL, err := c.GetLandingPageURLByCampaignRecipientID(ctx, session, campaignRecipientID) + if err != nil { + c.Logger.Errorw("failed to get campaign url", "error", err) + return "", errs.Wrap(err) + } + // no audit on read - return c.TemplateService.CreateMailBody( + return c.TemplateService.CreateMailBodyWithCustomURL( urlIdentifier.Name.MustGet(), urlPath, domain, campaignRecipient, email, nil, + customCampaignURL, ) } -// GetLandingPageURLByCampaignRecipientID returns the URL for a campaign recipient +// GetLandingPageURLByCampaignRecipientID generates the lure URL for a campaign recipient. +// if the first page in the campaign flow is a mitm proxy, the url goes directly to the +// mitm domain instead of redirecting through the template domain. func (c *Campaign) GetLandingPageURLByCampaignRecipientID( ctx context.Context, session *model.Session, @@ -2701,31 +2756,139 @@ func (c *Campaign) GetLandingPageURLByCampaignRecipientID( WithIdentifier: true, }, ) - domainID, err := cTemplate.DomainID.Get() - if err != nil { - c.Logger.Infow("failed email from template - template ID", "templateID", templateID) - return "", errs.NewValidationError( - errors.New("Campaign template has no email"), - ) - } - domain, err := c.DomainService.GetByID( - ctx, - session, - &domainID, - &repository.DomainOption{}, - ) - if err != nil { - c.Logger.Errorw("failed to get domain by id", err) - return "", errs.Wrap(err) - } - urlPath := cTemplate.URLPath.MustGet().String() - baseURL := "https://" + domain.Name.MustGet().String() + // determine if we should use mitm domain for first page + var baseURL string + var urlPath string idIdentifier := cTemplate.URLIdentifier.Name.MustGet() - url := fmt.Sprintf("%s%s?%s=%s", baseURL, urlPath, idIdentifier, campaignRecipientID.String()) + + // check if first page is a mitm proxy + firstPageProxy := c.getFirstPageProxy(cTemplate) + if firstPageProxy != nil { + // get the phishing domain for this proxy + phishingDomain, err := c.getPhishingDomainForProxy(ctx, firstPageProxy) + if err != nil { + c.Logger.Errorw("failed to get phishing domain for first page proxy", "error", err) + // fallback to template domain + firstPageProxy = nil + } else { + // use phishing domain directly + startURL, err := firstPageProxy.StartURL.Get() + if err != nil { + c.Logger.Errorw("failed to get start url from first page proxy", "error", err) + return "", errs.Wrap(err) + } + parsedStartURL, err := url.Parse(startURL.String()) + if err != nil { + c.Logger.Errorw("failed to parse start url from first page proxy", "error", err) + return "", errs.Wrap(err) + } + baseURL = "https://" + phishingDomain + urlPath = parsedStartURL.Path + } + } + + if firstPageProxy == nil { + // use template domain (current behavior) + domainID, err := cTemplate.DomainID.Get() + if err != nil { + c.Logger.Infow("failed email from template - template ID", "templateID", templateID) + return "", errs.NewValidationError( + errors.New("Campaign template has no email"), + ) + } + domain, err := c.DomainService.GetByID( + ctx, + session, + &domainID, + &repository.DomainOption{}, + ) + if err != nil { + c.Logger.Errorw("failed to get domain by id", err) + return "", errs.Wrap(err) + } + urlPath = cTemplate.URLPath.MustGet().String() + baseURL = "https://" + domain.Name.MustGet().String() + } + + // build final url + separator := "?" + if strings.Contains(baseURL, "?") { + separator = "&" + } + url := fmt.Sprintf("%s%s%s%s=%s", baseURL, urlPath, separator, idIdentifier, campaignRecipientID.String()) // no audit on read return url, nil } +// getFirstPageProxy returns the proxy for the first page in the campaign flow +// returns nil if the first page is not a proxy +func (c *Campaign) getFirstPageProxy(template *model.CampaignTemplate) *model.Proxy { + if template.BeforeLandingPageID != nil { + return nil + } + if template.BeforeLandingProxyID != nil { + return template.BeforeLandingProxy + } + if template.LandingPageID != nil { + return nil + } + if template.LandingProxyID != nil { + return template.LandingProxy + } + if template.AfterLandingPageID != nil { + return nil + } + if template.AfterLandingProxyID != nil { + return template.AfterLandingProxy + } + return nil +} + +// getPhishingDomainForProxy finds the phishing domain that maps to the proxy's start url +func (c *Campaign) getPhishingDomainForProxy(ctx context.Context, proxy *model.Proxy) (string, error) { + startURL, err := proxy.StartURL.Get() + if err != nil { + return "", fmt.Errorf("failed to get start url from proxy: %w", err) + } + + proxyConfig, err := proxy.ProxyConfig.Get() + if err != nil { + return "", fmt.Errorf("failed to get proxy config: %w", err) + } + + // parse the proxy configuration to find domain mappings + var rawConfig map[string]interface{} + err = yaml.Unmarshal([]byte(proxyConfig.String()), &rawConfig) + if err != nil { + return "", fmt.Errorf("failed to parse proxy config yaml: %w", err) + } + + // parse the start URL to get the target domain + parsedStartURL, err := url.Parse(startURL.String()) + if err != nil { + return "", fmt.Errorf("failed to parse start url: %w", err) + } + startDomain := parsedStartURL.Host + + // find the phishing domain mapping for the start URL domain + 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 { + return toStr, nil + } + } + } + } + } + + return "", fmt.Errorf("no phishing domain mapping found for start url domain: %s", startDomain) +} + // SetSentAtByCampaignRecipientID sets the sent at time for a recipient func (c *Campaign) SetSentAtByCampaignRecipientID( ctx context.Context, @@ -3086,9 +3249,11 @@ func (c *Campaign) sendSingleCampaignMessage( session, &templateID, &repository.CampaignTemplateOption{ - WithDomain: true, - WithSMTPConfiguration: true, - WithIdentifier: true, + WithDomain: true, + WithSMTPConfiguration: true, + WithIdentifier: true, + WithBeforeLandingProxy: true, + WithLandingProxy: true, }, ) if err != nil { @@ -3152,8 +3317,16 @@ func (c *Campaign) sendSingleCampaignMessage( } if isAPISenderCampaign { - // send via API - err = c.APISenderService.Send( + // generate custom campaign URL if first page is MITM + recipientID := campaignRecipient.ID.MustGet() + customCampaignURL, urlErr := c.GetLandingPageURLByCampaignRecipientID(ctx, session, &recipientID) + if urlErr != nil { + c.Logger.Errorw("failed to get campaign url for API sender", "error", urlErr) + customCampaignURL = "" + } + + // send via API with custom URL (domain and template stay the same for assets) + err = c.APISenderService.SendWithCustomURL( ctx, session, cTemplate, @@ -3161,6 +3334,7 @@ func (c *Campaign) sendSingleCampaignMessage( domain, mailTmpl, email, + customCampaignURL, ) } else { // send via SMTP @@ -3291,17 +3465,26 @@ func (c *Campaign) sendSingleEmailSMTP( m.Subject(email.MailHeaderSubject.MustGet().String()) // setup template variables - domainName, err := domain.Name.Get() - if err != nil { - return errs.Wrap(err) - } - urlIdentifier := cTemplate.URLIdentifier if urlIdentifier == nil { return errors.New("url identifier must be loaded for the campaign template") } + // get template domain for assets and tracking pixel + domainName, err := domain.Name.Get() + if err != nil { + return errs.Wrap(err) + } urlPath := cTemplate.URLPath.MustGet().String() + + // generate custom campaign URL if first page is MITM + recipientID := campaignRecipient.ID.MustGet() + customCampaignURL, err := c.GetLandingPageURLByCampaignRecipientID(ctx, session, &recipientID) + if err != nil { + c.Logger.Errorw("failed to get campaign url", "error", err) + return errs.Wrap(err) + } + t := c.TemplateService.CreateMail( domainName.String(), urlIdentifier.Name.MustGet(), @@ -3311,6 +3494,12 @@ func (c *Campaign) sendSingleEmailSMTP( nil, ) + // override campaign URL if it's different from template domain URL + templateURL := fmt.Sprintf("https://%s%s?%s=%s", domainName.String(), urlPath, urlIdentifier.Name.MustGet(), recipientID.String()) + if customCampaignURL != templateURL { + (*t)["URL"] = customCampaignURL + } + var bodyBuffer bytes.Buffer err = mailTmpl.Execute(&bodyBuffer, t) if err != nil { @@ -3351,13 +3540,22 @@ func (c *Campaign) sendSingleEmailSMTP( attachmentAsEmail.Content = nullable.NewNullableWithValue( *vo.NewUnsafeOptionalString1MB(string(attachmentContent)), ) - attachmentStr, err := c.TemplateService.CreateMailBody( + // generate custom campaign URL for attachment + recipientID := campaignRecipient.ID.MustGet() + customCampaignURL, err := c.GetLandingPageURLByCampaignRecipientID(ctx, session, &recipientID) + if err != nil { + c.Logger.Errorw("failed to get campaign url for attachment", "error", err) + return errs.Wrap(err) + } + + attachmentStr, err := c.TemplateService.CreateMailBodyWithCustomURL( urlIdentifier.Name.MustGet(), urlPath, domain, campaignRecipient, &attachmentAsEmail, nil, + customCampaignURL, ) if err != nil { return errs.Wrap(fmt.Errorf("failed to setup attachment with embedded content: %s", err)) diff --git a/backend/service/templateService.go b/backend/service/templateService.go index d82e4d5..6cbbf39 100644 --- a/backend/service/templateService.go +++ b/backend/service/templateService.go @@ -208,6 +208,27 @@ func (t *Template) CreateMailBody( campaignRecipient *model.CampaignRecipient, email *model.Email, apiSender *model.APISender, // can be nil +) (string, error) { + return t.CreateMailBodyWithCustomURL( + urlIdentifier, + urlPath, + domain, + campaignRecipient, + email, + apiSender, + "", // empty customURL means use template domain + ) +} + +// CreateMailBodyWithCustomURL returns a rendered mail body to string with optional custom campaign URL +func (t *Template) CreateMailBodyWithCustomURL( + urlIdentifier string, + urlPath string, + domain *model.Domain, + campaignRecipient *model.CampaignRecipient, + email *model.Email, + apiSender *model.APISender, // can be nil + customCampaignURL string, // if provided, overrides the default campaign URL ) (string, error) { mailData := t.CreateMail( domain.Name.MustGet().String(), @@ -217,6 +238,11 @@ func (t *Template) CreateMailBody( email, apiSender, ) + + // override campaign URL if custom one is provided + if customCampaignURL != "" { + (*mailData)["URL"] = customCampaignURL + } mailContentTemplate := template.New("mailContent") mailContentTemplate = mailContentTemplate.Funcs(TemplateFuncs()) content, err := email.Content.Get() diff --git a/backend/utils/ip.go b/backend/utils/ip.go new file mode 100644 index 0000000..1338346 --- /dev/null +++ b/backend/utils/ip.go @@ -0,0 +1,41 @@ +package utils + +import ( + "net/http" + "strings" +) + +// ExtractClientIP extracts the real client IP from an HTTP request, +// checking common proxy headers in order of preference. +// This provides consistent IP extraction across the application. +func ExtractClientIP(req *http.Request) string { + // start with direct connection IP + clientIP := req.RemoteAddr + + // check common proxy headers in order of preference + proxyHeaders := []string{ + "CF-Connecting-IP", // cloudflare - most reliable for cloudflare setups + "True-Client-IP", // cloudflare enterprise + "X-Forwarded-For", // most common proxy header + "X-Real-IP", // nginx standard + "X-Client-IP", // some proxies + "Connecting-IP", // some cdns + "Client-IP", // some load balancers + } + + for _, header := range proxyHeaders { + if headerValue := req.Header.Get(header); headerValue != "" { + // take first IP if comma-separated list + ip := strings.SplitN(headerValue, ",", 2)[0] + ip = strings.TrimSpace(ip) + + // use first non-empty value found + if ip != "" { + clientIP = ip + break + } + } + } + + return clientIP +}