add variable support for specific landing pages

Signed-off-by: RonniSkansing <rskansing@gmail.com>
This commit is contained in:
RonniSkansing
2026-08-10 20:57:32 +02:00
parent 2b4e93894a
commit 13e8a7976e
6 changed files with 254 additions and 2 deletions
+6 -1
View File
@@ -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
+6
View File
@@ -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))
+1
View File
@@ -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
+134
View File
@@ -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');