From 13e8a7976e1d320682a1c6e59c5d12303683b88d Mon Sep 17 00:00:00 2001 From: RonniSkansing Date: Mon, 10 Aug 2026 20:57:32 +0200 Subject: [PATCH] add variable support for specific landing pages Signed-off-by: RonniSkansing --- backend/service/apiSender.go | 7 +- backend/service/campaign.go | 6 + backend/service/email.go | 1 + backend/service/templateService.go | 134 ++++++++++++++++++ .../service/templateService_flowurl_test.go | 93 ++++++++++++ .../src/lib/components/editor/Editor.svelte | 15 +- 6 files changed, 254 insertions(+), 2 deletions(-) create mode 100644 backend/service/templateService_flowurl_test.go diff --git a/backend/service/apiSender.go b/backend/service/apiSender.go index 272c7e2..3732ce0 100644 --- a/backend/service/apiSender.go +++ b/backend/service/apiSender.go @@ -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 diff --git a/backend/service/campaign.go b/backend/service/campaign.go index b3d9fb3..45f318a 100644 --- a/backend/service/campaign.go +++ b/backend/service/campaign.go @@ -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)) diff --git a/backend/service/email.go b/backend/service/email.go index 5bc0e64..2217e57 100644 --- a/backend/service/email.go +++ b/backend/service/email.go @@ -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 diff --git a/backend/service/templateService.go b/backend/service/templateService.go index e4617a5..3992d82 100644 --- a/backend/service/templateService.go +++ b/backend/service/templateService.go @@ -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 ; 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). diff --git a/backend/service/templateService_flowurl_test.go b/backend/service/templateService_flowurl_test.go new file mode 100644 index 0000000..18bbda3 --- /dev/null +++ b/backend/service/templateService_flowurl_test.go @@ -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) + } + }) +} diff --git a/frontend/src/lib/components/editor/Editor.svelte b/frontend/src/lib/components/editor/Editor.svelte index 8b47640..43f8cf8 100644 --- a/frontend/src/lib/components/editor/Editor.svelte +++ b/frontend/src/lib/components/editor/Editor.svelte @@ -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');