add path param lure customization

Signed-off-by: RonniSkansing <rskansing@gmail.com>
This commit is contained in:
RonniSkansing
2026-07-27 17:57:53 +02:00
parent dfcfafd502
commit fd7ca01b22
29 changed files with 1818 additions and 75 deletions
+12
View File
@@ -42,6 +42,7 @@ type JSONResponseHandler interface {
Forbidden(g *gin.Context)
BadRequest(g *gin.Context)
BadRequestMessage(g *gin.Context, message string)
Conflict(g *gin.Context, message string, data any)
ValidationFailed(g *gin.Context, field string, err error)
ServerError(g *gin.Context)
ServerErrorMessage(g *gin.Context, message string)
@@ -130,6 +131,17 @@ func (r *jsonResponseHandler) BadRequestMessage(g *gin.Context, message string)
g.Abort()
}
// Conflict responds with 409 - CONFLICT and carries enough detail for the
// caller to offer a resolution, such as naming the campaign already holding a
// lure code the operator asked for.
func (r *jsonResponseHandler) Conflict(g *gin.Context, message string, data any) {
g.JSON(
http.StatusConflict,
r.newResponse(false, data, message),
)
g.Abort()
}
func (r *jsonResponseHandler) unwrapErrorMessage(err error) string {
message := err.Error()
unwrapped := errors.Unwrap(err)
+2
View File
@@ -192,6 +192,7 @@ const (
ROUTE_V1_CAMPAIGN_RECIPIENT_EMAIL = "/api/v1/campaign/recipient/:id/email"
ROUTE_V1_CAMPAIGN_RECIPIENT_URL = "/api/v1/campaign/recipient/:id/url"
ROUTE_V1_CAMPAIGN_RECIPIENT_SET_SENT = "/api/v1/campaign/recipient/:id/sent"
ROUTE_V1_CAMPAIGN_RECIPIENT_LURE_CODE = "/api/v1/campaign/recipient/:id/lure-code"
ROUTE_V1_CAMPAIGN_RECIPIENT_SEND_EMAIL = "/api/v1/campaign/recipient/:id/send"
// asset
ROUTE_V1_ASSET = "/api/v1/asset"
@@ -533,6 +534,7 @@ func setupRoutes(
GET(ROUTE_V1_CAMPAIGN_RECIPIENT_EMAIL, middleware.SessionHandler, controllers.Campaign.GetCampaignEmail).
GET(ROUTE_V1_CAMPAIGN_RECIPIENT_URL, middleware.SessionHandler, controllers.Campaign.GetCampaignURL).
POST(ROUTE_V1_CAMPAIGN_RECIPIENT_SET_SENT, middleware.SessionHandler, controllers.Campaign.SetSentAtByCampaignRecipientID).
PUT(ROUTE_V1_CAMPAIGN_RECIPIENT_LURE_CODE, middleware.SessionHandler, controllers.Campaign.SetLureCode).
POST(ROUTE_V1_CAMPAIGN_RECIPIENT_SEND_EMAIL, middleware.SessionHandler, controllers.Campaign.SendEmailByCampaignRecipientID).
// asset
GET(ROUTE_V1_ASSET_DOMAIN_VIEW, middleware.SessionHandler, controllers.Asset.GetContentByID).
+2
View File
@@ -676,6 +676,7 @@ func (s *Server) checkAndServePhishingPage(
c.Request,
s.repositories.Identifier,
s.repositories.CampaignRecipient,
true,
)
if err != nil {
s.logger.Debugw("failed to get campaign recipient from URL parameters",
@@ -1870,6 +1871,7 @@ func (s *Server) renderDenyPage(
c.Request,
s.repositories.Identifier,
s.repositories.CampaignRecipient,
true,
)
if err != nil {
return fmt.Errorf("failed to get campaign recipient for deny page: %s", err)
+5
View File
@@ -212,8 +212,13 @@ func NewServices(
Common: common,
RemoteBrowserRepository: repositories.RemoteBrowser,
}
lureCode := &service.LureCode{
CampaignRecipientRepository: repositories.CampaignRecipient,
Logger: common.Logger,
}
campaign := &service.Campaign{
Common: common,
LureCodeService: lureCode,
CampaignRepository: repositories.Campaign,
CampaignRecipientRepository: repositories.CampaignRecipient,
RecipientRepository: repositories.Recipient,
+43
View File
@@ -927,6 +927,49 @@ func (c *Campaign) UpdateByID(g *gin.Context) {
c.Response.OK(g, gin.H{})
}
// setLureCodeRequest is the body for assigning an operator chosen lure code.
type setLureCodeRequest struct {
Code string `json:"code"`
// Reclaim releases the code from whichever recipient currently holds it.
// The caller sets this only after being shown which campaign that is.
Reclaim bool `json:"reclaim"`
}
// SetLureCode assigns an operator chosen lure code to a single campaign recipient
func (c *Campaign) SetLureCode(g *gin.Context) {
// handle session
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
var req setLureCodeRequest
if ok := c.handleParseRequest(g, &req); !ok {
return
}
conflict, err := c.CampaignService.SetLureCodeByCampaignRecipientID(
g.Request.Context(),
session,
id,
req.Code,
req.Reclaim,
)
// a taken code is an expected outcome the operator can resolve, so it is
// reported with the current owner rather than as a plain failure
if errors.Is(err, service.ErrLureCodeTaken) {
c.Response.Conflict(g, "Lure code is already in use", conflict)
return
}
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
// SetSentAtByCampaignRecipientID sets the sent at time for a campaign recipient
func (c *Campaign) SetSentAtByCampaignRecipientID(g *gin.Context) {
// handle session
+21
View File
@@ -0,0 +1,21 @@
package data
const (
// LureURLModeQuery carries the recipient identifier as a query parameter,
// for example https://example.com/login?id=<uuid>.
LureURLModeQuery = "query"
// LureURLModePath carries the recipient identifier as the last path
// segment, for example https://example.com/login/4H7K9QM2XR3T. This form
// survives being read aloud or sent over SMS, where a query string with a
// UUID does not.
LureURLModePath = "path"
)
// IsValidLureURLMode reports whether mode is a known lure URL mode.
func IsValidLureURLMode(mode string) bool {
switch mode {
case LureURLModeQuery, LureURLModePath:
return true
}
return false
}
+9
View File
@@ -64,6 +64,15 @@ type Campaign struct {
WebhookIncludeData string `gorm:"not null;default:'full'"`
WebhookEvents int `gorm:"not null;default:0"`
// LureURLMode, LureCodeAlgo and LureCodeLength are snapshotted from the
// campaign template when the campaign is scheduled. Reading them from the
// campaign rather than the template keeps recipients added later to a self
// managed campaign consistent with the ones already scheduled, and keeps a
// template edit from changing the form of URLs already delivered.
LureURLMode string `gorm:"not null;default:'query'"`
LureCodeAlgo string `gorm:"not null;default:'crockford32'"`
LureCodeLength int `gorm:"not null;default:12"`
// has one
CampaignTemplateID *uuid.UUID `gorm:"index;type:uuid;"`
CampaignTemplate *CampaignTemplate
+63
View File
@@ -4,6 +4,7 @@ import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
const (
@@ -45,8 +46,70 @@ type CampaignRecipient struct {
// NotableEventID is the most notable event for this recipient
NotableEvent *Event `gorm:"foreignKey:NotableEventID;references:ID"`
NotableEventID *uuid.UUID `gorm:"type:uuid;index"`
// LureCode is the short identifier that appears in the lure URL instead of
// the campaign recipient UUID, for example https://example.com/4H7K9QM2XR3T.
// It is stored exactly as written and matched exactly, which is what lets an
// operator pick a code such as Special-42 and get that link verbatim.
//
// Releasing a code sets this back to null. Nulling rather than flagging is
// what keeps a reclaimed code from still showing against its former owner,
// and it lets the unique index below need nothing but IS NOT NULL.
LureCode *string `gorm:"type:varchar(64)"`
// LureCodeCustom marks a code the operator set by hand. A custom code cannot
// be told apart from a generated one by shape, so the distinction is stored.
LureCodeCustom bool `gorm:"not null;default:false"`
}
func (CampaignRecipient) TableName() string {
return CAMPAIGN_RECIPIENT_TABLE_NAME
}
// Migrate creates the partial unique index backing lure code allocation.
//
// The predicate has to exclude rows carrying no code, otherwise every recipient
// in a query mode campaign enters the index under the same value and the second
// insert fails. Because releasing a code nulls it, that single predicate also
// covers reuse: a released code leaves the index and is free to claim again.
//
// This is raw SQL rather than a gorm index tag because only the sqlite driver
// emits the WHERE clause. The generic migrator drops it, which would silently
// produce a full unique index and break reuse.
func (CampaignRecipient) Migrate(db *gorm.DB) error {
// lure_code is the only code column this table carries. AutoMigrate adds
// columns but never drops them, so any other lure column and its indexes are
// removed here. errors are ignored because on a fresh install there is
// nothing to drop.
db.Exec(`DROP INDEX IF EXISTS idx_campaign_recipients_lure_code_lookup`)
db.Exec(`DROP INDEX IF EXISTS idx_campaign_recipients_lure_code_released_at`)
db.Exec(`DROP INDEX IF EXISTS idx_campaign_recipients_lure_code_folded`)
db.Exec(`ALTER TABLE campaign_recipients DROP COLUMN lure_code_lookup`)
db.Exec(`ALTER TABLE campaign_recipients DROP COLUMN lure_code_released_at`)
db.Exec(`ALTER TABLE campaign_recipients DROP COLUMN lure_code_folded`)
createIndex := func() error {
return db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_campaign_recipients_lure_code
ON campaign_recipients (lure_code)
WHERE lure_code IS NOT NULL`).Error
}
err := createIndex()
if err == nil {
return nil
}
// the index fails when two rows already hold the same code. keep the first
// row for each code and release the rest, then build the index.
//
// this runs only after a failure, because the scan and grouping it needs
// are too costly to pay on every startup.
if dedupErr := db.Exec(`UPDATE campaign_recipients SET lure_code = NULL
WHERE lure_code IS NOT NULL
AND rowid NOT IN (
SELECT MIN(rowid) FROM campaign_recipients
WHERE lure_code IS NOT NULL
GROUP BY lure_code
)`).Error; dedupErr != nil {
return dedupErr
}
return createIndex()
}
+12
View File
@@ -21,6 +21,18 @@ type CampaignTemplate struct {
URLPath string `gorm:"not null;default:'';index"`
// LureURLMode selects how a delivered lure URL carries the recipient
// identifier. "query" appends ?id=<uuid>, "path" appends /<code> after
// URLPath. The resolver accepts both forms regardless of this setting, so
// changing it never breaks links that are already out.
LureURLMode string `gorm:"not null;default:'query'"`
// LureCodeAlgo names the generator that allocates codes for campaigns
// created from this template.
LureCodeAlgo string `gorm:"not null;default:'crockford32'"`
// LureCodeLength is the number of characters in a generated code. Shorter
// codes are easier to read aloud and easier to guess.
LureCodeLength int `gorm:"not null;default:12"`
// IsUsable indicates if a template is usable based on if it has all the required
// data such as domainID, landingPage and etc to be used in a campaign
IsUsable bool `gorm:"not null;default:false;index"`
+75
View File
@@ -0,0 +1,75 @@
// Package lure builds and resolves the short recipient identifiers that appear
// in a lure URL, for example https://example.com/account/4H7K9QM2XR3T.
//
// A code is stored and matched byte for byte. Nothing is folded, uppercased or
// stripped, so an operator who picks Special-42 gets exactly that link and it
// stays distinct from special-42. This is required by base58, whose two cases
// are different symbols, and applying the same rule everywhere keeps one
// matching behaviour rather than one per algorithm.
package lure
import "strings"
// Alphabet is the Crockford base32 encoding alphabet. It omits I, L, O and U
// so that a code read aloud or transcribed from a message cannot be confused
// with a visually similar character or spell an unintended word.
const Alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
// Base58Alphabet is the Bitcoin base58 alphabet. It keeps both cases, which
// makes a code shorter for the same number of combinations, and drops the four
// glyphs that are hard to tell apart in print: 0, O, I and l.
const Base58Alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
// MinLength and MaxLength bound the operator selectable length of a *generated*
// code. The lower bound is a deliberate operator choice for short lived
// campaigns where a guessable link is acceptable.
const (
MinLength = 6
MaxLength = 16
DefaultLength = 12
)
// MaxCustomLength is the only limit placed on an operator written code, and it
// exists because the column is varchar(64).
const MaxCustomLength = 64
// IsCandidate reports whether a path segment or query value could be a lure
// code and is therefore worth a database probe.
//
// Because an operator written code may be any text, this cannot check an
// alphabet. It only rules out what could not be a code at all: anything too
// long for the column, and anything that would have to be escaped to sit in a
// single path segment. A dot is allowed, so a lure can end in something like
// invoice.pdf, at the cost of one indexed probe on static asset requests.
func IsCandidate(s string) bool {
if s == "" || len(s) > MaxCustomLength {
return false
}
// structural URL characters, which would split the segment or need escaping
if strings.ContainsAny(s, "/%?#\\") {
return false
}
// quotes and angle brackets, because the finished URL is written into a
// mail body rendered with text/template, which does not escape it
if strings.ContainsAny(s, "\"'<>") {
return false
}
// control characters and whitespace. a code containing one could never be
// matched back from a request path, so it would be silently unreachable
for _, r := range s {
if r <= 0x20 || r == 0x7f {
return false
}
}
return true
}
// IsValidCustom reports whether an operator supplied code can be stored.
//
// The code is kept byte for byte as written, so the only rules are the ones
// that would otherwise make it unreachable: it has to fit the column and it has
// to survive as a single path segment without escaping. A path with more than
// one segment comes from the template URL path, not from the code.
func IsValidCustom(s string) bool {
return IsCandidate(s)
}
+99
View File
@@ -0,0 +1,99 @@
package lure
import (
"fmt"
"github.com/phishingclub/phishingclub/random"
)
// Algorithm selects how a generated code is produced. The resolver never reads
// this value, it keys only on the stored code, so a new algorithm can be added
// without touching the request path.
type Algorithm string
const (
// AlgorithmCrockford32 draws from the Crockford base32 alphabet.
AlgorithmCrockford32 Algorithm = "crockford32"
// AlgorithmBase58 draws from the Bitcoin base58 alphabet, which uses both
// cases and so needs fewer characters for the same number of combinations.
AlgorithmBase58 Algorithm = "base58"
)
// DefaultAlgorithm is used when a campaign template has no explicit choice.
const DefaultAlgorithm = AlgorithmCrockford32
// IsValidAlgorithm reports whether a is a known generator.
func IsValidAlgorithm(a Algorithm) bool {
switch a {
case AlgorithmCrockford32, AlgorithmBase58:
return true
}
return false
}
// AlphabetFor returns the symbol set an algorithm draws from.
func AlphabetFor(a Algorithm) string {
switch a {
case AlgorithmBase58:
return Base58Alphabet
default:
return Alphabet
}
}
// Code is an allocated identifier. It is stored and matched exactly as it
// appears here.
type Code struct {
Display string
}
// NewCustomCode builds a Code from an operator supplied string, kept byte for
// byte.
func NewCustomCode(s string) Code {
return Code{Display: s}
}
// Generate returns a new random code of the given length.
//
// Symbols are drawn by rejection sampling so every symbol is equally likely.
// Taking a byte modulo an alphabet size that does not divide 256 would favour
// the symbols at the start of the alphabet and shrink the real key space below
// the advertised one.
func Generate(algorithm Algorithm, length int) (Code, error) {
if !IsValidAlgorithm(algorithm) {
return Code{}, fmt.Errorf("unknown lure code algorithm: %s", algorithm)
}
if length < MinLength || length > MaxLength {
return Code{}, fmt.Errorf(
"lure code length must be between %d and %d, got %d",
MinLength,
MaxLength,
length,
)
}
alphabet := AlphabetFor(algorithm)
size := len(alphabet)
// largest multiple of the alphabet size within a byte range. draws at or
// above it are redrawn. an alphabet size that divides 256 gives a limit of
// 256, which is why this is an int and the comparison below widens the
// drawn byte rather than narrowing the limit.
limit := 256 / size * size
out := make([]byte, 0, length)
for len(out) < length {
buf, err := random.GenerateRandomBytes(length)
if err != nil {
return Code{}, fmt.Errorf("failed to generate lure code: %w", err)
}
for _, b := range buf {
if int(b) >= limit {
continue
}
out = append(out, alphabet[int(b)%size])
if len(out) == length {
break
}
}
}
return Code{Display: string(out)}, nil
}
+172
View File
@@ -0,0 +1,172 @@
package lure
import (
"strings"
"testing"
)
func TestIsCandidate(t *testing.T) {
cases := []struct {
in string
want bool
}{
{"4H7K9QM2XR3T", true},
{"zQ8fRt2mKp9x", true},
// an operator written code may be any text, so no alphabet is applied
{"special-42", true},
{"Special_42", true},
{"hello", true},
// dots are allowed so a lure can end in something like invoice.pdf
{"invoice.pdf", true},
{"main.4f3a2b1c.js", true},
// values that could not sit in a single path segment unescaped
{"hello world", false},
{"a/b", false},
{"a%2Fb", false},
{"a?b", false},
{"a#b", false},
{"", false},
{strings.Repeat("a", MaxCustomLength), true},
{strings.Repeat("a", MaxCustomLength+1), false},
}
for _, c := range cases {
if got := IsCandidate(c.in); got != c.want {
t.Errorf("IsCandidate(%q) = %v, want %v", c.in, got, c.want)
}
}
}
func TestIsCandidateRejectsUnescapableValues(t *testing.T) {
for _, in := range []string{"a/b", "%2Fetc", "a\\b", "a b"} {
if IsCandidate(in) {
t.Errorf("IsCandidate(%q) should be false", in)
}
}
}
func TestNewCustomCodeIsKeptVerbatim(t *testing.T) {
// an operator written code must reach the URL exactly as chosen, so
// Special-42 and special-42 stay distinct links
for _, in := range []string{"special-42", "Special_42", "HR-Survey-2026", "invoice.pdf"} {
if got := NewCustomCode(in).Display; got != in {
t.Errorf("display form was altered: %q -> %q", in, got)
}
}
}
func TestGenerateLength(t *testing.T) {
for _, algorithm := range []Algorithm{AlgorithmCrockford32, AlgorithmBase58} {
for length := MinLength; length <= MaxLength; length++ {
code, err := Generate(algorithm, length)
if err != nil {
t.Fatalf("Generate(%s, %d) failed: %v", algorithm, length, err)
}
if len(code.Display) != length {
t.Errorf("Generate(%s, %d) gave length %d", algorithm, length, len(code.Display))
}
}
}
}
func TestGenerateStaysInsideItsAlphabet(t *testing.T) {
for _, algorithm := range []Algorithm{AlgorithmCrockford32, AlgorithmBase58} {
alphabet := AlphabetFor(algorithm)
for i := 0; i < 500; i++ {
code, err := Generate(algorithm, MaxLength)
if err != nil {
t.Fatalf("Generate failed: %v", err)
}
for _, r := range code.Display {
if !strings.ContainsRune(alphabet, r) {
t.Fatalf("%s produced %q outside its alphabet: %q", algorithm, string(r), code.Display)
}
}
}
}
}
func TestGenerateCoversEachAlphabet(t *testing.T) {
// rejection sampling must reach every symbol, and must accept enough draws
// to terminate. an alphabet size that divides 256 gives a limit of 256, so
// a limit held in a byte would be zero and reject every draw.
for _, algorithm := range []Algorithm{AlgorithmCrockford32, AlgorithmBase58} {
alphabet := AlphabetFor(algorithm)
seen := map[rune]bool{}
for i := 0; i < 8000; i++ {
code, err := Generate(algorithm, MaxLength)
if err != nil {
t.Fatalf("Generate failed: %v", err)
}
for _, r := range code.Display {
seen[r] = true
}
}
for _, r := range alphabet {
if !seen[r] {
t.Errorf("%s never generated symbol %q", algorithm, string(r))
}
}
}
}
func TestBase58UsesBothCases(t *testing.T) {
// base58 treats the two cases as distinct symbols, which is why matching is
// exact everywhere rather than folded
lower := false
upper := false
for i := 0; i < 500 && (!lower || !upper); i++ {
code, err := Generate(AlgorithmBase58, MaxLength)
if err != nil {
t.Fatalf("Generate failed: %v", err)
}
if strings.ToLower(code.Display) != code.Display {
upper = true
}
if strings.ToUpper(code.Display) != code.Display {
lower = true
}
}
if !lower || !upper {
t.Error("base58 should produce both cases")
}
}
func TestGenerateExcludesConfusableGlyphs(t *testing.T) {
for i := 0; i < 500; i++ {
crockford, err := Generate(AlgorithmCrockford32, MaxLength)
if err != nil {
t.Fatalf("Generate failed: %v", err)
}
if strings.ContainsAny(crockford.Display, "ILOU") {
t.Fatalf("crockford code contains an excluded glyph: %q", crockford.Display)
}
base58, err := Generate(AlgorithmBase58, MaxLength)
if err != nil {
t.Fatalf("Generate failed: %v", err)
}
if strings.ContainsAny(base58.Display, "0OIl") {
t.Fatalf("base58 code contains an excluded glyph: %q", base58.Display)
}
}
}
func TestGenerateRejectsBadInput(t *testing.T) {
if _, err := Generate("nope", DefaultLength); err == nil {
t.Error("expected an error for an unknown algorithm")
}
if _, err := Generate(AlgorithmCrockford32, MinLength-1); err == nil {
t.Error("expected an error for a length below the minimum")
}
if _, err := Generate(AlgorithmCrockford32, MaxLength+1); err == nil {
t.Error("expected an error for a length above the maximum")
}
}
func TestIsValidAlgorithm(t *testing.T) {
if !IsValidAlgorithm(AlgorithmCrockford32) || !IsValidAlgorithm(AlgorithmBase58) {
t.Error("both shipped algorithms should be valid")
}
if IsValidAlgorithm("base64") {
t.Error("unknown algorithm should not validate")
}
}
+52
View File
@@ -10,6 +10,7 @@ import (
"github.com/oapi-codegen/nullable"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/lure"
"github.com/phishingclub/phishingclub/utils"
"github.com/phishingclub/phishingclub/validate"
"github.com/phishingclub/phishingclub/vo"
@@ -69,6 +70,17 @@ type Campaign struct {
// webhooks configuration with per-webhook settings
Webhooks nullable.Nullable[[]*CampaignWebhook] `json:"webhooks,omitempty"`
// lure URL settings snapshotted from the campaign template at schedule time
LureURLMode nullable.Nullable[string] `json:"lureURLMode"`
LureCodeAlgo nullable.Nullable[string] `json:"lureCodeAlgo"`
LureCodeLength nullable.Nullable[int] `json:"lureCodeLength"`
// HasCustomLureCodes reports whether any recipient in this campaign carries
// an operator set code. The recipient table uses it to decide whether to
// show the code column, which pagination makes impossible to derive from a
// single page of rows.
HasCustomLureCodes bool `json:"hasCustomLureCodes"`
// must not be set by a user
NotableEventID nullable.Nullable[uuid.UUID] `json:"notableEventID"`
NotableEventName string `json:"notableEventName"`
@@ -551,10 +563,50 @@ func (c *Campaign) ToDBMap() map[string]any {
m["jitter_max"] = v
}
}
if c.LureURLMode.IsSpecified() {
if v, err := c.LureURLMode.Get(); err == nil {
m["lure_url_mode"] = v
}
}
if c.LureCodeAlgo.IsSpecified() {
if v, err := c.LureCodeAlgo.Get(); err == nil {
m["lure_code_algo"] = v
}
}
if c.LureCodeLength.IsSpecified() {
if v, err := c.LureCodeLength.Get(); err == nil {
m["lure_code_length"] = v
}
}
return m
}
// LureSettings returns the campaign's snapshotted lure URL settings, falling
// back to the defaults for campaigns created before the columns existed.
func (c *Campaign) LureSettings() (mode string, algorithm lure.Algorithm, length int) {
mode = data.LureURLModeQuery
algorithm = lure.DefaultAlgorithm
length = lure.DefaultLength
if v, err := c.LureURLMode.Get(); err == nil && v != "" {
mode = v
}
if v, err := c.LureCodeAlgo.Get(); err == nil && v != "" {
algorithm = lure.Algorithm(v)
}
if v, err := c.LureCodeLength.Get(); err == nil && v >= lure.MinLength && v <= lure.MaxLength {
length = v
}
return mode, algorithm, length
}
// UsesLureCodePath reports whether delivered lure URLs for this campaign carry
// the recipient identifier as a path segment.
func (c *Campaign) UsesLureCodePath() bool {
mode, _, _ := c.LureSettings()
return mode == data.LureURLModePath
}
// Close sets the close at timestamp to now
// dont confuse with method Closed
func (c *Campaign) Close() error {
+10
View File
@@ -30,6 +30,11 @@ type CampaignRecipient struct {
Recipient *Recipient `json:"recipient"`
NotableEventID nullable.Nullable[uuid.UUID] `json:"notableEventID"`
NotableEventName string `json:"notableEventName"`
// LureCode is the identifier used in the lure URL, stored exactly as written
// and matched exactly. Setting it to null releases it for reuse.
LureCode nullable.Nullable[string] `json:"lureCode"`
// LureCodeCustom marks a code set by the operator rather than generated.
LureCodeCustom nullable.Nullable[bool] `json:"lureCodeCustom"`
}
// Validate validates the campaign recipient
@@ -104,6 +109,11 @@ func (c *CampaignRecipient) ToDBMap() map[string]any {
m["notable_event_id"] = v
}
}
// the lure code is not part of this map. callers load a whole recipient,
// change one field such as the notable event, and write the model back, so
// anything carried here is restated on every such write. a restated code
// collides with the unique index once another recipient holds it. the code
// is written only by Insert, SetLureCodeByID and ReleaseLureCodeByID.
return m
}
+44
View File
@@ -1,13 +1,16 @@
package model
import (
"fmt"
"time"
"github.com/go-errors/errors"
"github.com/google/uuid"
"github.com/oapi-codegen/nullable"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/lure"
"github.com/phishingclub/phishingclub/validate"
"github.com/phishingclub/phishingclub/vo"
)
@@ -54,6 +57,13 @@ type CampaignTemplate struct {
URLPath nullable.Nullable[vo.URLPath] `json:"urlPath"`
// lure URL settings. These are defaults, snapshotted onto a campaign when it
// is scheduled, so editing them here never changes a campaign already
// running.
LureURLMode nullable.Nullable[string] `json:"lureURLMode"`
LureCodeAlgo nullable.Nullable[string] `json:"lureCodeAlgo"`
LureCodeLength nullable.Nullable[int] `json:"lureCodeLength"`
EmailID nullable.Nullable[uuid.UUID] `json:"emailID"`
Email *Email `json:"email"`
@@ -91,6 +101,22 @@ func (c *CampaignTemplate) Validate() error {
}
// URLPath is optional, no validation needed
if v, err := c.LureURLMode.Get(); err == nil && !data.IsValidLureURLMode(v) {
return errs.NewValidationError(
errors.New("lure URL mode must be query or path"),
)
}
if v, err := c.LureCodeAlgo.Get(); err == nil && !lure.IsValidAlgorithm(lure.Algorithm(v)) {
return errs.NewValidationError(
errors.New("unknown lure code algorithm"),
)
}
if v, err := c.LureCodeLength.Get(); err == nil && (v < lure.MinLength || v > lure.MaxLength) {
return errs.NewValidationError(
fmt.Errorf("lure code length must be between %d and %d", lure.MinLength, lure.MaxLength),
)
}
// validate that only one type is set per stage
// before landing page: can have neither (optional), or one type, but not both
_, errBeforePage := c.BeforeLandingPageID.Get()
@@ -261,6 +287,24 @@ func (c *CampaignTemplate) ToDBMap() map[string]any {
}
}
}
if c.LureURLMode.IsSpecified() {
m["lure_url_mode"] = data.LureURLModeQuery
if v, err := c.LureURLMode.Get(); err == nil && data.IsValidLureURLMode(v) {
m["lure_url_mode"] = v
}
}
if c.LureCodeAlgo.IsSpecified() {
m["lure_code_algo"] = string(lure.DefaultAlgorithm)
if v, err := c.LureCodeAlgo.Get(); err == nil && lure.IsValidAlgorithm(lure.Algorithm(v)) {
m["lure_code_algo"] = v
}
}
if c.LureCodeLength.IsSpecified() {
m["lure_code_length"] = lure.DefaultLength
if v, err := c.LureCodeLength.Get(); err == nil && v >= lure.MinLength && v <= lure.MaxLength {
m["lure_code_length"] = v
}
}
_, errDomain := c.DomainID.Get()
_, errSMTP := c.SMTPConfigurationID.Get()
+28 -2
View File
@@ -3041,11 +3041,19 @@ func (m *ProxyHandler) sameSiteToString(sameSite http.SameSite) string {
func (m *ProxyHandler) getCampaignRecipientIDFromURLParams(req *http.Request) (*uuid.UUID, string) {
ctx := req.Context()
campaignRecipient, paramName, err := server.GetCampaignRecipientFromURLParams(
// on a proxy domain every path mirrors the target site, so a lure code
// shaped path segment could belong to the target rather than to a
// recipient. once a session cookie exists the recipient is already known,
// so the path form is switched off. this removes the misattribution risk and
// keeps every subresource of a proxied page from running an extra lookup.
allowPathLookup := !m.hasValidSessionCookie(req)
campaignRecipient, match, err := server.GetCampaignRecipientFromURLParams(
ctx,
req,
m.IdentifierRepository,
m.CampaignRecipientRepository,
allowPathLookup,
)
if err != nil {
m.logger.Errorw("failed to get identifiers for URL param extraction", "error", err)
@@ -3056,8 +3064,16 @@ func (m *ProxyHandler) getCampaignRecipientIDFromURLParams(req *http.Request) (*
return nil, ""
}
// take a consumed code back out of the path before anything downstream sees
// the URL. url rewrite rules compare the path exactly, and whatever is left
// here is forwarded to the target. the query form is stripped later through
// ParamName.
if match.PathSegment != "" {
req.URL.Path = server.TrimLastPathSegment(req.URL.Path)
}
campaignRecipientID := campaignRecipient.ID.MustGet()
return &campaignRecipientID, paramName
return &campaignRecipientID, match.ParamName
}
// applyEarlyRequestHeaderReplacements applies request header replacements before client creation
@@ -3878,6 +3894,16 @@ func (m *ProxyHandler) IsValidProxyCookie(cookie string) bool {
return m.isValidSessionCookie(cookie)
}
// hasValidSessionCookie reports whether the request already carries a live
// proxy session, meaning the recipient behind it is established.
func (m *ProxyHandler) hasValidSessionCookie(req *http.Request) bool {
sessionCookie, err := req.Cookie(m.cookieName)
if err != nil {
return false
}
return m.isValidSessionCookie(sessionCookie.Value)
}
// checkResponseRules checks if any response rules match the current request
func (m *ProxyHandler) checkResponseRules(req *http.Request, reqCtx *RequestContext) *http.Response {
// check global response rules first
+3
View File
@@ -2131,6 +2131,9 @@ func ToCampaign(row *database.Campaign) (*model.Campaign, error) {
Webhooks: webhooks,
NotableEventID: notableEventID,
NotableEventName: notableEventName,
LureURLMode: nullable.NewNullableWithValue(row.LureURLMode),
LureCodeAlgo: nullable.NewNullableWithValue(row.LureCodeAlgo),
LureCodeLength: nullable.NewNullableWithValue(row.LureCodeLength),
}, nil
}
+202 -13
View File
@@ -119,6 +119,13 @@ func (r *CampaignRecipient) Insert(
row := campaignRecipient.ToDBMap()
row["id"] = id
AddTimestamps(row)
// ToDBMap omits the lure code, so it is carried across here
if code, err := campaignRecipient.LureCode.Get(); err == nil && code != "" {
row["lure_code"] = code
}
if custom, err := campaignRecipient.LureCodeCustom.Get(); err == nil {
row["lure_code_custom"] = custom
}
res := r.DB.
Model(&database.CampaignRecipient{}).
@@ -130,6 +137,32 @@ func (r *CampaignRecipient) Insert(
return &id, nil
}
// SetLureCodeByID assigns an operator chosen code to one recipient.
//
// This is the only path that writes a code after insert. It touches lure_code
// and lure_code_custom and nothing else, so a caller holding a stale recipient
// model cannot restate a code through it.
func (r *CampaignRecipient) SetLureCodeByID(
ctx context.Context,
id *uuid.UUID,
code string,
) error {
row := map[string]any{
"lure_code": code,
"lure_code_custom": true,
}
AddUpdatedAt(row)
res := r.DB.
Model(&database.CampaignRecipient{}).
Where(
fmt.Sprintf("%s = ?", TableColumnID(database.CAMPAIGN_RECIPIENT_TABLE_NAME)),
id.String(),
).
Updates(row)
return res.Error
}
// DeleteRecipientsNotIn deletes recipients in campaign that are
// not in the slice recipient ids supplied
func (r *CampaignRecipient) DeleteRecipientsNotIn(
@@ -273,6 +306,152 @@ func (r *CampaignRecipient) GetByCampaignRecipientID(
return ToCampaignRecipient(&dbCampaignRecipient)
}
// GetByLureCode gets a campaign recipient by the code carried in a request.
//
// The match is exact and case sensitive, which is what keeps Special-42
// distinct from special-42 and is required by base58, whose two cases are
// different symbols.
//
// A released code is null, so it drops out of this match without needing a
// separate predicate. That is also what stops an anonymized recipient's link
// from working.
func (r *CampaignRecipient) GetByLureCode(
ctx context.Context,
code string,
) (*model.CampaignRecipient, error) {
if code == "" {
return nil, gorm.ErrRecordNotFound
}
var dbCampaignRecipient database.CampaignRecipient
res := r.DB.
Where(
fmt.Sprintf("%s = ?", TableColumn(database.CAMPAIGN_RECIPIENT_TABLE_NAME, "lure_code")),
code,
).
First(&dbCampaignRecipient)
if res.Error != nil {
return nil, res.Error
}
return ToCampaignRecipient(&dbCampaignRecipient)
}
// findTakenLureCodesChunkSize keeps each IN clause well inside the sqlite bound
// variable limit, which is 999 on the most restrictive builds.
const findTakenLureCodesChunkSize = 500
// FindTakenLureCodes returns the subset of the given codes already claimed by a
// recipient whose code has not been released.
//
// It queries lure_code, the column carrying the uniqueness constraint, so a
// candidate that collides with an operator written code is caught here rather
// than by a failed insert part way through scheduling.
//
// Allocation probes a whole batch this way rather than inserting and retrying on
// conflict, so scheduling a campaign costs a handful of queries instead of one
// per recipient.
func (r *CampaignRecipient) FindTakenLureCodes(
ctx context.Context,
codes []string,
) ([]string, error) {
taken := []string{}
for start := 0; start < len(codes); start += findTakenLureCodesChunkSize {
end := start + findTakenLureCodesChunkSize
if end > len(codes) {
end = len(codes)
}
chunk := []string{}
res := r.DB.
Model(&database.CampaignRecipient{}).
Where(
fmt.Sprintf("%s IN ?", TableColumn(database.CAMPAIGN_RECIPIENT_TABLE_NAME, "lure_code")),
codes[start:end],
).
Pluck("lure_code", &chunk)
if res.Error != nil {
return nil, res.Error
}
taken = append(taken, chunk...)
}
return taken, nil
}
// GetActiveByLureCode returns the recipient currently holding a code, so the
// owning campaign can be named when an operator tries to reuse it. It matches
// lure_code, which is where uniqueness is enforced.
func (r *CampaignRecipient) GetActiveByLureCode(
ctx context.Context,
code string,
) (*model.CampaignRecipient, error) {
var dbCampaignRecipient database.CampaignRecipient
res := r.DB.
Preload("Campaign").
Where(
fmt.Sprintf("%s = ?", TableColumn(database.CAMPAIGN_RECIPIENT_TABLE_NAME, "lure_code")),
code,
).
First(&dbCampaignRecipient)
if res.Error != nil {
return nil, res.Error
}
return ToCampaignRecipient(&dbCampaignRecipient)
}
// ReleaseLureCodeByID frees a recipient's code for reuse.
//
// The code is nulled rather than flagged, so it leaves the unique index and
// stops showing against a recipient who no longer owns it. Only the code is
// cleared; the recipient row and its events are untouched.
func (r *CampaignRecipient) ReleaseLureCodeByID(
ctx context.Context,
id *uuid.UUID,
) error {
row := map[string]any{
"lure_code": nil,
}
AddUpdatedAt(row)
res := r.DB.
Model(&database.CampaignRecipient{}).
Where(
fmt.Sprintf("%s = ?", TableColumnID(database.CAMPAIGN_RECIPIENT_TABLE_NAME)),
id.String(),
).
Updates(row)
return res.Error
}
// HasCustomLureCodesByCampaignID reports whether any recipient in the campaign
// carries an operator set code. The recipient table is paginated, so this
// cannot be derived from a single page of rows.
func (r *CampaignRecipient) HasCustomLureCodesByCampaignID(
ctx context.Context,
campaignID *uuid.UUID,
) (bool, error) {
// this runs on every campaign detail load, so it stops at the first match
// rather than counting every recipient in the campaign
ids := []string{}
res := r.DB.
Model(&database.CampaignRecipient{}).
Where(
fmt.Sprintf("%s = ?", TableColumn(database.CAMPAIGN_RECIPIENT_TABLE_NAME, "campaign_id")),
campaignID,
).
Where(
fmt.Sprintf("%s = ?", TableColumn(database.CAMPAIGN_RECIPIENT_TABLE_NAME, "lure_code_custom")),
true,
).
Limit(1).
Pluck("id", &ids)
if res.Error != nil {
return false, res.Error
}
return len(ids) > 0, nil
}
// GetUnsendRecipients gets campaign recipients that were never attempted and are
// not already cancelled (cancelled_at IS NULL AND last_attempt_at IS NULL). Used
// at campaign close to cancel only sends that never started; recipients that were
@@ -461,8 +640,11 @@ func (r *CampaignRecipient) Anonymize(
recipientID *uuid.UUID,
anonymizedID *uuid.UUID,
) error {
// releasing the code alongside anonymization stops the recipient's lure link
// from resolving and hands the code back for reuse
row := map[string]interface{}{
"anonymized_id": anonymizedID.String(),
"lure_code": nil,
}
AddUpdatedAt(row)
db := r.DB.Model(&database.CampaignRecipient{})
@@ -627,19 +809,26 @@ func ToCampaignRecipient(row *database.CampaignRecipient) (*model.CampaignRecipi
notableEventID = nullable.NewNullableWithValue(*row.NotableEventID)
notableEventName = cache.EventNameByID[row.NotableEventID.String()]
}
var lureCode nullable.Nullable[string]
lureCode.SetNull()
if row.LureCode != nil {
lureCode = nullable.NewNullableWithValue(*row.LureCode)
}
return &model.CampaignRecipient{
ID: id,
CancelledAt: cancelledAt,
SendAt: sendAt,
SentAt: sentAt,
LastAttemptAt: lastAttemptAt,
SelfManaged: selfManaged,
CampaignID: campaignID,
Campaign: campaign,
AnonymizedID: anonymizedID,
RecipientID: recipientID,
Recipient: recipient,
NotableEventID: notableEventID,
NotableEventName: notableEventName,
ID: id,
CancelledAt: cancelledAt,
SendAt: sendAt,
SentAt: sentAt,
LastAttemptAt: lastAttemptAt,
SelfManaged: selfManaged,
CampaignID: campaignID,
Campaign: campaign,
AnonymizedID: anonymizedID,
RecipientID: recipientID,
Recipient: recipient,
NotableEventID: notableEventID,
NotableEventName: notableEventName,
LureCode: lureCode,
LureCodeCustom: nullable.NewNullableWithValue(row.LureCodeCustom),
}, nil
}
+3
View File
@@ -953,6 +953,9 @@ func ToCampaignTemplate(row *database.CampaignTemplate) (*model.CampaignTemplate
StateIdentifierID: stateIdentifierID,
StateIdentifier: stateIdentifier,
URLPath: urlPath,
LureURLMode: nullable.NewNullableWithValue(row.LureURLMode),
LureCodeAlgo: nullable.NewNullableWithValue(row.LureCodeAlgo),
LureCodeLength: nullable.NewNullableWithValue(row.LureCodeLength),
IsUsable: isUsable,
}, nil
}
+130 -26
View File
@@ -3,56 +3,160 @@ package server
import (
"context"
"net/http"
"strings"
"github.com/google/uuid"
"github.com/phishingclub/phishingclub/lure"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
)
// GetCampaignRecipientFromURLParams extracts campaign recipient information from URL parameters
// by checking all identifiers against query parameters and finding the first matching campaign recipient.
// returns the campaign recipient object, parameter name, and any error encountered.
// LastPathSegment returns the final segment of a request path, with any
// trailing slash trimmed. A mail client or message preview commonly appends
// one, so a link that arrives as /account/4H7K9QM2XR3T/ must resolve the same
// as one without.
//
// It refuses paths carrying traversal or an encoded slash rather than trying to
// clean them, because a segment that needs cleaning is never a lure code.
func LastPathSegment(path string) (string, bool) {
if path == "" || strings.Contains(path, "..") {
return "", false
}
if strings.Contains(path, "%2f") || strings.Contains(path, "%2F") {
return "", false
}
trimmed := strings.TrimSuffix(path, "/")
if trimmed == "" {
return "", false
}
index := strings.LastIndex(trimmed, "/")
if index < 0 {
return trimmed, true
}
segment := trimmed[index+1:]
if segment == "" {
return "", false
}
return segment, true
}
// TrimLastPathSegment removes the final segment of a path, which is how a
// consumed lure code is taken back out of the URL before the request is
// forwarded onward.
func TrimLastPathSegment(path string) string {
trimmed := strings.TrimSuffix(path, "/")
index := strings.LastIndex(trimmed, "/")
if index <= 0 {
return "/"
}
return trimmed[:index]
}
// LureMatch records how a request identified its recipient, so a caller can
// undo whatever carried the identifier before passing the request on.
type LureMatch struct {
// ParamName is the query parameter that matched. Empty when the identifier
// came from the path.
ParamName string
// PathSegment is the trailing path segment that matched. Empty when the
// identifier came from the query.
PathSegment string
}
// GetCampaignRecipientFromURLParams resolves the campaign recipient a request
// belongs to.
//
// Three forms are accepted, in this order:
//
// - a query parameter holding a campaign recipient UUID, the original form
// - a query parameter holding a lure code, so an operator set code such as
// special-42 works as ?id=special-42 too
// - the last path segment as a lure code, https://example.com/acc/4H7K9QM2XR3T
//
// All three are accepted regardless of the campaign's configured mode. The mode
// only decides which form is emitted, so changing it never breaks a link that
// has already been delivered.
//
// The query forms take priority over the path. An identifier in the query
// states which recipient a request belongs to, while a path segment only looks
// like a code: a query mode template whose URL path ends in a code shaped
// segment, say /careers, collides with any custom code holding that string.
//
// allowPathLookup lets a caller turn off the path form. The proxy switches it
// off once a session cookie exists, because on a proxy domain every path
// mirrors the target site and the recipient is already known by then.
func GetCampaignRecipientFromURLParams(
ctx context.Context,
req *http.Request,
identifierRepo *repository.Identifier,
campaignRecipientRepo *repository.CampaignRecipient,
) (*model.CampaignRecipient, string, error) {
allowPathLookup bool,
) (*model.CampaignRecipient, LureMatch, error) {
// get all identifiers
identifiers, err := identifierRepo.GetAll(ctx, &repository.IdentifierOption{})
if err != nil {
return nil, "", err
return nil, LureMatch{}, err
}
query := req.URL.Query()
var matchingParams []struct {
var matchingUUIDParams []struct {
name string
id *uuid.UUID
}
var matchingCodeParams []struct {
name string
value string
}
// collect all query parameters that match identifier names and can be parsed as UUIDs
// collect all query parameters that match identifier names, splitting them
// by whether they carry a campaign recipient UUID or a lure code
for _, identifier := range identifiers.Rows {
if name := identifier.Name.MustGet(); query.Has(name) {
if id, err := uuid.Parse(query.Get(name)); err == nil {
matchingParams = append(matchingParams, struct {
name string
id *uuid.UUID
}{name: name, id: &id})
name := identifier.Name.MustGet()
if !query.Has(name) {
continue
}
value := query.Get(name)
if id, err := uuid.Parse(value); err == nil {
matchingUUIDParams = append(matchingUUIDParams, struct {
name string
id *uuid.UUID
}{name: name, id: &id})
continue
}
if lure.IsCandidate(value) {
matchingCodeParams = append(matchingCodeParams, struct {
name string
value string
}{name: name, value: value})
}
}
// check each matching parameter to find a valid campaign recipient
for _, param := range matchingUUIDParams {
campaignRecipient, err := campaignRecipientRepo.GetByCampaignRecipientID(ctx, param.id)
if err == nil && campaignRecipient != nil {
return campaignRecipient, LureMatch{ParamName: param.name}, nil
}
}
for _, param := range matchingCodeParams {
campaignRecipient, err := campaignRecipientRepo.GetByLureCode(ctx, param.value)
if err == nil && campaignRecipient != nil {
return campaignRecipient, LureMatch{ParamName: param.name}, nil
}
}
// nothing in the query identified a recipient, so fall back to reading the
// last path segment as a code
if allowPathLookup {
if segment, ok := LastPathSegment(req.URL.Path); ok {
if lure.IsCandidate(segment) {
campaignRecipient, err := campaignRecipientRepo.GetByLureCode(ctx, segment)
if err == nil && campaignRecipient != nil {
return campaignRecipient, LureMatch{PathSegment: segment}, nil
}
}
}
}
if len(matchingParams) == 0 {
return nil, "", nil
}
// check each matching parameter to find a valid campaign recipient
for _, param := range matchingParams {
campaignRecipient, err := campaignRecipientRepo.GetByCampaignRecipientID(ctx, param.id)
if err == nil && campaignRecipient != nil {
return campaignRecipient, param.name, nil
}
}
return nil, "", nil
return nil, LureMatch{}, nil
}
+49
View File
@@ -0,0 +1,49 @@
package server
import "testing"
func TestLastPathSegment(t *testing.T) {
cases := []struct {
path string
want string
ok bool
}{
{"/4H7K9QM2XR3T", "4H7K9QM2XR3T", true},
{"/account/login/4H7K9QM2XR3T", "4H7K9QM2XR3T", true},
// a trailing slash is added by some mail clients and previews
{"/account/4H7K9QM2XR3T/", "4H7K9QM2XR3T", true},
{"/logo.png", "logo.png", true},
{"/", "", false},
{"", "", false},
// traversal and encoded slashes are refused rather than cleaned
{"/a/../b", "", false},
{"/a%2Fb", "", false},
{"/a%2fb", "", false},
}
for _, c := range cases {
got, ok := LastPathSegment(c.path)
if ok != c.ok || got != c.want {
t.Errorf("LastPathSegment(%q) = %q,%v want %q,%v", c.path, got, ok, c.want, c.ok)
}
}
}
func TestTrimLastPathSegment(t *testing.T) {
// a consumed code has to come back out of the path before the request is
// forwarded, or url rewrite rules that compare the path exactly stop matching
cases := []struct {
path string
want string
}{
{"/signin/4H7K9QM2XR3T", "/signin"},
{"/a/b/4H7K9QM2XR3T", "/a/b"},
{"/4H7K9QM2XR3T", "/"},
{"/signin/4H7K9QM2XR3T/", "/signin"},
{"/", "/"},
}
for _, c := range cases {
if got := TrimLastPathSegment(c.path); got != c.want {
t.Errorf("TrimLastPathSegment(%q) = %q, want %q", c.path, got, c.want)
}
}
}
+4 -4
View File
@@ -870,11 +870,11 @@ func (a *APISender) buildRequestWithCustomURL(
}
// override campaign URL if custom one is provided
// GetLandingPageURLByCampaignRecipientID is the single builder that knows
// about proxy first pages and about path mode lure codes, so its result is
// always the one to use
if customCampaignURL != "" {
templateURL := fmt.Sprintf("https://%s%s?%s=%s", domainName, urlPath, urlKey, campaignRecipient.ID.MustGet().String())
if customCampaignURL != templateURL {
(*t)["URL"] = customCampaignURL
}
(*t)["URL"] = customCampaignURL
}
// setup headers
+300 -13
View File
@@ -29,6 +29,7 @@ import (
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/log"
"github.com/phishingclub/phishingclub/lure"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/remotebrowser"
"github.com/phishingclub/phishingclub/repository"
@@ -42,6 +43,7 @@ import (
// Campaign is the Campaign service
type Campaign struct {
Common
LureCodeService *LureCode
CampaignRepository *repository.Campaign
CampaignRecipientRepository *repository.CampaignRecipient
RecipientRepository *repository.Recipient
@@ -315,6 +317,103 @@ func applyJitter(baseTime time.Time, jitterMin, jitterMax int, startBound, endBo
return jitteredTime
}
// lureCodeAllocator hands out codes drawn up front for a single schedule run.
// A query mode campaign gets an empty allocator, so recipients keep resolving
// by campaign recipient UUID alone and no code is consumed.
type lureCodeAllocator struct {
codes []lure.Code
next int
}
// applyTo sets the next unused code on a recipient, leaving it null once the
// batch is spent or when the campaign is not using codes at all.
// Over drawing is harmless: a code that is never inserted is never claimed.
func (a *lureCodeAllocator) applyTo(campaignRecipient *model.CampaignRecipient) {
var display nullable.Nullable[string]
display.SetNull()
if a != nil && a.next < len(a.codes) {
display = nullable.NewNullableWithValue(a.codes[a.next].Display)
a.next++
}
campaignRecipient.LureCode = display
}
// newLureCodeAllocator draws every code a schedule run needs in one batch.
func (c *Campaign) newLureCodeAllocator(
ctx context.Context,
campaign *model.Campaign,
count int,
) (*lureCodeAllocator, error) {
if !campaign.UsesLureCodePath() || count <= 0 || c.LureCodeService == nil {
return &lureCodeAllocator{}, nil
}
_, algorithm, length := campaign.LureSettings()
codes, err := c.LureCodeService.AllocateBatch(ctx, algorithm, length, count)
if err != nil {
return nil, errs.Wrap(err)
}
return &lureCodeAllocator{codes: codes}, nil
}
// snapshotLureSettings copies the template's lure URL settings onto the
// campaign. Reading them from the campaign afterwards keeps a template edit
// from changing the form of URLs already delivered, and keeps recipients added
// later to a self managed campaign consistent with the ones already scheduled.
// Nothing here is fatal. A campaign whose template is missing or unreadable
// keeps the query parameter defaults and continues scheduling, so a lure URL
// setting cannot stop a campaign from being scheduled. A missing or unusable
// template is reported by the close handler.
func (c *Campaign) snapshotLureSettings(
ctx context.Context,
session *model.Session,
campaign *model.Campaign,
) {
mode := data.LureURLModeQuery
algorithm := string(lure.DefaultAlgorithm)
length := lure.DefaultLength
if templateID, err := campaign.TemplateID.Get(); err == nil {
cTemplate, err := c.CampaignTemplateService.GetByID(
ctx,
session,
&templateID,
&repository.CampaignTemplateOption{},
)
if err != nil {
c.Logger.Infow("could not read lure settings from template, using defaults",
"error", err,
)
} else if cTemplate != nil {
if v, err := cTemplate.LureURLMode.Get(); err == nil && data.IsValidLureURLMode(v) {
mode = v
}
if v, err := cTemplate.LureCodeAlgo.Get(); err == nil && lure.IsValidAlgorithm(lure.Algorithm(v)) {
algorithm = v
}
if v, err := cTemplate.LureCodeLength.Get(); err == nil && v >= lure.MinLength && v <= lure.MaxLength {
length = v
}
}
}
campaign.LureURLMode = nullable.NewNullableWithValue(mode)
campaign.LureCodeAlgo = nullable.NewNullableWithValue(algorithm)
campaign.LureCodeLength = nullable.NewNullableWithValue(length)
campaignID, err := campaign.ID.Get()
if err != nil {
return
}
if err := c.CampaignRepository.UpdateByID(ctx, &campaignID, &model.Campaign{
LureURLMode: campaign.LureURLMode,
LureCodeAlgo: campaign.LureCodeAlgo,
LureCodeLength: campaign.LureCodeLength,
}); err != nil {
// the in memory campaign still carries the settings, so this run
// allocates correctly even if the snapshot could not be persisted
c.Logger.Errorw("failed to persist lure settings snapshot", "error", err)
}
}
func (c *Campaign) schedule(
ctx context.Context,
session *model.Session,
@@ -329,6 +428,9 @@ func (c *Campaign) schedule(
if !isAuthorized {
return errs.ErrAuthorizationFailed
}
// settle the lure URL settings before any recipient row is written, so every
// recipient in this run is allocated under the same rules
c.snapshotLureSettings(ctx, session, campaign)
// get all recipients and remove duplicates
recipients := []*model.Recipient{}
@@ -435,6 +537,12 @@ func (c *Campaign) schedule(
c.Logger.Errorw("failed to delete campaign recipients", "error", err)
return errs.Wrap(err)
}
// draw enough codes for the worst case where every supplied recipient is
// new, unused draws are simply discarded
allocator, err := c.newLureCodeAllocator(ctx, campaign, len(recipients))
if err != nil {
return errs.Wrap(err)
}
// insert campaign-recipients that are not already in the schedule
campaignRecipients := make([]*model.CampaignRecipient, len(recipients))
for i, recipient := range recipients {
@@ -462,6 +570,7 @@ func (c *Campaign) schedule(
CampaignID: nullable.NewNullableWithValue(campaignID),
SelfManaged: nullable.NewNullableWithValue(true),
}
allocator.applyTo(campaignRecipients[i])
// save campaign-recipient
_, err = c.CampaignRecipientRepository.Insert(ctx, campaignRecipients[i])
if err != nil {
@@ -503,6 +612,13 @@ func (c *Campaign) schedule(
}
scheduledEvent := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_SCHEDULED]
// draw every code this run needs before writing any recipient row, so an
// exhausted code space fails before the campaign is half scheduled
allocator, err := c.newLureCodeAllocator(ctx, campaign, recipientsCount)
if err != nil {
return errs.Wrap(err)
}
// get jitter values if specified
jitterMin := 0
jitterMax := 0
@@ -529,6 +645,7 @@ func (c *Campaign) schedule(
SendAt: nullable.NewNullableWithValue(jitteredStartAt),
NotableEventID: nullable.NewNullableWithValue(*scheduledEvent),
}
allocator.applyTo(campaignRecipient)
_, err := c.CampaignRecipientRepository.Insert(ctx, campaignRecipient)
if err != nil {
c.Logger.Errorw("failed to create campaign", "error", err)
@@ -606,6 +723,7 @@ func (c *Campaign) schedule(
SendAt: nullable.NewNullableWithValue(jitteredTime),
NotableEventID: nullable.NewNullableWithValue(*scheduledEvent),
}
allocator.applyTo(campaignRecipient)
_, err := c.CampaignRecipientRepository.Insert(ctx, campaignRecipient)
if err != nil {
c.Logger.Errorw("failed to create campaign", "error", err)
@@ -652,6 +770,7 @@ func (c *Campaign) schedule(
SendAt: nullable.NewNullableWithValue(jitteredSentAt),
NotableEventID: nullable.NewNullableWithValue(*scheduledEvent),
}
allocator.applyTo(campaignRecipients[i])
// save
_, err = c.CampaignRecipientRepository.Insert(ctx, campaignRecipients[i])
if err != nil {
@@ -863,6 +982,14 @@ func (c *Campaign) GetByID(
c.Logger.Errorw("failed to get campaign by id", "error", err)
return nil, errs.Wrap(err)
}
// the recipient table is paginated, so whether the campaign has any operator
// set codes cannot be derived from a single page of rows
hasCustom, err := c.CampaignRecipientRepository.HasCustomLureCodesByCampaignID(ctx, id)
if err != nil {
c.Logger.Errorw("failed to check for custom lure codes", "error", err)
} else {
campaign.HasCustomLureCodes = hasCustom
}
// no audit on read
return campaign, nil
}
@@ -2642,11 +2769,10 @@ func (c *Campaign) sendCampaignMessages(
campaignCompanyID,
)
// 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
}
// GetLandingPageURLByCampaignRecipientID is the single builder that knows
// about proxy first pages and about path mode lure codes, so its result
// is always the one to use
(*t)["URL"] = customCampaignURL
// build per-recipient template funcs so that {{MicrosoftDeviceCode}} resolves
// to a real device code for this campaign recipient.
@@ -3736,10 +3862,35 @@ func (c *Campaign) GetLandingPageURLByCampaignRecipientID(
}
// build final url
separator := "?"
url := fmt.Sprintf("%s%s%s%s=%s", baseURL, urlPath, separator, idIdentifier, campaignRecipientID.String())
// no audit on read
return url, nil
return BuildLureURL(baseURL, urlPath, idIdentifier, campaignRecipient), nil
}
// BuildLureURL assembles the URL delivered to a recipient.
//
// The path form is used whenever the recipient holds a code. A generated code
// exists only for a campaign scheduled in path mode, and an operator set code
// is a deliberate request for that exact link, so the presence of a code is a
// sufficient signal on its own and the campaign does not need consulting.
//
// A recipient with no code gets the query form, carrying the campaign recipient
// UUID.
func BuildLureURL(
baseURL string,
urlPath string,
urlIdentifier string,
campaignRecipient *model.CampaignRecipient,
) string {
if code, err := campaignRecipient.LureCode.Get(); err == nil && code != "" {
return strings.TrimSuffix(baseURL+urlPath, "/") + "/" + code
}
return fmt.Sprintf(
"%s%s?%s=%s",
baseURL,
urlPath,
urlIdentifier,
campaignRecipient.ID.MustGet().String(),
)
}
// getFirstPageProxy returns the proxy for the first page in the campaign flow
@@ -3812,6 +3963,143 @@ func (c *Campaign) getPhishingDomainForProxy(ctx context.Context, proxy *model.P
}
// SetSentAtByCampaignRecipientID sets the sent at time for a recipient
// ErrLureCodeTaken is returned when the requested code is already claimed by a
// recipient whose code has not been released.
var ErrLureCodeTaken = errors.New("lure code is already in use")
// companyIDsEqual reports whether two optional company references point at the
// same company, treating both unset as the global scope.
func companyIDsEqual(a nullable.Nullable[uuid.UUID], b nullable.Nullable[uuid.UUID]) bool {
aID, aErr := a.Get()
bID, bErr := b.Get()
if aErr != nil && bErr != nil {
return true
}
if aErr != nil || bErr != nil {
return false
}
return aID == bID
}
// LureCodeConflict describes who currently holds a requested code, so the
// operator can decide whether to reclaim it.
type LureCodeConflict struct {
CampaignID string `json:"campaignID"`
CampaignName string `json:"campaignName"`
IsClosed bool `json:"isClosed"`
}
// SetLureCodeByCampaignRecipientID assigns an operator chosen code to a single
// recipient.
//
// A custom code has close to no entropy, so anyone who guesses it reaches the
// landing page rendered for that one recipient. That is a deliberate trade for
// a link that has to be read aloud or typed, and it is why this is an explicit
// per recipient action rather than a campaign wide setting.
//
// When the code is held by another recipient the call fails with a conflict
// describing the owner. Passing reclaim releases that older code first, which
// means anyone still holding the old link resolves into this campaign instead.
func (c *Campaign) SetLureCodeByCampaignRecipientID(
ctx context.Context,
session *model.Session,
campaignRecipientID *uuid.UUID,
code string,
reclaim bool,
) (*LureCodeConflict, error) {
ae := NewAuditEvent("Campaign.SetLureCodeByCampaignRecipientID", session)
ae.Details["campaignRecipientId"] = campaignRecipientID.String()
ae.Details["lureCode"] = code
ae.Details["reclaim"] = fmt.Sprintf("%t", reclaim)
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
c.LogAuthError(err)
return nil, errs.Wrap(err)
}
if !isAuthorized {
c.AuditLogNotAuthorized(ae)
return nil, errs.ErrAuthorizationFailed
}
if !lure.IsValidCustom(code) {
return nil, errs.NewValidationError(
go_errors.Errorf(
"lure URL must be 1 to %d characters and cannot contain a slash, space or %%",
lure.MaxCustomLength,
),
)
}
campaignRecipient, err := c.CampaignRecipientRepository.GetByID(
ctx,
campaignRecipientID,
&repository.CampaignRecipientOption{
WithCampaign: true,
},
)
if err != nil {
c.Logger.Errorw("failed to get campaign recipient by id", "error", err)
return nil, errs.Wrap(err)
}
campaign := campaignRecipient.Campaign
if campaign == nil || !campaign.IsActive() {
return nil, errs.NewValidationError(errors.New("campaign is closed"))
}
if _, err := campaignRecipient.RecipientID.Get(); err != nil {
return nil, errs.NewValidationError(errors.New("recipient is not available"))
}
newCode := lure.NewCustomCode(code)
// setting the same code again is a no op rather than a self conflict
if current, err := campaignRecipient.LureCode.Get(); err == nil && current == newCode.Display {
c.AuditLogAuthorized(ae)
return nil, nil
}
holder, err := c.CampaignRecipientRepository.GetActiveByLureCode(ctx, newCode.Display)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
c.Logger.Errorw("failed to check lure code owner", "error", err)
return nil, errs.Wrap(err)
}
if holder != nil {
conflict := &LureCodeConflict{}
if holder.Campaign != nil {
// codes are unique across the whole instance, so the holder may
// belong to another company. naming it would disclose that
// company's campaign to someone who only guessed a code, so the
// name is only given when both campaigns share a company.
sameCompany := companyIDsEqual(campaign.CompanyID, holder.Campaign.CompanyID)
if sameCompany {
holderCampaignID := holder.Campaign.ID.MustGet()
conflict.CampaignID = holderCampaignID.String()
if name, err := holder.Campaign.Name.Get(); err == nil {
conflict.CampaignName = name.String()
}
}
conflict.IsClosed = !holder.Campaign.IsActive()
}
if !reclaim {
return conflict, ErrLureCodeTaken
}
holderID := holder.ID.MustGet()
if err := c.CampaignRecipientRepository.ReleaseLureCodeByID(ctx, &holderID); err != nil {
c.Logger.Errorw("failed to release lure code from previous owner", "error", err)
return nil, errs.Wrap(err)
}
ae.Details["reclaimedFromCampaignRecipientId"] = holderID.String()
}
if err := c.CampaignRecipientRepository.SetLureCodeByID(
ctx,
campaignRecipientID,
newCode.Display,
); err != nil {
c.Logger.Errorw("failed to set lure code", "error", err)
return nil, errs.Wrap(err)
}
c.AuditLogAuthorized(ae)
return nil, nil
}
func (c *Campaign) SetSentAtByCampaignRecipientID(
ctx context.Context,
session *model.Session,
@@ -4585,11 +4873,10 @@ func (c *Campaign) sendSingleEmailSMTP(
campaignCompanyID,
)
// 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
}
// GetLandingPageURLByCampaignRecipientID is the single builder that knows
// about proxy first pages and about path mode lure codes, so its result is
// always the one to use
(*t)["URL"] = customCampaignURL
// custom headers support the same per recipient variables as the subject and body
applyCustomSMTPHeaders(m, smtpConfig.Headers, t, recipientDeviceFuncs, c.Logger)
+21
View File
@@ -9,6 +9,7 @@ import (
"github.com/oapi-codegen/nullable"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/lure"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/validate"
@@ -85,6 +86,17 @@ func (c *CampaignTemplate) Create(
if !campaignTemplate.URLPath.IsSpecified() || campaignTemplate.URLPath.IsNull() {
campaignTemplate.URLPath = nullable.NewNullableWithValue(*vo.NewURLPathMust(""))
}
// lure URL settings default to the existing query parameter behaviour, so a
// template created without them keeps producing the URLs it always did
if !campaignTemplate.LureURLMode.IsSpecified() || campaignTemplate.LureURLMode.IsNull() {
campaignTemplate.LureURLMode = nullable.NewNullableWithValue(data.LureURLModeQuery)
}
if !campaignTemplate.LureCodeAlgo.IsSpecified() || campaignTemplate.LureCodeAlgo.IsNull() {
campaignTemplate.LureCodeAlgo = nullable.NewNullableWithValue(string(lure.DefaultAlgorithm))
}
if !campaignTemplate.LureCodeLength.IsSpecified() || campaignTemplate.LureCodeLength.IsNull() {
campaignTemplate.LureCodeLength = nullable.NewNullableWithValue(lure.DefaultLength)
}
// if no afterLandingPageRedirectURL set to ''
if !campaignTemplate.AfterLandingPageRedirectURL.IsSpecified() || campaignTemplate.AfterLandingPageRedirectURL.IsNull() {
campaignTemplate.AfterLandingPageRedirectURL = nullable.NewNullableWithValue(*vo.NewOptionalString255Must(""))
@@ -725,6 +737,15 @@ func (c *CampaignTemplate) UpdateByID(
incoming.URLPath.Set(*vo.NewURLPathMust(""))
}
}
if v, err := campaignTemplate.LureURLMode.Get(); err == nil {
incoming.LureURLMode.Set(v)
}
if v, err := campaignTemplate.LureCodeAlgo.Get(); err == nil {
incoming.LureCodeAlgo.Set(v)
}
if v, err := campaignTemplate.LureCodeLength.Get(); err == nil {
incoming.LureCodeLength.Set(v)
}
// validate
if err := incoming.Validate(); err != nil {
c.Logger.Errorw("failed to validate campaign template", "error", err)
+95
View File
@@ -0,0 +1,95 @@
package service
import (
"context"
"github.com/go-errors/errors"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/lure"
"github.com/phishingclub/phishingclub/repository"
"go.uber.org/zap"
)
// allocateRounds bounds how many times a batch is regenerated when some of the
// drawn codes turn out to be taken. Each round clears all but a vanishing
// fraction of a healthy space, so exhausting the rounds means the space itself
// is too small rather than that the draw was unlucky.
const allocateRounds = 3
// LureCode allocates the short identifiers used in lure URLs.
type LureCode struct {
CampaignRecipientRepository *repository.CampaignRecipient
Logger *zap.SugaredLogger
}
// AllocateBatch returns n distinct codes that are free at the time of the call.
//
// The whole batch is drawn up front and checked against the database in chunked
// queries rather than inserting each code and retrying on conflict, so
// scheduling a ten thousand recipient campaign costs a couple of dozen queries.
//
// Running out of rounds is the exhaustion signal, and is the only one. A
// capacity threshold would need a count over every live code and would still
// only estimate what the next draw hits.
func (l *LureCode) AllocateBatch(
ctx context.Context,
algorithm lure.Algorithm,
length int,
n int,
) ([]lure.Code, error) {
if n <= 0 {
return []lure.Code{}, nil
}
if !lure.IsValidAlgorithm(algorithm) {
return nil, errs.NewValidationError(
errors.Errorf("unknown lure code algorithm: %s", algorithm),
)
}
if length < lure.MinLength || length > lure.MaxLength {
return nil, errs.NewValidationError(
errors.Errorf(
"lure code length must be between %d and %d",
lure.MinLength,
lure.MaxLength,
),
)
}
// keyed by the code itself, which is the column uniqueness sits on, so the
// map rejects internal duplicates on the same terms the database will
pool := make(map[string]lure.Code, n)
for round := 0; round < allocateRounds; round++ {
for len(pool) < n {
code, err := lure.Generate(algorithm, length)
if err != nil {
return nil, errs.Wrap(err)
}
pool[code.Display] = code
}
candidates := make([]string, 0, len(pool))
for display := range pool {
candidates = append(candidates, display)
}
taken, err := l.CampaignRecipientRepository.FindTakenLureCodes(ctx, candidates)
if err != nil {
l.Logger.Errorw("failed to check taken lure codes", "error", err)
return nil, errs.Wrap(err)
}
if len(taken) == 0 {
codes := make([]lure.Code, 0, len(pool))
for _, code := range pool {
codes = append(codes, code)
}
return codes, nil
}
for _, display := range taken {
delete(pool, display)
}
}
return nil, errs.NewValidationError(
errors.Errorf(
"could not allocate %d unique lure codes at length %d, use a longer code length",
n,
length,
),
)
}
+5 -7
View File
@@ -54,13 +54,7 @@ func (t *Template) CreateMail(
rid := campaignRecipient.ID.MustGet()
ridStr := rid.String()
baseURL := "https://" + domainName
url := fmt.Sprintf(
"%s%s?%s=%s",
baseURL,
urlPath,
idKey,
ridStr,
)
url := BuildLureURL(baseURL, urlPath, idKey, campaignRecipient)
// set body
trackingPixelPath := fmt.Sprintf(
"%s/wf/open?upn=%s",
@@ -416,6 +410,10 @@ func (t *Template) CreatePhishingPageWithCampaignAndRecipient(
queryParams := parsedURL.Query()
// the page flow url keeps the query form even for a path mode campaign,
// because the encrypted state param that drives the next page has to travel
// in the query string regardless. path mode covers the delivered link, which
// is the one a recipient has to read or type.
// only add campaign parameters if they don't already exist
if !queryParams.Has(urlIdentifier) {
queryParams.Set(urlIdentifier, id)
+40 -4
View File
@@ -911,6 +911,24 @@ export class API {
return await getJSON(this.getPath(`/campaign/recipient/${campaignRecipientID}/url`));
},
/**
* Set an operator chosen lure code for a single campaign recipient.
*
* Responds 409 with the owning campaign when the code is already in use.
* Repeat the call with reclaim true to take it over.
*
* @param {string} campaignRecipientID
* @param {string} code
* @param {boolean} [reclaim]
* @return {Promise<ApiResponse>}
*/
setLureCode: async (campaignRecipientID, code, reclaim = false) => {
return await putJSON(this.getPath(`/campaign/recipient/${campaignRecipientID}/lure-code`), {
code,
reclaim
});
},
/**
* Delete all device codes for a campaign so every recipient gets a fresh
* code (and picks up any proxy change) on their next page visit.
@@ -1088,6 +1106,9 @@ export class API {
* @param {string} template.stateIdentifierID
* @param {string} template.urlPath
* @param {string} template.emailID
* @param {string} template.lureURLMode
* @param {string} template.lureCodeAlgo
* @param {number} template.lureCodeLength
* @returns {Promise<ApiResponse>}
*/
create: async ({
@@ -1106,7 +1127,10 @@ export class API {
stateIdentifierID,
afterLandingPageRedirectURL,
emailID: emailID,
urlPath: urlPath
urlPath: urlPath,
lureURLMode,
lureCodeAlgo,
lureCodeLength
}) => {
return await postJSON(this.getPath('/campaign/template'), {
name: name,
@@ -1124,7 +1148,10 @@ export class API {
urlIdentifierID: urlIdentifierID,
stateIdentifierID: stateIdentifierID,
emailID: emailID,
urlPath: urlPath
urlPath: urlPath,
lureURLMode: lureURLMode,
lureCodeAlgo: lureCodeAlgo,
lureCodeLength: lureCodeLength
});
},
@@ -1149,6 +1176,9 @@ export class API {
* @param {string} template.urlIdentifierID
* @param {string} template.stateIdentifierID
* @param {string} template.urlPath
* @param {string} template.lureURLMode
* @param {string} template.lureCodeAlgo
* @param {number} template.lureCodeLength
* @returns {Promise<ApiResponse>}
*/
update: async ({
@@ -1168,7 +1198,10 @@ export class API {
emailID: emailID,
urlIdentifierID: urlIdentifierID,
stateIdentifierID: stateIdentifierID,
urlPath: urlPath
urlPath: urlPath,
lureURLMode,
lureCodeAlgo,
lureCodeLength
}) => {
return await postJSON(this.getPath(`/campaign/template/${id}`), {
name: name,
@@ -1186,7 +1219,10 @@ export class API {
emailID: emailID,
urlIdentifierID: urlIdentifierID,
stateIdentifierID: stateIdentifierID,
urlPath: urlPath
urlPath: urlPath,
lureURLMode: lureURLMode,
lureCodeAlgo: lureCodeAlgo,
lureCodeLength: lureCodeLength
});
},
@@ -66,7 +66,62 @@
apiSender: null,
urlIdentifier: 'id',
stateIdentifier: 'session',
urlPath: ''
urlPath: '',
lureURLMode: 'query',
lureCodeAlgo: 'crockford32',
lureCodeLength: 12
};
// codes are matched exactly, so the choice is about which glyphs appear and
// how many characters are needed, not about how forgiving matching is
const LURE_CODE_ALGOS = {
crockford32: {
label: 'Crockford base32',
symbols: 32,
sample: '4H7K9QM2XR3T',
note: 'Upper case only, without I L O U.'
},
base58: {
label: 'Base58',
symbols: 58,
sample: 'zQ8fRt2mKp9x',
note: 'Mixed case, without 0 O I l.'
}
};
const LURE_CODE_MIN_LENGTH = 6;
const LURE_CODE_MAX_LENGTH = 16;
// the backend rejects anything outside these bounds, so correct it here
// rather than bouncing the whole form back with a validation error
/** @param {string|number} value */
const clampLureCodeLength = (value) => {
const n = Number(value);
if (!Number.isFinite(n)) {
return 12;
}
return Math.min(LURE_CODE_MAX_LENGTH, Math.max(LURE_CODE_MIN_LENGTH, Math.round(n)));
};
// size of the code space, which depends on how many symbols the algorithm uses
/** @param {string} algo @param {number} length */
const lureCodeKeyspace = (algo, length) => {
const spec = LURE_CODE_ALGOS[algo];
if (!spec || !length || length < LURE_CODE_MIN_LENGTH || length > LURE_CODE_MAX_LENGTH) {
return '';
}
return `${(spec.symbols ** length).toLocaleString('en-US', { maximumFractionDigits: 0 })} combinations`;
};
// shape of the link that gets delivered, so the choice is concrete
/** @param {Object} values */
const lureURLExample = (values) => {
const path = values.urlPath || '';
if (values.lureURLMode === 'path') {
const sample = LURE_CODE_ALGOS[values.lureCodeAlgo]?.sample ?? '4H7K9QM2XR3T';
return `https://domain${path.replace(/\/$/, '')}/${sample}`;
}
return `https://domain${path}?${values.urlIdentifier || 'id'}=6ba7b810-9dad-11d1-80b4-00c04fd430c8`;
};
let contextCompanyID = null;
@@ -351,6 +406,9 @@
urlIdentifierID: identifierMap.byValueOrNull(formValues.urlIdentifier),
stateIdentifierID: identifierMap.byValueOrNull(formValues.stateIdentifier),
urlPath: formValues.urlPath || '',
lureURLMode: formValues.lureURLMode,
lureCodeAlgo: formValues.lureCodeAlgo,
lureCodeLength: clampLureCodeLength(formValues.lureCodeLength),
companyID: contextCompanyID
});
if (!res.success) {
@@ -390,7 +448,10 @@
afterLandingPageRedirectURL: formValues.afterLandingPageRedirectURL || '',
urlIdentifierID: identifierMap.byValueOrNull(formValues.urlIdentifier),
stateIdentifierID: identifierMap.byValueOrNull(formValues.stateIdentifier),
urlPath: formValues.urlPath || ''
urlPath: formValues.urlPath || '',
lureURLMode: formValues.lureURLMode,
lureCodeAlgo: formValues.lureCodeAlgo,
lureCodeLength: clampLureCodeLength(formValues.lureCodeLength)
});
if (!res.success) {
modalError = res.error;
@@ -449,7 +510,10 @@
apiSender: null,
urlIdentifier: 'id',
stateIdentifier: 'session',
urlPath: ''
urlPath: '',
lureURLMode: 'query',
lureCodeAlgo: 'crockford32',
lureCodeLength: 12
};
modalError = '';
showAdvancedOptions = false;
@@ -562,6 +626,9 @@
formValues.urlIdentifier = identifierMap.byKey(template.urlIdentifierID);
formValues.stateIdentifier = identifierMap.byKey(template.stateIdentifierID);
formValues.urlPath = template.urlPath || '';
formValues.lureURLMode = template.lureURLMode || 'query';
formValues.lureCodeAlgo = template.lureCodeAlgo || 'crockford32';
formValues.lureCodeLength = template.lureCodeLength || 12;
// set advanced options visibility based on template configuration
showAdvancedOptions = !!(
@@ -571,6 +638,7 @@
(template.urlIdentifierID && identifierMap.byKey(template.urlIdentifierID) !== 'id') ||
(template.stateIdentifierID &&
identifierMap.byKey(template.stateIdentifierID) !== 'session') ||
(template.lureURLMode && template.lureURLMode !== 'query') ||
template.apiSenderID
) // Show advanced if using External API
);
@@ -1160,10 +1228,62 @@ Simulation URLs to allow:\n${allowListingData.simulationUrl}\n
placeholder="/employee/login">URL Path</TextField
>
</div>
<div>
<SelectSquare
label="Lure URL format"
width="small"
center={false}
options={[
{ value: 'query', label: 'Query parameter' },
{ value: 'path', label: 'Path code' }
]}
bind:value={formValues.lureURLMode}
/>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-300 break-all">
{lureURLExample(formValues)}
</p>
</div>
{#if formValues.lureURLMode === 'path'}
<div>
<TextFieldSelect
id="lureCodeAlgo"
toolTipText="Symbol set the generated code is drawn from."
required
bind:value={formValues.lureCodeAlgo}
options={Object.entries(LURE_CODE_ALGOS).map(([value, spec]) => ({
value,
label: spec.label
}))}>Code format</TextFieldSelect
>
{#if LURE_CODE_ALGOS[formValues.lureCodeAlgo]}
<p class="mt-1 text-sm text-gray-600 dark:text-gray-300">
{LURE_CODE_ALGOS[formValues.lureCodeAlgo].note}
</p>
{/if}
</div>
<div>
<TextField
type="number"
min={6}
max={16}
toolTipText="Number of characters in the generated code, between 6 and 16."
bind:value={formValues.lureCodeLength}
placeholder="12">Code length (6 to 16)</TextField
>
{#if lureCodeKeyspace(formValues.lureCodeAlgo, Number(formValues.lureCodeLength))}
<p class="mt-1 text-sm text-gray-600 dark:text-gray-300">
{lureCodeKeyspace(
formValues.lureCodeAlgo,
Number(formValues.lureCodeLength)
)}
</p>
{/if}
</div>
{/if}
<div>
<TextFieldSelect
id="urlIdentifier"
toolTipText="This is the query param key used in the phishing URL."
toolTipText="Query param key carrying the recipient. With the path code format the delivered link does not use it, but the pages after the first click still do."
required
bind:value={formValues.urlIdentifier}
options={identifierMap.values()}>Query param key</TextFieldSelect
@@ -1172,7 +1292,7 @@ Simulation URLs to allow:\n${allowListingData.simulationUrl}\n
<div>
<TextFieldSelect
id="stateIdentifier"
toolTipText="This is the query param key used for state."
toolTipText="Query param key carrying page flow state. Always a query param, including with the path code format."
required
bind:value={formValues.stateIdentifier}
options={identifierMap.values()}>State param key</TextFieldSelect
+192 -1
View File
@@ -47,6 +47,8 @@
import IconButton from '$lib/components/IconButton.svelte';
import papaparse from 'papaparse';
import FormFooter from '$lib/components/FormFooter.svelte';
import TextField from '$lib/components/TextField.svelte';
import FormError from '$lib/components/FormError.svelte';
import TextFieldSelect from '$lib/components/TextFieldSelect.svelte';
import { resourceContext } from '$lib/store/resourceContext';
import RemoteBrowserStream from '$lib/components/remote-browser/RemoteBrowserStream.svelte';
@@ -148,6 +150,7 @@
let isAnonymizeDataModalVisible = false;
let isSendMessageModalVisible = false;
let isSetAsSentModalVisible = false;
let isLureCodeModalVisible = false;
let isSessionSushiModalVisible = false;
let isTrackingPixelWarningVisible = false;
let isReportedCSVModalVisible = false;
@@ -175,6 +178,12 @@
}
let sendMessageRecipient = null;
let setAsSentRecipient = null;
let lureCodeRecipient = null;
let lureCodeValue = '';
let lureCodeError = '';
/** set when the requested code is held by another recipient */
let lureCodeConflict = null;
let lureCodeSubmitting = false;
let lastPoll3399Nano = '';
// live remote browser sessions
@@ -671,6 +680,73 @@
sendMessageRecipient = null;
};
// a custom lure URL is stored byte for byte, so the only rules are the ones
// that would otherwise make it unreachable: it has to fit the column and sit
// in one path segment without escaping
/** @param {string} value */
const isLureCodeValid = (value) => !!value && value.length <= 64 && !/[/%?#\\\s]/.test(value);
// path the template puts in front of the code, used for the preview
$: campaignURLPath = (campaign?.template?.urlPath || '').replace(/\/$/, '');
/** @param {Object} recp */
const showLureCodeModal = (recp) => {
lureCodeRecipient = {
id: recp.id,
name: `${recp.recipient?.firstName || ''} ${recp.recipient?.lastName || ''}`.trim(),
email: recp.recipient?.email,
sentAt: recp.sentAt,
current: recp.lureCode || ''
};
lureCodeValue = recp.lureCode || '';
lureCodeError = '';
lureCodeConflict = null;
isLureCodeModalVisible = true;
};
const closeLureCodeModal = () => {
isLureCodeModalVisible = false;
lureCodeRecipient = null;
lureCodeValue = '';
lureCodeError = '';
lureCodeConflict = null;
};
/** @param {boolean} reclaim */
const submitLureCode = async (reclaim = false) => {
if (!lureCodeRecipient) {
return;
}
if (!isLureCodeValid(lureCodeValue)) {
lureCodeError = 'Up to 64 characters. Cannot contain a slash, space, %, ? or #.';
return;
}
lureCodeSubmitting = true;
lureCodeError = '';
try {
const res = await api.campaign.setLureCode(lureCodeRecipient.id, lureCodeValue, reclaim);
if (!res.success) {
// 409 carries the campaign currently holding the code so the
// operator can decide whether to take it over
if (res.statusCode === 409) {
lureCodeConflict = res.data || {};
lureCodeError = res.error || 'Lure code is already in use';
return;
}
lureCodeError = res.error || 'Failed to set lure URL';
return;
}
addToast('Lure URL updated', 'Success');
closeLureCodeModal();
await refreshCampaignRecipients();
} catch (e) {
lureCodeError = 'Failed to set lure URL';
console.error('failed to set lure code', e);
} finally {
lureCodeSubmitting = false;
}
};
/** @param {string} campaignRecipientID @param {Object} recipient */
const showSetAsSentModal = (campaignRecipientID, recipient) => {
setAsSentRecipient = {
@@ -2183,7 +2259,8 @@
{ column: 'Status', size: 'small' },
{ column: 'Send at', title: 'Scheduled', size: 'small' },
{ column: 'Sent at', title: 'Delivered', size: 'small' },
{ column: 'Cancelled at', size: 'small' }
{ column: 'Cancelled at', size: 'small' },
...(campaign.hasCustomLureCodes ? [{ column: 'Lure URL', size: 'small' }] : [])
]}
sortable={[
'First name',
@@ -2277,6 +2354,9 @@
<TableCell value={recp?.sendAt} isDate />
<TableCell value={recp?.sentAt} isDate />
<TableCell value={recp?.cancelledAt} isDate />
{#if campaign.hasCustomLureCodes}
<TableCell value={recp?.lureCode || ''} />
{/if}
{#if !campaign.sentAt}
<TableCellEmpty />
<TableCellAction>
@@ -2327,6 +2407,27 @@
: ''}
on:click={() => onClickCopyURL(recp.id)}
/>
<TableUpdateButton
name="Set custom lure URL"
disabled={!!campaign.closedAt ||
!!campaign.anonymizedAt ||
!recp.recipient ||
isContextMismatch()}
title={isContextMismatch()
? campaign.companyID
? 'Switch to company view to perform this action'
: 'Switch to global view to perform this action'
: campaign.closedAt
? 'Campaign is closed'
: campaign.anonymizedAt
? 'Campaign is anonymized'
: !recp.recipient
? 'Recipient not available'
: recp.sentAt
? 'Changing this will break the link already sent to this recipient'
: 'Choose the identifier used in this recipient lure URL'}
on:click={() => showLureCodeModal(recp)}
/>
<TableUpdateButton
name="Copy email content"
disabled={!!campaign.closedAt || !!campaign.anonymizedAt || isContextMismatch()}
@@ -2884,6 +2985,96 @@
</div>
</Alert>
<Modal
headerText="Set custom lure URL"
visible={isLureCodeModalVisible}
onClose={closeLureCodeModal}
>
{#if lureCodeRecipient}
<div class="py-6 w-full max-w-xl">
<div class="pb-4 mb-6 border-b border-gray-200 dark:border-gray-700">
<p class="font-medium text-gray-900 dark:text-gray-100">{lureCodeRecipient.name}</p>
<p class="text-sm text-gray-600 dark:text-gray-300">{lureCodeRecipient.email}</p>
</div>
<TextField
bind:value={lureCodeValue}
maxLength={64}
placeholder="special-42">Lure URL</TextField
>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-300 break-all">
{lureCodeValue
? `https://${campaign.template?.domain?.name ?? 'domain'}${campaignURLPath}/${lureCodeValue}`
: 'Matched exactly as written, so case and separators are kept.'}
</p>
{#if lureCodeRecipient.sentAt}
<p class="mt-4 text-sm text-amber-600 dark:text-amber-500">
Already sent on {new Date(lureCodeRecipient.sentAt).toLocaleString()}. Changing this
stops the link the recipient is holding from working.
</p>
{/if}
{#if lureCodeError}
<div class="mt-4">
<FormError message={lureCodeError} />
</div>
{/if}
{#if lureCodeConflict}
<div
class="mt-4 rounded-md border border-amber-400 dark:border-amber-600 p-4 text-sm space-y-3"
>
<p class="text-gray-800 dark:text-gray-100">
{#if lureCodeConflict.campaignName}
Already used by campaign <span class="font-medium"
>{lureCodeConflict.campaignName}</span
>{lureCodeConflict.isClosed ? ' (closed)' : ' (still running)'}.
{:else}
Already used by a campaign in another company{lureCodeConflict.isClosed
? ' (closed)'
: ' (still running)'}.
{/if}
</p>
<p class="text-gray-600 dark:text-gray-300">
Reclaiming frees it for this recipient. Anyone still holding the old link will land
in this campaign instead.
</p>
<button
type="button"
disabled={lureCodeSubmitting}
on:click={() => submitLureCode(true)}
class="rounded-md border border-amber-500 px-4 py-2 font-medium text-amber-700 dark:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-900/30 disabled:opacity-50 transition-colors duration-200"
>
Reclaim
</button>
</div>
{/if}
<div
class="mt-8 pt-4 flex justify-end gap-3 border-t border-gray-200 dark:border-gray-700"
>
<button
type="button"
on:click={closeLureCodeModal}
class="rounded-md px-4 py-2 font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-200"
>
Cancel
</button>
<button
type="button"
disabled={lureCodeSubmitting}
on:click={() => submitLureCode(false)}
class="rounded-md bg-cta-blue dark:bg-blue-700 px-6 py-2 font-medium text-white hover:bg-blue-700 dark:hover:bg-blue-600 disabled:opacity-50 transition-colors duration-200"
>
Save
</button>
</div>
</div>
{/if}
</Modal>
<Alert
headline="Set as Message Sent"
bind:visible={isSetAsSentModalVisible}