mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-08-17 08:00:34 +02:00
add variable support for specific landing pages
Signed-off-by: RonniSkansing <rskansing@gmail.com>
This commit is contained in:
@@ -513,6 +513,7 @@ func (a *APISender) SendTest(
|
||||
nil, // no company context for test
|
||||
uuid.Nil, // no campaign context for test
|
||||
uuid.Nil, // no recipient context for test
|
||||
nil, // no campaign flow context for test
|
||||
)
|
||||
if err != nil {
|
||||
a.Logger.Errorw("failed to build test request", "error", err)
|
||||
@@ -644,6 +645,7 @@ func (a *APISender) SendWithCustomURL(
|
||||
companyID,
|
||||
campaignID,
|
||||
recipientID,
|
||||
NewFlowContext(cTemplate, campaignID),
|
||||
)
|
||||
if err != nil {
|
||||
a.Logger.Errorw("failed to build api sender request", "error", err)
|
||||
@@ -833,8 +835,9 @@ func (a *APISender) buildRequest(
|
||||
companyID *uuid.UUID,
|
||||
campaignID uuid.UUID,
|
||||
recipientID uuid.UUID,
|
||||
flowCtx *FlowContext, // nil when no campaign flow context is available
|
||||
) (*apiRequestURL, []*model.HTTPHeader, *apiRequestBody, error) {
|
||||
return a.buildRequestWithCustomURL(ctx, apiSender, domainName, urlKey, urlPath, campaignRecipient, email, "", "", companyID, campaignID, recipientID)
|
||||
return a.buildRequestWithCustomURL(ctx, apiSender, domainName, urlKey, urlPath, campaignRecipient, email, "", "", companyID, campaignID, recipientID, flowCtx)
|
||||
}
|
||||
|
||||
// buildRequestWithCustomURL builds an API request with optional custom campaign URL
|
||||
@@ -851,6 +854,7 @@ func (a *APISender) buildRequestWithCustomURL(
|
||||
companyID *uuid.UUID,
|
||||
campaignID uuid.UUID,
|
||||
recipientID uuid.UUID,
|
||||
flowCtx *FlowContext, // nil when no campaign flow context is available
|
||||
) (*apiRequestURL, []*model.HTTPHeader, *apiRequestBody, error) {
|
||||
// create template data first so it can be used in headers, url, and body
|
||||
t := a.TemplateService.CreateMail(
|
||||
@@ -862,6 +866,7 @@ func (a *APISender) buildRequestWithCustomURL(
|
||||
email,
|
||||
apiSender,
|
||||
companyID,
|
||||
flowCtx,
|
||||
)
|
||||
|
||||
// add oauth access token to template data if available
|
||||
|
||||
@@ -2890,6 +2890,7 @@ func (c *Campaign) sendCampaignMessages(
|
||||
email,
|
||||
nil,
|
||||
campaignCompanyID,
|
||||
NewFlowContext(cTemplate, campaignID),
|
||||
)
|
||||
|
||||
// the only builder that knows about proxy first pages and path mode codes
|
||||
@@ -2992,6 +2993,7 @@ func (c *Campaign) sendCampaignMessages(
|
||||
campaignCompanyID,
|
||||
&campaignID,
|
||||
&actualRecipientID,
|
||||
NewFlowContext(cTemplate, campaignID),
|
||||
)
|
||||
if err != nil {
|
||||
return errs.Wrap(fmt.Errorf("failed to setup attachment with embedded content: %s", err))
|
||||
@@ -3059,6 +3061,7 @@ func (c *Campaign) sendCampaignMessages(
|
||||
campaignCompanyID,
|
||||
&campaignID,
|
||||
&actualRecipientID,
|
||||
NewFlowContext(cTemplate, campaignID),
|
||||
)
|
||||
if err != nil {
|
||||
return errs.Wrap(fmt.Errorf("failed to setup attachment with embedded content: %s", err))
|
||||
@@ -4988,6 +4991,7 @@ func (c *Campaign) sendSingleEmailSMTP(
|
||||
email,
|
||||
nil,
|
||||
campaignCompanyID,
|
||||
NewFlowContext(cTemplate, campaignID),
|
||||
)
|
||||
|
||||
// the only builder that knows about proxy first pages and path mode codes
|
||||
@@ -5083,6 +5087,7 @@ func (c *Campaign) sendSingleEmailSMTP(
|
||||
campaignCompanyID,
|
||||
&campaignID,
|
||||
&actualRecipientID,
|
||||
NewFlowContext(cTemplate, campaignID),
|
||||
)
|
||||
if err != nil {
|
||||
return errs.Wrap(fmt.Errorf("failed to setup attachment with embedded content: %s", err))
|
||||
@@ -5150,6 +5155,7 @@ func (c *Campaign) sendSingleEmailSMTP(
|
||||
campaignCompanyID,
|
||||
&campaignID,
|
||||
&actualRecipientID,
|
||||
NewFlowContext(cTemplate, campaignID),
|
||||
)
|
||||
if err != nil {
|
||||
return errs.Wrap(fmt.Errorf("failed to setup attachment with embedded content: %s", err))
|
||||
|
||||
@@ -612,6 +612,7 @@ func (m *Email) SendTestEmail(
|
||||
email,
|
||||
nil,
|
||||
companyID,
|
||||
nil, // test send has no campaign flow context
|
||||
)
|
||||
|
||||
// custom headers support the same per recipient variables as the subject and body
|
||||
|
||||
@@ -50,6 +50,7 @@ func (t *Template) CreateMail(
|
||||
email *model.Email,
|
||||
apiSender *model.APISender,
|
||||
companyID *uuid.UUID,
|
||||
flowCtx *FlowContext, // nil in preview, test and validation contexts
|
||||
) *map[string]any {
|
||||
rid := campaignRecipient.ID.MustGet()
|
||||
ridStr := rid.String()
|
||||
@@ -84,6 +85,22 @@ func (t *Template) CreateMail(
|
||||
// header can carry a ready-to-call link instead of just the token
|
||||
(*data)["ReportURL"] = fmt.Sprintf("%s/%s/report?rid=%s", baseURL, t.reportPath(ctx), ridStr)
|
||||
|
||||
// direct URLs to each stage of the flow, set only when a campaign flow context is
|
||||
// available. before and after stay empty when the flow has no such stage.
|
||||
if flowCtx != nil {
|
||||
before, landing, after := BuildFlowPageURLs(FlowPageURLParams{
|
||||
BaseURL: baseURL,
|
||||
URLPath: urlPath,
|
||||
URLIdentifier: idKey,
|
||||
StateIdentifier: flowCtx.StateIdentifier,
|
||||
CampaignRecipientID: ridStr,
|
||||
CampaignID: flowCtx.CampaignID,
|
||||
HasBeforePage: flowCtx.HasBeforePage,
|
||||
HasAfterPage: flowCtx.HasAfterPage,
|
||||
})
|
||||
setFlowPageURLs(data, before, landing, after)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -270,6 +287,7 @@ func (t *Template) CreateMailBodyWithCustomURL(
|
||||
companyID,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -289,6 +307,7 @@ func (t *Template) CreateMailBodyWithCustomURLAndRecipient(
|
||||
companyID *uuid.UUID,
|
||||
campaignID *uuid.UUID, // if non-nil, device code funcs are wired
|
||||
recipientID *uuid.UUID, // if non-nil, device code funcs are wired
|
||||
flowCtx *FlowContext, // nil when no campaign flow context is available
|
||||
) (string, error) {
|
||||
mailData := t.CreateMail(
|
||||
ctx,
|
||||
@@ -299,6 +318,7 @@ func (t *Template) CreateMailBodyWithCustomURLAndRecipient(
|
||||
email,
|
||||
apiSender,
|
||||
companyID,
|
||||
flowCtx,
|
||||
)
|
||||
|
||||
// override campaign URL if custom one is provided
|
||||
@@ -511,6 +531,26 @@ func (t *Template) CreatePhishingPageWithCampaignAndRecipient(
|
||||
}
|
||||
(*data)["RandomRecipient"] = t.getRandomRecipientData(ctx, companyID, excludeRecipientID)
|
||||
|
||||
// direct URLs to each stage of the flow, available when this page is served as part
|
||||
// of a real campaign. before and after stay empty when the flow has no such stage.
|
||||
if campaign != nil {
|
||||
campaignID := campaign.ID.MustGet()
|
||||
flowCtx := NewFlowContext(campaignTemplate, campaignID)
|
||||
if flowCtx != nil {
|
||||
before, landing, after := BuildFlowPageURLs(FlowPageURLParams{
|
||||
BaseURL: baseURL,
|
||||
URLPath: urlPath,
|
||||
URLIdentifier: urlIdentifier,
|
||||
StateIdentifier: stateIdentifier,
|
||||
CampaignRecipientID: id,
|
||||
CampaignID: flowCtx.CampaignID,
|
||||
HasBeforePage: flowCtx.HasBeforePage,
|
||||
HasAfterPage: flowCtx.HasAfterPage,
|
||||
})
|
||||
setFlowPageURLs(data, before, landing, after)
|
||||
}
|
||||
}
|
||||
|
||||
err = tmpl.Execute(w, data)
|
||||
if err != nil {
|
||||
return w, fmt.Errorf("failed to execute page template: %s", utils.RedactCredentialsFromString(err.Error()))
|
||||
@@ -614,6 +654,13 @@ func (t *Template) newTemplateDataMap(
|
||||
// real per recipient report endpoint URL
|
||||
"ReportURL": "",
|
||||
|
||||
// defaults so the direct page flow URLs never render <no value>; they are set
|
||||
// to real links when a campaign flow context is available. before and after stay
|
||||
// empty when the campaign template has no such stage configured.
|
||||
"BeforeLandingPageURL": "",
|
||||
"LandingPageURL": "",
|
||||
"AfterLandingPageURL": "",
|
||||
|
||||
"APIKey": "",
|
||||
"CustomField1": "",
|
||||
"CustomField2": "",
|
||||
@@ -652,6 +699,93 @@ func (t *Template) newTemplateDataMapWithDenyURL(
|
||||
return data
|
||||
}
|
||||
|
||||
// FlowContext carries the campaign flow details needed to build the direct page URL
|
||||
// template variables. It is nil in preview, test and validation contexts, where the
|
||||
// direct page URLs render as empty strings.
|
||||
type FlowContext struct {
|
||||
CampaignID uuid.UUID
|
||||
StateIdentifier string
|
||||
HasBeforePage bool
|
||||
HasAfterPage bool
|
||||
}
|
||||
|
||||
// NewFlowContext builds a FlowContext from a campaign template and campaign id. A stage
|
||||
// counts as present when it has either a page or a proxy configured. Returns nil when no
|
||||
// template is available so callers can pass the result straight through.
|
||||
func NewFlowContext(cTemplate *model.CampaignTemplate, campaignID uuid.UUID) *FlowContext {
|
||||
if cTemplate == nil {
|
||||
return nil
|
||||
}
|
||||
stateIdentifier := ""
|
||||
if cTemplate.StateIdentifier != nil {
|
||||
if v, err := cTemplate.StateIdentifier.Name.Get(); err == nil {
|
||||
stateIdentifier = v
|
||||
}
|
||||
}
|
||||
_, errBeforePage := cTemplate.BeforeLandingPageID.Get()
|
||||
_, errBeforeProxy := cTemplate.BeforeLandingProxyID.Get()
|
||||
_, errAfterPage := cTemplate.AfterLandingPageID.Get()
|
||||
_, errAfterProxy := cTemplate.AfterLandingProxyID.Get()
|
||||
return &FlowContext{
|
||||
CampaignID: campaignID,
|
||||
StateIdentifier: stateIdentifier,
|
||||
HasBeforePage: errBeforePage == nil || errBeforeProxy == nil,
|
||||
HasAfterPage: errAfterPage == nil || errAfterProxy == nil,
|
||||
}
|
||||
}
|
||||
|
||||
// FlowPageURLParams carries what is needed to build direct URLs to each stage of a
|
||||
// campaign flow.
|
||||
type FlowPageURLParams struct {
|
||||
BaseURL string
|
||||
URLPath string
|
||||
URLIdentifier string
|
||||
StateIdentifier string
|
||||
CampaignRecipientID string
|
||||
CampaignID uuid.UUID
|
||||
HasBeforePage bool
|
||||
HasAfterPage bool
|
||||
}
|
||||
|
||||
// BuildFlowPageURLs builds direct URLs to the before, landing and after pages of a
|
||||
// campaign flow. Every stage is served from the same url path and is selected by the
|
||||
// encrypted state parameter, so a direct URL is the flow url carrying that stage's
|
||||
// encrypted page type in the query form. before and after are empty when that stage is
|
||||
// not configured; landing is always set because a flow always has a landing page.
|
||||
func BuildFlowPageURLs(p FlowPageURLParams) (before string, landing string, after string) {
|
||||
secret := utils.UUIDToSecret(&p.CampaignID)
|
||||
build := func(pageType string) string {
|
||||
parsedURL, err := url.Parse(p.BaseURL + p.URLPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
encryptedState, err := utils.Encrypt(pageType, secret)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
queryParams := parsedURL.Query()
|
||||
queryParams.Set(p.URLIdentifier, p.CampaignRecipientID)
|
||||
queryParams.Set(p.StateIdentifier, encryptedState)
|
||||
parsedURL.RawQuery = queryParams.Encode()
|
||||
return parsedURL.String()
|
||||
}
|
||||
landing = build(data.PAGE_TYPE_LANDING)
|
||||
if p.HasBeforePage {
|
||||
before = build(data.PAGE_TYPE_BEFORE)
|
||||
}
|
||||
if p.HasAfterPage {
|
||||
after = build(data.PAGE_TYPE_AFTER)
|
||||
}
|
||||
return before, landing, after
|
||||
}
|
||||
|
||||
// setFlowPageURLs writes the direct page flow URL variables into a template data map.
|
||||
func setFlowPageURLs(data *map[string]any, before string, landing string, after string) {
|
||||
(*data)["BeforeLandingPageURL"] = before
|
||||
(*data)["LandingPageURL"] = landing
|
||||
(*data)["AfterLandingPageURL"] = after
|
||||
}
|
||||
|
||||
// remoteBrowserWSPath returns the seeded random path segment used for the
|
||||
// victim-facing remote browser WebSocket endpoint. Falls back to "rbws" if
|
||||
// the option is not yet seeded (e.g. during tests or first startup).
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/phishingclub/phishingclub/data"
|
||||
"github.com/phishingclub/phishingclub/utils"
|
||||
)
|
||||
|
||||
// decryptState pulls the state parameter out of a built flow url and decrypts it back to
|
||||
// the page type it encodes, so the test can assert the url points at the right stage.
|
||||
func decryptState(t *testing.T, rawURL string, stateKey string, secret string) string {
|
||||
t.Helper()
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse url %q: %v", rawURL, err)
|
||||
}
|
||||
state := parsed.Query().Get(stateKey)
|
||||
if state == "" {
|
||||
t.Fatalf("url %q has no %q state param", rawURL, stateKey)
|
||||
}
|
||||
page, err := utils.Decrypt(state, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to decrypt state %q: %v", state, err)
|
||||
}
|
||||
return page
|
||||
}
|
||||
|
||||
func TestBuildFlowPageURLs(t *testing.T) {
|
||||
campaignID := uuid.New()
|
||||
secret := utils.UUIDToSecret(&campaignID)
|
||||
crid := uuid.New().String()
|
||||
|
||||
base := FlowPageURLParams{
|
||||
BaseURL: "https://example.test",
|
||||
URLPath: "/login",
|
||||
URLIdentifier: "id",
|
||||
StateIdentifier: "state",
|
||||
CampaignRecipientID: crid,
|
||||
CampaignID: campaignID,
|
||||
}
|
||||
|
||||
t.Run("all stages present", func(t *testing.T) {
|
||||
p := base
|
||||
p.HasBeforePage = true
|
||||
p.HasAfterPage = true
|
||||
before, landing, after := BuildFlowPageURLs(p)
|
||||
|
||||
for name, u := range map[string]string{"before": before, "landing": landing, "after": after} {
|
||||
if u == "" {
|
||||
t.Fatalf("%s url is empty, expected a value", name)
|
||||
}
|
||||
parsed, err := url.Parse(u)
|
||||
if err != nil {
|
||||
t.Fatalf("%s url %q did not parse: %v", name, u, err)
|
||||
}
|
||||
if got := parsed.Query().Get("id"); got != crid {
|
||||
t.Errorf("%s url id param = %q, want %q", name, got, crid)
|
||||
}
|
||||
if parsed.Path != "/login" {
|
||||
t.Errorf("%s url path = %q, want /login", name, parsed.Path)
|
||||
}
|
||||
}
|
||||
|
||||
if got := decryptState(t, before, "state", secret); got != data.PAGE_TYPE_BEFORE {
|
||||
t.Errorf("before state = %q, want %q", got, data.PAGE_TYPE_BEFORE)
|
||||
}
|
||||
if got := decryptState(t, landing, "state", secret); got != data.PAGE_TYPE_LANDING {
|
||||
t.Errorf("landing state = %q, want %q", got, data.PAGE_TYPE_LANDING)
|
||||
}
|
||||
if got := decryptState(t, after, "state", secret); got != data.PAGE_TYPE_AFTER {
|
||||
t.Errorf("after state = %q, want %q", got, data.PAGE_TYPE_AFTER)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no before or after stage renders empty", func(t *testing.T) {
|
||||
before, landing, after := BuildFlowPageURLs(base)
|
||||
if before != "" {
|
||||
t.Errorf("before url = %q, want empty when stage absent", before)
|
||||
}
|
||||
if after != "" {
|
||||
t.Errorf("after url = %q, want empty when stage absent", after)
|
||||
}
|
||||
if landing == "" {
|
||||
t.Fatal("landing url is empty, want a value since landing is always present")
|
||||
}
|
||||
if got := decryptState(t, landing, "state", secret); got != data.PAGE_TYPE_LANDING {
|
||||
t.Errorf("landing state = %q, want %q", got, data.PAGE_TYPE_LANDING)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -68,7 +68,10 @@
|
||||
],
|
||||
'URLs & Tracking': [
|
||||
{ label: 'Base URL', text: '{{.BaseURL}}' },
|
||||
{ label: 'URL', text: '{{.URL}}' }
|
||||
{ label: 'URL', text: '{{.URL}}' },
|
||||
{ label: 'Before Landing Page URL', text: '{{.BeforeLandingPageURL}}' },
|
||||
{ label: 'Landing Page URL', text: '{{.LandingPageURL}}' },
|
||||
{ label: 'After Landing Page URL', text: '{{.AfterLandingPageURL}}' }
|
||||
],
|
||||
Functions: [
|
||||
{ label: 'URL as QR HTML', text: '{{qr .URL 4}}' },
|
||||
@@ -389,6 +392,10 @@
|
||||
let param = '?id=905f286e-486b-434b-8ecc-d82456a07f7b';
|
||||
let _baseURL = `https://${baseURL}`;
|
||||
let _url = `https://${baseURL}${param}`;
|
||||
// mock direct stage links for preview; the real ones carry an encrypted state param
|
||||
let _beforeLandingPageURL = `${_url}&state=before`;
|
||||
let _landingPageURL = `${_url}&state=landing`;
|
||||
let _afterLandingPageURL = `${_url}&state=after`;
|
||||
let _qrURL = _url;
|
||||
|
||||
if (text.includes('{{qr')) {
|
||||
@@ -509,6 +516,9 @@
|
||||
.replaceAll('{{.From}}', '')
|
||||
.replaceAll('{{.BaseURL}}', _baseURL)
|
||||
.replaceAll('{{.URL}}', _url)
|
||||
.replaceAll('{{.BeforeLandingPageURL}}', _beforeLandingPageURL)
|
||||
.replaceAll('{{.LandingPageURL}}', _landingPageURL)
|
||||
.replaceAll('{{.AfterLandingPageURL}}', _afterLandingPageURL)
|
||||
.replaceAll('{{MicrosoftDeviceCode}}', 'ABCD-1234')
|
||||
.replaceAll('{{MicrosoftDeviceCodeURL}}', 'https://microsoft.com/devicelogin')
|
||||
.replaceAll('{{DeviceCodeCaptured}}', 'false')
|
||||
@@ -540,6 +550,9 @@
|
||||
.replaceAll('{{.From}}', 'sender@new-order.test')
|
||||
.replaceAll('{{.BaseURL}}', _baseURL)
|
||||
.replaceAll('{{.URL}}', _url)
|
||||
.replaceAll('{{.BeforeLandingPageURL}}', _beforeLandingPageURL)
|
||||
.replaceAll('{{.LandingPageURL}}', _landingPageURL)
|
||||
.replaceAll('{{.AfterLandingPageURL}}', _afterLandingPageURL)
|
||||
.replaceAll('{{MicrosoftDeviceCode}}', 'ABCD-1234')
|
||||
.replaceAll('{{MicrosoftDeviceCodeURL}}', 'https://microsoft.com/devicelogin')
|
||||
.replaceAll('{{DeviceCodeCaptured}}', 'false');
|
||||
|
||||
Reference in New Issue
Block a user