From adc9ae43b0c4d5edd1f73bb43c63a2d14eafa1e6 Mon Sep 17 00:00:00 2001 From: RonniSkansing Date: Mon, 10 Aug 2026 18:24:37 +0200 Subject: [PATCH] add campaign with url paths Signed-off-by: RonniSkansing --- backend/api/response.go | 12 + backend/app/administration.go | 2 + backend/app/server.go | 4 + backend/app/services.go | 5 + backend/controller/campaign.go | 43 ++ backend/data/lureURL.go | 20 + backend/database/campaign.go | 8 + backend/database/campaignRecipient.go | 109 +++++ backend/database/campaignTemplate.go | 11 + backend/lure/codec.go | 78 ++++ backend/lure/generate.go | 126 +++++ backend/lure/lure_test.go | 226 +++++++++ backend/model/campaign.go | 42 ++ backend/model/campaignRecipient.go | 9 + backend/model/campaignTemplate.go | 43 ++ backend/proxy/proxy.go | 38 +- backend/repository/campaign.go | 28 ++ backend/repository/campaignRecipient.go | 270 +++++++++++ backend/repository/campaignTemplate.go | 3 + backend/server/shared.go | 183 ++++++-- backend/server/shared_test.go | 123 +++++ backend/service/apiSender.go | 6 +- backend/service/campaign.go | 438 +++++++++++++++++- backend/service/campaignTemplate.go | 21 + backend/service/lureCode.go | 98 ++++ backend/service/templateService.go | 11 +- backend/vo/http.go | 12 +- frontend/src/lib/api/api.js | 44 +- .../src/routes/campaign-template/+page.svelte | 133 +++++- .../src/routes/campaign/[id]/+page.svelte | 247 +++++++++- makefile | 17 +- 31 files changed, 2338 insertions(+), 72 deletions(-) create mode 100644 backend/data/lureURL.go create mode 100644 backend/lure/codec.go create mode 100644 backend/lure/generate.go create mode 100644 backend/lure/lure_test.go create mode 100644 backend/server/shared_test.go create mode 100644 backend/service/lureCode.go diff --git a/backend/api/response.go b/backend/api/response.go index b1b357d..a15eee3 100644 --- a/backend/api/response.go +++ b/backend/api/response.go @@ -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 409 with enough detail for the caller to offer a +// resolution, such as naming the campaign already holding a requested lure +// code. +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) diff --git a/backend/app/administration.go b/backend/app/administration.go index 61732df..9f9168a 100644 --- a/backend/app/administration.go +++ b/backend/app/administration.go @@ -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). diff --git a/backend/app/server.go b/backend/app/server.go index b096912..5192220 100644 --- a/backend/app/server.go +++ b/backend/app/server.go @@ -676,6 +676,8 @@ func (s *Server) checkAndServePhishingPage( c.Request, s.repositories.Identifier, s.repositories.CampaignRecipient, + domain, + true, ) if err != nil { s.logger.Debugw("failed to get campaign recipient from URL parameters", @@ -1870,6 +1872,8 @@ func (s *Server) renderDenyPage( c.Request, s.repositories.Identifier, s.repositories.CampaignRecipient, + domain, + true, ) if err != nil { return fmt.Errorf("failed to get campaign recipient for deny page: %s", err) diff --git a/backend/app/services.go b/backend/app/services.go index db98ee8..7af1a12 100644 --- a/backend/app/services.go +++ b/backend/app/services.go @@ -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, diff --git a/backend/controller/campaign.go b/backend/controller/campaign.go index 6c242e8..f68de8b 100644 --- a/backend/controller/campaign.go +++ b/backend/controller/campaign.go @@ -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 holds it. Set only + // after the caller has been 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, + ) + // an expected outcome the operator can resolve, so it carries the owner + // rather than reporting 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 diff --git a/backend/data/lureURL.go b/backend/data/lureURL.go new file mode 100644 index 0000000..ce95cb8 --- /dev/null +++ b/backend/data/lureURL.go @@ -0,0 +1,20 @@ +package data + +const ( + // LureURLModeQuery carries the recipient as a query parameter, for example + // https://example.com/login?id=. + LureURLModeQuery = "query" + // LureURLModePath carries the recipient as the last path segment, for example + // https://example.com/login/4H7K9QM2XR3T. 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 +} diff --git a/backend/database/campaign.go b/backend/database/campaign.go index eb680af..29599c5 100644 --- a/backend/database/campaign.go +++ b/backend/database/campaign.go @@ -64,6 +64,14 @@ type Campaign struct { WebhookIncludeData string `gorm:"not null;default:'full'"` WebhookEvents int `gorm:"not null;default:0"` + // Snapshotted from the campaign template while the campaign holds no + // recipients. Read from here rather than the template so a later addition to + // a self managed campaign matches the recipients already scheduled, and a + // template edit cannot change 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 diff --git a/backend/database/campaignRecipient.go b/backend/database/campaignRecipient.go index fb29e68..389f838 100644 --- a/backend/database/campaignRecipient.go +++ b/backend/database/campaignRecipient.go @@ -1,9 +1,13 @@ package database import ( + "database/sql" + "errors" + "strings" "time" "github.com/google/uuid" + "gorm.io/gorm" ) const ( @@ -45,8 +49,113 @@ 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 replaces the campaign recipient UUID in a lure URL, for example + // https://example.com/4H7K9QM2XR3T. Stored and matched byte for byte, so an + // operator picking Special-42 gets that link verbatim. + // + // Releasing sets this to null rather than flagging it, which keeps a + // reclaimed code from showing against its former owner and 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, which cannot be told + // apart from a generated one by shape. + LureCodeCustom bool `gorm:"not null;default:false"` } func (CampaignRecipient) TableName() string { return CAMPAIGN_RECIPIENT_TABLE_NAME } + +// lureCodeIndexName is the partial unique index backing lure code allocation. +const lureCodeIndexName = "idx_campaign_recipients_lure_code" + +// lureCodeCustomIndexName is the index answering whether a campaign carries any +// operator set code. +const lureCodeCustomIndexName = "idx_campaign_recipients_lure_code_custom" + +// Migrate creates the indexes the lure code feature reads. +// +// The unique predicate must exclude rows carrying no code, or every recipient in +// a query mode campaign enters the index under the same value and the second +// insert fails. It covers reuse too, since releasing a code nulls it and the row +// leaves the index. +// +// Raw SQL rather than gorm index tags, because the generic migrator drops the +// WHERE clause and would silently build a full unique index. +func (CampaignRecipient) Migrate(db *gorm.DB) error { + createUnique := func() error { + return ensureIndex(db, lureCodeIndexName, `CREATE UNIQUE INDEX `+lureCodeIndexName+` + ON campaign_recipients (lure_code) + WHERE lure_code IS NOT NULL`) + } + if err := createUnique(); err != nil { + // creation fails when two rows already hold the same code. keep the first + // row for each and release the rest. only after a failure, because the + // scan and grouping 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 + } + if err := createUnique(); err != nil { + return err + } + } + + // only rows with an operator set code enter the index, rather than every + // recipient ever created. sqlite uses a partial index only where the query + // repeats the predicate, so HasCustomLureCodesByCampaignID writes the same + // lure_code_custom = 1 literal. a bare column predicate would not match. + return ensureIndex(db, lureCodeCustomIndexName, `CREATE INDEX `+lureCodeCustomIndexName+` + ON campaign_recipients (campaign_id) + WHERE lure_code_custom = 1`) +} + +// ensureIndex creates an index, replacing whatever carries the name when its +// definition differs. +// +// The whole definition is compared, not just existence: an index left by an +// earlier release keeps working and goes unnoticed, while a predicate that no +// longer matches the query stops the planner using it at all. The ddl must omit +// IF NOT EXISTS, because sqlite drops those words before storing the statement +// and the two would never compare equal. +func ensureIndex(db *gorm.DB, name string, ddl string) error { + // through the underlying pool rather than gorm, whose Row returns nil when it + // could not build the statement and would panic here during startup + sqlDB, err := db.DB() + if err != nil { + return err + } + var definition sql.NullString + err = sqlDB.QueryRow( + `SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ?`, + name, + ).Scan(&definition) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + if err == nil { + // an index sqlite created for itself carries no definition and is not ours + if !definition.Valid { + return nil + } + if normalizeSQL(definition.String) == normalizeSQL(ddl) { + return nil + } + if err := db.Exec(`DROP INDEX IF EXISTS ` + name).Error; err != nil { + return err + } + } + return db.Exec(ddl).Error +} + +// normalizeSQL collapses whitespace so a statement read back from sqlite_master +// compares equal to the indented literal it was created from. +func normalizeSQL(s string) string { + return strings.Join(strings.Fields(s), " ") +} diff --git a/backend/database/campaignTemplate.go b/backend/database/campaignTemplate.go index b407a40..98f23ac 100644 --- a/backend/database/campaignTemplate.go +++ b/backend/database/campaignTemplate.go @@ -21,6 +21,17 @@ type CampaignTemplate struct { URLPath string `gorm:"not null;default:'';index"` + // LureURLMode selects how a delivered lure URL carries the recipient. + // "query" appends ?id=, "path" appends / after URLPath. The + // resolver accepts both whatever this says, so changing it never breaks a + // link already delivered. + LureURLMode string `gorm:"not null;default:'query'"` + // LureCodeAlgo names the generator used for campaigns from this template. + LureCodeAlgo string `gorm:"not null;default:'crockford32'"` + // LureCodeLength is the character count of a generated code. Shorter is + // 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"` diff --git a/backend/lure/codec.go b/backend/lure/codec.go new file mode 100644 index 0000000..6ebe4ed --- /dev/null +++ b/backend/lure/codec.go @@ -0,0 +1,78 @@ +// 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. Base58 treats the two cases as +// different symbols, so folding would collapse distinct codes, and applying the +// same rule to every algorithm keeps one matching behaviour rather than one per +// alphabet. +package lure + +import "strings" + +// Alphabet is the Crockford base32 alphabet. It omits I, L, O and U so a code +// read aloud cannot be confused with a similar glyph or spell a word. +const Alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" + +// Base58Alphabet is the Bitcoin base58 alphabet. Keeping both cases needs fewer +// characters for the same number of combinations, and 0, O, I and l are dropped +// as hard to tell apart in print. +const Base58Alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + +// Bounds on a generated code. The lower bound is low enough to be guessable, +// which is an operator choice for short lived campaigns. +const ( + MinLength = 6 + MaxLength = 16 + DefaultLength = 12 +) + +// MaxCustomLength is the only limit on an operator written code, and it exists +// because the column is varchar(64). +const MaxCustomLength = 64 + +// DisallowedCustomCharacters lists what IsCandidate rejects, as the symbols +// themselves, so an error message shows what to look for. +const DisallowedCustomCharacters = `/ \ % ? # " ' < >` + +// IsCandidate reports whether a path segment or query value could be a lure code +// and is therefore worth a database probe. +// +// An operator written code may be any text, so no alphabet applies. This rules +// out only what could never be a code: too long for the column, or needing an +// escape to sit in a single path segment. A single dot is allowed so a lure can +// end in invoice.pdf, at the cost of one indexed probe per static asset request. +func IsCandidate(s string) bool { + if s == "" || len(s) > MaxCustomLength { + return false + } + // a browser resolves a lone dot away before sending, and LastPathSegment + // refuses a whole path carrying a doubled dot rather than cleaning it, so + // neither shape could arrive back intact + if s == "." || strings.Contains(s, "..") { + return false + } + // would split the segment or need escaping + if strings.ContainsAny(s, "/%?#\\") { + return false + } + // the finished URL is written into a mail body rendered with text/template, + // which does not escape it + if strings.ContainsAny(s, "\"'<>") { + return false + } + // a code carrying one could never be matched back from a request path + for _, r := range s { + if r <= 0x20 || r == 0x7f { + return false + } + } + return true +} + +// IsValidCustom reports whether an operator supplied code can be stored. The +// rules are the same ones that keep a code reachable, so a stored code always +// resolves. 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) +} diff --git a/backend/lure/generate.go b/backend/lure/generate.go new file mode 100644 index 0000000..f43b335 --- /dev/null +++ b/backend/lure/generate.go @@ -0,0 +1,126 @@ +package lure + +import ( + "fmt" + + "github.com/phishingclub/phishingclub/random" +) + +// Algorithm selects how a generated code is produced. The resolver keys only on +// the stored code and never reads this, 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. + 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, stored and matched exactly as it appears +// here. +type Code struct { + Display string +} + +// NewCustomCode builds a Code from an operator supplied string, kept verbatim. +func NewCustomCode(s string) Code { + return Code{Display: s} +} + +// Generate returns a new random code of the given length. +func Generate(algorithm Algorithm, length int) (Code, error) { + codes, err := GenerateBatch(algorithm, length, 1) + if err != nil { + return Code{}, err + } + return codes[0], nil +} + +// maxRandomBlock caps a single entropy read so a large batch does not ask for +// one allocation the size of the whole batch. +const maxRandomBlock = 64 * 1024 + +// GenerateBatch returns n new random codes of the given length. +// +// Entropy is drawn in blocks covering many codes, because scheduling a large +// campaign asks for tens of thousands at once. Symbols are drawn by rejection +// sampling: a byte taken modulo an alphabet size that does not divide 256 would +// favour the symbols at the start and shrink the real key space. +func GenerateBatch(algorithm Algorithm, length int, n int) ([]Code, error) { + if !IsValidAlgorithm(algorithm) { + return nil, fmt.Errorf("unknown lure code algorithm: %s", algorithm) + } + if length < MinLength || length > MaxLength { + return nil, fmt.Errorf( + "lure code length must be between %d and %d, got %d", + MinLength, + MaxLength, + length, + ) + } + if n <= 0 { + return []Code{}, nil + } + alphabet := AlphabetFor(algorithm) + size := len(alphabet) + // largest multiple of the alphabet size within a byte range, above which a + // draw is discarded. an alphabet dividing 256 gives 256, which is why this is + // an int and the comparison below widens the byte rather than narrowing it. + limit := 256 / size * size + + // symbols wanted plus headroom for discarded draws. running short only costs + // another read, so this is a guess rather than a bound. + block := n*length + (n*length)/4 + 16 + if block > maxRandomBlock { + block = maxRandomBlock + } + + codes := make([]Code, 0, n) + out := make([]byte, 0, length) + buf := []byte{} + for len(codes) < n { + if len(buf) == 0 { + drawn, err := random.GenerateRandomBytes(block) + if err != nil { + return nil, fmt.Errorf("failed to generate lure code: %w", err) + } + buf = drawn + } + b := buf[0] + buf = buf[1:] + if int(b) >= limit { + continue + } + out = append(out, alphabet[int(b)%size]) + if len(out) == length { + // string copies, so out can be reused + codes = append(codes, Code{Display: string(out)}) + out = out[:0] + } + } + return codes, nil +} diff --git a/backend/lure/lure_test.go b/backend/lure/lure_test.go new file mode 100644 index 0000000..cf2b5be --- /dev/null +++ b/backend/lure/lure_test.go @@ -0,0 +1,226 @@ +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}, + // a single dot is allowed so a lure can end in 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}, + // a browser resolves a lone dot away before sending, and the resolver + // refuses a whole path carrying a doubled dot rather than cleaning it + {".", false}, + {"..", false}, + {"...", false}, + {"invoice..pdf", false}, + {"v1..2", false}, + // a single dot elsewhere in the segment is still fine + {".hidden", true}, + {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 reaches the URL 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 accept enough draws to + // terminate. an alphabet dividing 256 gives a limit of 256, which held in a + // byte would be zero and reject everything. + 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 + // never 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 TestGenerateBatch(t *testing.T) { + // a schedule run draws every code at once, so the batch must return exactly + // what was asked for and stay in its alphabet across entropy block refills + for _, algorithm := range []Algorithm{AlgorithmCrockford32, AlgorithmBase58} { + alphabet := AlphabetFor(algorithm) + codes, err := GenerateBatch(algorithm, DefaultLength, 5000) + if err != nil { + t.Fatalf("GenerateBatch(%s) failed: %v", algorithm, err) + } + if len(codes) != 5000 { + t.Fatalf("GenerateBatch(%s) gave %d codes, want 5000", algorithm, len(codes)) + } + seen := map[string]bool{} + for _, code := range codes { + if len(code.Display) != DefaultLength { + t.Fatalf("code %q is not %d characters", code.Display, DefaultLength) + } + for _, r := range code.Display { + if !strings.ContainsRune(alphabet, r) { + t.Fatalf("%s produced %q outside its alphabet: %q", algorithm, string(r), code.Display) + } + } + seen[code.Display] = true + } + // the allocator dedupes, so a batch need not be distinct, but a broken draw + // repeating one code shows up here + if len(seen) < 4990 { + t.Errorf("%s produced only %d distinct codes out of 5000", algorithm, len(seen)) + } + } +} + +func TestGenerateBatchRejectsBadInput(t *testing.T) { + if _, err := GenerateBatch("nope", DefaultLength, 1); err == nil { + t.Error("expected an error for an unknown algorithm") + } + if _, err := GenerateBatch(AlgorithmCrockford32, MinLength-1, 1); err == nil { + t.Error("expected an error for a length below the minimum") + } + codes, err := GenerateBatch(AlgorithmCrockford32, DefaultLength, 0) + if err != nil || len(codes) != 0 { + t.Errorf("GenerateBatch with n 0 = %v,%v want empty,nil", codes, err) + } +} + +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") + } +} diff --git a/backend/model/campaign.go b/backend/model/campaign.go index 4caa25f..12c95c9 100644 --- a/backend/model/campaign.go +++ b/backend/model/campaign.go @@ -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,16 @@ type Campaign struct { // webhooks configuration with per-webhook settings Webhooks nullable.Nullable[[]*CampaignWebhook] `json:"webhooks,omitempty"` + // snapshotted from the campaign template while the campaign holds no + // recipients. read only, see ToDBMap. + LureURLMode nullable.Nullable[string] `json:"lureURLMode"` + LureCodeAlgo nullable.Nullable[string] `json:"lureCodeAlgo"` + LureCodeLength nullable.Nullable[int] `json:"lureCodeLength"` + + // HasCustomLureCodes decides whether the recipient table shows the code + // column, which pagination makes impossible to derive from one page. + HasCustomLureCodes bool `json:"hasCustomLureCodes"` + // must not be set by a user NotableEventID nullable.Nullable[uuid.UUID] `json:"notableEventID"` NotableEventName string `json:"notableEventName"` @@ -551,10 +562,41 @@ func (c *Campaign) ToDBMap() map[string]any { m["jitter_max"] = v } } + // the lure settings are left out. a create or update request carries whatever + // a caller put in those fields, so the snapshot is written only by + // SetLureSettingsByID, from the template, at the first schedule. return m } +// LureSettings returns the snapshotted lure URL settings, falling back to the +// defaults for campaigns created before the columns existed. +// +// Each value is rechecked rather than trusted, so anything unusable becomes a +// default here instead of failing the schedule during allocation. +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 && data.IsValidLureURLMode(v) { + mode = v + } + if v, err := c.LureCodeAlgo.Get(); err == nil && lure.IsValidAlgorithm(lure.Algorithm(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 this campaign's lure URLs carry the recipient +// 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 { diff --git a/backend/model/campaignRecipient.go b/backend/model/campaignRecipient.go index 7fc6491..b9e3b68 100644 --- a/backend/model/campaignRecipient.go +++ b/backend/model/campaignRecipient.go @@ -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 in the lure URL, stored and matched byte for + // byte. 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,10 @@ func (c *CampaignRecipient) ToDBMap() map[string]any { m["notable_event_id"] = v } } + // the lure code is left out. callers load a whole recipient, change one + // field and write it back, so a code carried here would be restated on every + // such write and collide with the unique index once another recipient holds + // it. written only by Insert, SetLureCodeByID and ReleaseLureCodeByID. return m } diff --git a/backend/model/campaignTemplate.go b/backend/model/campaignTemplate.go index 6e684ce..e6b26fd 100644 --- a/backend/model/campaignTemplate.go +++ b/backend/model/campaignTemplate.go @@ -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,12 @@ type CampaignTemplate struct { URLPath nullable.Nullable[vo.URLPath] `json:"urlPath"` + // 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 +100,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 +286,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() diff --git a/backend/proxy/proxy.go b/backend/proxy/proxy.go index 8494266..aae3d08 100644 --- a/backend/proxy/proxy.go +++ b/backend/proxy/proxy.go @@ -325,7 +325,7 @@ func (m *ProxyHandler) initializeRequestContext(ctx context.Context, req *http.R } // check for campaign recipient id - campaignRecipientID, paramName := m.getCampaignRecipientIDFromURLParams(req) + campaignRecipientID, paramName := m.getCampaignRecipientIDFromURLParams(req, domain) reqCtx := &RequestContext{ PhishDomain: req.Host, @@ -3038,14 +3038,27 @@ func (m *ProxyHandler) sameSiteToString(sameSite http.SameSite) string { } } -func (m *ProxyHandler) getCampaignRecipientIDFromURLParams(req *http.Request) (*uuid.UUID, string) { +func (m *ProxyHandler) getCampaignRecipientIDFromURLParams( + req *http.Request, + domain *database.Domain, +) (*uuid.UUID, string) { ctx := req.Context() - campaignRecipient, paramName, err := server.GetCampaignRecipientFromURLParams( + // every path and query here mirrors the target site, so a code shaped segment + // or value could belong to the target rather than a recipient. once a session + // cookie exists the recipient is known, so both code forms go off: that ends + // the misattribution risk, which here also means an established session being + // torn down mid flow, and spares every subresource an extra lookup. a UUID + // cannot collide with target content by accident, so it stays on. + allowLureCodeLookup := !m.hasValidSessionCookie(req) + + campaignRecipient, match, err := server.GetCampaignRecipientFromURLParams( ctx, req, m.IdentifierRepository, m.CampaignRecipientRepository, + domain, + allowLureCodeLookup, ) if err != nil { m.logger.Errorw("failed to get identifiers for URL param extraction", "error", err) @@ -3056,8 +3069,15 @@ func (m *ProxyHandler) getCampaignRecipientIDFromURLParams(req *http.Request) (* return nil, "" } + // take a consumed code out of the path before anything downstream sees the + // URL. rewrite rules compare the path exactly and whatever is left is + // forwarded to the target. the query form is stripped later via 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 +3898,16 @@ func (m *ProxyHandler) IsValidProxyCookie(cookie string) bool { return m.isValidSessionCookie(cookie) } +// hasValidSessionCookie reports whether the request carries a live proxy +// session, meaning the recipient behind it is already 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 diff --git a/backend/repository/campaign.go b/backend/repository/campaign.go index 241f287..08a1764 100644 --- a/backend/repository/campaign.go +++ b/backend/repository/campaign.go @@ -1520,6 +1520,31 @@ func (r *Campaign) HasEvent( return count > 0, nil } +// SetLureSettingsByID writes the lure URL settings taken from the campaign +// template. The only path that writes them, because a create or update request +// carries whatever a caller put in those fields and they must come from the +// template instead. +func (r *Campaign) SetLureSettingsByID( + ctx context.Context, + id *uuid.UUID, + mode string, + algorithm string, + length int, +) error { + row := map[string]any{ + "lure_url_mode": mode, + "lure_code_algo": algorithm, + "lure_code_length": length, + } + AddUpdatedAt(row) + res := r.DB. + Model(&database.Campaign{}). + Where("id = ?", id). + Updates(row) + + return res.Error +} + // UpdateByID updates a campaign by id // does not update the campaign recipient groups and campaign recipients func (r *Campaign) UpdateByID( @@ -2131,6 +2156,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 } diff --git a/backend/repository/campaignRecipient.go b/backend/repository/campaignRecipient.go index 868f687..4edee8d 100644 --- a/backend/repository/campaignRecipient.go +++ b/backend/repository/campaignRecipient.go @@ -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 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,30 @@ func (r *CampaignRecipient) Insert( return &id, nil } +// SetLureCodeByID assigns an operator chosen code to one recipient. The only +// path that writes a code after insert, and it touches 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 +304,235 @@ func (r *CampaignRecipient) GetByCampaignRecipientID( return ToCampaignRecipient(&dbCampaignRecipient) } +// GetByLureCodeOnDomain gets a campaign recipient by the code carried in a +// request, but only when the code belongs to a campaign reachable on the domain +// serving it. +// +// The match is case sensitive, keeping Special-42 distinct from special-42 as +// base58 requires. A released code is null and so drops out without a separate +// predicate, which is also what stops an anonymized recipient's link working. +// +// The domain predicate keeps a guessed code from reaching across the instance. +// Codes are unique instance wide and short enough to guess, so without it a +// guess on any domain would resolve a recipient of any campaign, including +// another company's. A campaign is reachable on its template's own domain and on +// a proxy domain whose proxy the template uses for one of its pages. +func (r *CampaignRecipient) GetByLureCodeOnDomain( + ctx context.Context, + code string, + domain *database.Domain, +) (*model.CampaignRecipient, error) { + if code == "" || domain == nil { + return nil, gorm.ErrRecordNotFound + } + var dbCampaignRecipient database.CampaignRecipient + query := r.DB. + Model(&database.CampaignRecipient{}). + Joins(fmt.Sprintf( + "JOIN `%s` ON %s = %s", + database.CAMPAIGN_TABLE, + TableColumnID(database.CAMPAIGN_TABLE), + TableColumn(database.CAMPAIGN_RECIPIENT_TABLE_NAME, "campaign_id"), + )). + Joins(fmt.Sprintf( + "JOIN `%s` ON %s = %s", + database.CAMPAIGN_TEMPLATE_TABLE, + TableColumnID(database.CAMPAIGN_TEMPLATE_TABLE), + TableColumn(database.CAMPAIGN_TABLE, "campaign_template_id"), + )). + Where( + fmt.Sprintf("%s = ?", TableColumn(database.CAMPAIGN_RECIPIENT_TABLE_NAME, "lure_code")), + code, + ). + // restating the index predicate. equality already excludes nulls, so no + // result changes, but the planner matches the partial unique index without + // deriving it. this sits on the request path, where a scan is paid on + // every visit. + Where( + fmt.Sprintf("%s IS NOT NULL", TableColumn(database.CAMPAIGN_RECIPIENT_TABLE_NAME, "lure_code")), + ) + + if domain.ProxyID != nil { + // a proxy domain serves whichever template routes a page through that + // proxy, while the template's own domain still serves evasion and deny. + // the parentheses are written here rather than left to the builder: + // unparenthesised this would widen to match any campaign using the proxy + // regardless of the rest of the clause, and it is what confines a guessed + // code to one domain. + query = query.Where( + fmt.Sprintf( + "(%s = ? OR %s = ? OR %s = ? OR %s = ?)", + TableColumn(database.CAMPAIGN_TEMPLATE_TABLE, "domain_id"), + TableColumn(database.CAMPAIGN_TEMPLATE_TABLE, "before_landing_proxy_id"), + TableColumn(database.CAMPAIGN_TEMPLATE_TABLE, "landing_proxy_id"), + TableColumn(database.CAMPAIGN_TEMPLATE_TABLE, "after_landing_proxy_id"), + ), + domain.ID, + domain.ProxyID, + domain.ProxyID, + domain.ProxyID, + ) + } else { + query = query.Where( + fmt.Sprintf("%s = ?", TableColumn(database.CAMPAIGN_TEMPLATE_TABLE, "domain_id")), + domain.ID, + ) + } + + res := query. + Select(TableColumnAll(database.CAMPAIGN_RECIPIENT_TABLE_NAME)). + First(&dbCampaignRecipient) + + if res.Error != nil { + return nil, res.Error + } + return ToCampaignRecipient(&dbCampaignRecipient) +} + +// findTakenLureCodesChunkSize keeps each IN clause inside the sqlite bound +// variable limit, 999 on the most restrictive builds. +const findTakenLureCodesChunkSize = 500 + +// FindTakenLureCodes returns the subset of codes already claimed by a recipient +// whose code has not been released. +// +// Querying lure_code, the column carrying the uniqueness constraint, catches a +// collision with an operator written code here rather than through a failed +// insert part way through scheduling. Probing a whole batch costs a handful of +// queries instead of one insert and retry 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], + ). + Where( + fmt.Sprintf("%s IS NOT NULL", TableColumn(database.CAMPAIGN_RECIPIENT_TABLE_NAME, "lure_code")), + ). + Pluck("lure_code", &chunk) + + if res.Error != nil { + return nil, res.Error + } + taken = append(taken, chunk...) + } + return taken, nil +} + +// GetActiveByLureCode returns the recipient holding a code, so the owning +// campaign can be named when an operator tries to reuse it. +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, + ). + Where( + fmt.Sprintf("%s IS NOT NULL", TableColumn(database.CAMPAIGN_RECIPIENT_TABLE_NAME, "lure_code")), + ). + First(&dbCampaignRecipient) + + if res.Error != nil { + return nil, res.Error + } + return ToCampaignRecipient(&dbCampaignRecipient) +} + +// ReleaseLureCodeByID frees a recipient's code for reuse. Nulling rather than +// flagging takes it out of the unique index and stops it showing against a +// recipient who no longer owns it. The 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 +} + +// HasRecipientsByCampaignID reports whether the campaign still holds any +// recipient row. Scheduling reads it to decide whether the lure settings are +// settled: a campaign holding recipients has links out resolving through those +// rows. A non self managed reschedule deletes them first, which is what lets it +// pick up a template change, its old links being dead either way. +func (r *CampaignRecipient) HasRecipientsByCampaignID( + ctx context.Context, + campaignID *uuid.UUID, +) (bool, error) { + // campaign_id leads the campaign and recipient unique index, so this stops at + // the first entry rather than counting the campaign + ids := []string{} + res := r.DB. + Model(&database.CampaignRecipient{}). + Where( + fmt.Sprintf("%s = ?", TableColumn(database.CAMPAIGN_RECIPIENT_TABLE_NAME, "campaign_id")), + campaignID, + ). + Limit(1). + Pluck("id", &ids) + + if res.Error != nil { + return false, res.Error + } + return len(ids) > 0, nil +} + +// HasCustomLureCodesByCampaignID reports whether any recipient in the campaign +// carries an operator set code. The recipient table is paginated, so this cannot +// be derived from one page of rows. +func (r *CampaignRecipient) HasCustomLureCodesByCampaignID( + ctx context.Context, + campaignID *uuid.UUID, +) (bool, error) { + // the flag is a literal because sqlite chooses the partial index at prepare + // time, where a bound value is unknown. keep it in step with the predicate + // in database.CampaignRecipient.Migrate. campaign_id is plucked so the + // answer comes from the index without reading the row. + 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 = 1", TableColumn(database.CAMPAIGN_RECIPIENT_TABLE_NAME, "lure_code_custom")), + ). + Limit(1). + Pluck("campaign_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 +721,11 @@ func (r *CampaignRecipient) Anonymize( recipientID *uuid.UUID, anonymizedID *uuid.UUID, ) error { + // releasing here stops the recipient's lure link 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,6 +890,11 @@ 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, @@ -641,5 +909,7 @@ func ToCampaignRecipient(row *database.CampaignRecipient) (*model.CampaignRecipi Recipient: recipient, NotableEventID: notableEventID, NotableEventName: notableEventName, + LureCode: lureCode, + LureCodeCustom: nullable.NewNullableWithValue(row.LureCodeCustom), }, nil } diff --git a/backend/repository/campaignTemplate.go b/backend/repository/campaignTemplate.go index c9a72d3..8e118a5 100644 --- a/backend/repository/campaignTemplate.go +++ b/backend/repository/campaignTemplate.go @@ -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 } diff --git a/backend/server/shared.go b/backend/server/shared.go index 4dea6bd..4eef9a0 100644 --- a/backend/server/shared.go +++ b/backend/server/shared.go @@ -3,56 +3,187 @@ package server import ( "context" "net/http" + "net/url" + "strings" "github.com/google/uuid" + "github.com/phishingclub/phishingclub/database" + "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 decoded request path, with any +// trailing slash trimmed. Mail clients and message previews append one, so +// /account/4H7K9QM2XR3T/ must resolve the same as the link without it. +// +// Traversal is refused rather than cleaned, because a segment needing a clean is +// never a lure code. lure.IsCandidate refuses a doubled dot on the same terms, +// so a code that can be stored can always be resolved back. +func LastPathSegment(path string) (string, bool) { + if path == "" || strings.Contains(path, "..") { + 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 +} + +// lureCodeFromPath returns the trailing path segment of a request URL when it +// could be a lure code. +// +// URL.Path is already decoded, so an encoded separator has become a real one by +// the time it is read and would silently change which segment is last. RawPath +// is the only place that separator is still visible. +// +// Only the separator is refused, not every re encoding. A code may carry +// characters Go escapes in a path, such as a bracket or anything non ASCII, and +// each sets RawPath on its own. Refusing on RawPath alone would make all of +// those unreachable while IsValidCustom still accepted them. +func lureCodeFromPath(u *url.URL) (string, bool) { + if u == nil { + return "", false + } + if strings.Contains(u.RawPath, "%2f") || strings.Contains(u.RawPath, "%2F") { + return "", false + } + segment, ok := LastPathSegment(u.Path) + if !ok || !lure.IsCandidate(segment) { + return "", false + } + return segment, true +} + +// TrimLastPathSegment removes the final segment of a path, taking a consumed +// lure code back out of the URL before the request is forwarded on. +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 whatever the campaign's mode. The mode only decides +// which form is emitted, so changing it never breaks a delivered link. +// +// Query beats path. A query identifier states which recipient a request belongs +// to, while a path segment only looks like a code: a template whose URL path +// ends in /careers collides with any custom code holding that string. +// +// Both code forms resolve only against campaigns reachable on the serving +// domain. A code is short enough to guess, so without that a guess made on any +// domain would reach a recipient of any campaign, including another company's. +// +// allowLureCodeLookup turns the code forms off, leaving only the UUID. The proxy +// does that once a session cookie exists, because there every path and query +// mirrors the target site and the recipient is already known. A UUID cannot +// collide with target site content by accident, so it stays on. func GetCampaignRecipientFromURLParams( ctx context.Context, req *http.Request, identifierRepo *repository.Identifier, campaignRecipientRepo *repository.CampaignRecipient, -) (*model.CampaignRecipient, string, error) { + domain *database.Domain, + allowLureCodeLookup 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 } + // a code resolves only against the serving domain, so without one there is + // nothing to match it to + lookupCodes := allowLureCodeLookup && domain != nil + 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 + // split matching identifier params by whether they carry a UUID or a 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 lookupCodes && 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.GetByLureCodeOnDomain(ctx, param.value, domain) + if err == nil && campaignRecipient != nil { + return campaignRecipient, LureMatch{ParamName: param.name}, nil + } + } + + // nothing in the query matched, so read the last path segment as a code + if lookupCodes { + if segment, ok := lureCodeFromPath(req.URL); ok { + campaignRecipient, err := campaignRecipientRepo.GetByLureCodeOnDomain(ctx, segment, domain) + 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 } diff --git a/backend/server/shared_test.go b/backend/server/shared_test.go new file mode 100644 index 0000000..aed4542 --- /dev/null +++ b/backend/server/shared_test.go @@ -0,0 +1,123 @@ +package server + +import ( + "net/url" + "testing" + + "github.com/phishingclub/phishingclub/lure" +) + +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 is refused rather than cleaned + {"/a/../b", "", 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 TestLureCodeFromPath(t *testing.T) { + // URL.Path is already decoded, so how the path was written can only be judged + // on the parsed URL and not on a path string + cases := []struct { + raw string + want string + ok bool + }{ + {"https://example.com/4H7K9QM2XR3T", "4H7K9QM2XR3T", true}, + {"https://example.com/account/4H7K9QM2XR3T/", "4H7K9QM2XR3T", true}, + {"https://example.com/special-42", "special-42", true}, + {"https://example.com/", "", false}, + // an encoded separator decodes into a real one and would move which segment + // is last, so the request is refused + {"https://example.com/a%2Fb", "", false}, + {"https://example.com/a%2fb", "", false}, + // characters go escapes when re encoding set RawPath on their own, and + // IsValidCustom accepts them, so they must still resolve + {"https://example.com/invoice(1)", "invoice(1)", true}, + {"https://example.com/special!42", "special!42", true}, + {"https://example.com/a%5Bb%5D", "a[b]", true}, + {"https://example.com/caf%C3%A9-42", "café-42", true}, + // a redundant encoding resolves to the code it spells + {"https://example.com/%41BCDEF", "ABCDEF", true}, + // values IsCandidate rules out never reach a database probe + {"https://example.com/a%20b", "", false}, + {"https://example.com/..", "", false}, + {"https://example.com/invoice..pdf", "", false}, + } + for _, c := range cases { + u, err := url.Parse(c.raw) + if err != nil { + t.Fatalf("failed to parse %q: %v", c.raw, err) + } + got, ok := lureCodeFromPath(u) + if ok != c.ok || got != c.want { + t.Errorf("lureCodeFromPath(%q) = %q,%v want %q,%v", c.raw, got, ok, c.want, c.ok) + } + } +} + +func TestStorableCustomCodesResolveFromPath(t *testing.T) { + // the two rules live in different packages, so a value only one side accepts + // is a link that gets delivered and never resolves + for _, code := range []string{ + "special-42", + "Special_42", + "HR-Survey-2026", + "invoice.pdf", + ".hidden", + "invoice..pdf", + "v1..2", + "..", + ".", + "a b", + } { + u, err := url.Parse("https://example.com/login/" + code) + if err != nil { + t.Fatalf("failed to parse a URL carrying %q: %v", code, err) + } + got, ok := lureCodeFromPath(u) + storable := lure.IsValidCustom(code) + if storable != ok { + t.Errorf("IsValidCustom(%q) = %v but lureCodeFromPath = %v", code, storable, ok) + } + if ok && got != code { + t.Errorf("lureCodeFromPath returned %q for %q", got, code) + } + } +} + +func TestTrimLastPathSegment(t *testing.T) { + // a consumed code must leave the path before forwarding, or 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) + } + } +} diff --git a/backend/service/apiSender.go b/backend/service/apiSender.go index 27989f2..272c7e2 100644 --- a/backend/service/apiSender.go +++ b/backend/service/apiSender.go @@ -870,11 +870,9 @@ func (a *APISender) buildRequestWithCustomURL( } // override campaign URL if custom one is provided + // the only builder that knows about proxy first pages and path mode codes 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 diff --git a/backend/service/campaign.go b/backend/service/campaign.go index d30fe49..b3d9fb3 100644 --- a/backend/service/campaign.go +++ b/backend/service/campaign.go @@ -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,214 @@ func applyJitter(baseTime time.Time, jitterMin, jitterMax int, startBound, endBo return jitteredTime } +// lureCodeAllocator hands out codes drawn up front for one schedule run. A +// campaign not using codes gets an empty allocator, so its recipients keep +// resolving by campaign recipient UUID alone. +type lureCodeAllocator struct { + codes []lure.Code + next int + state lureCodeAllocatorState +} + +// lureCodeAllocatorState carries what a redraw needs. +type lureCodeAllocatorState struct { + // false for a query mode campaign, which separates wanting no codes from a + // batch that ran short + enabled bool + algorithm lure.Algorithm + length int +} + +// applyTo sets the next unused code on a recipient. +// +// Over drawing is harmless, an uninserted code is never claimed. Running short +// is not: the recipient would fall back to a query URL in a campaign whose point +// is the path form. The batch covers the worst case, so a shortfall is a bug. +func (a *lureCodeAllocator) applyTo(campaignRecipient *model.CampaignRecipient) error { + var display nullable.Nullable[string] + display.SetNull() + if a != nil && a.state.enabled { + if a.next >= len(a.codes) { + return errs.Wrap(errors.New( + "ran out of allocated lure codes while scheduling this campaign", + )) + } + display = nullable.NewNullableWithValue(a.codes[a.next].Display) + a.next++ + } + campaignRecipient.LureCode = display + return nil +} + +// 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, + state: lureCodeAllocatorState{ + enabled: true, + algorithm: algorithm, + length: length, + }, + }, nil +} + +// lureCodeInsertRetries bounds the redraws on insert. Allocation checks a code +// is free, but the insert lands later, so a concurrent schedule run or an +// operator setting a custom code can claim it in between. +const lureCodeInsertRetries = 3 + +// isLureCodeConflict reports whether an insert failed because the code was taken +// between allocation and insert. +// +// Error translation is off on the gorm session, so a duplicate key does not +// surface as gorm.ErrDuplicatedKey. Matching the message instead is narrowed to +// this column so an unrelated constraint is never swallowed and retried. +func isLureCodeConflict(err error) bool { + if err == nil { + return false + } + if errors.Is(err, gorm.ErrDuplicatedKey) { + return true + } + message := err.Error() + return strings.Contains(message, "UNIQUE constraint failed") && + strings.Contains(message, "campaign_recipients.lure_code") +} + +// insertScheduledRecipient inserts one scheduled recipient, redrawing its code if +// it was claimed between allocation and insert. The loop has no transaction to +// roll back, so a losing race would otherwise abort a half written schedule. +func (c *Campaign) insertScheduledRecipient( + ctx context.Context, + allocator *lureCodeAllocator, + campaignRecipient *model.CampaignRecipient, +) error { + for attempt := 0; ; attempt++ { + _, err := c.CampaignRecipientRepository.Insert(ctx, campaignRecipient) + if err == nil { + return nil + } + if attempt >= lureCodeInsertRetries || + !isLureCodeConflict(err) || + allocator == nil || + !allocator.state.enabled || + c.LureCodeService == nil { + return errs.Wrap(err) + } + c.Logger.Infow("lure code was claimed between allocation and insert, redrawing", + "attempt", attempt+1, + ) + codes, drawErr := c.LureCodeService.AllocateBatch( + ctx, + allocator.state.algorithm, + allocator.state.length, + 1, + ) + if drawErr != nil || len(codes) == 0 { + return errs.Wrap(err) + } + campaignRecipient.LureCode = nullable.NewNullableWithValue(codes[0].Display) + } +} + +// snapshotLureSettings copies the template's lure URL settings onto the campaign +// while it still holds no recipients. +// +// A campaign holding recipients has links out that resolve through those rows, +// so reading the template again could hand a new recipient a different URL form +// than the ones already delivered. That is the self managed case, where a +// reschedule keeps existing recipients. A non self managed reschedule deletes +// them all first and so does read the template again, which is safe because its +// recipients are recreated with new IDs and the old links die either way. +// +// Nothing here is fatal, so a lure setting cannot stop a campaign scheduling. A +// template that could not be read leaves the snapshot unwritten rather than +// settling on the defaults, because the write closes the question for good and a +// transient error would otherwise mark a path mode campaign as query mode with +// no way back while it holds recipients. +func (c *Campaign) snapshotLureSettings( + ctx context.Context, + session *model.Session, + campaign *model.Campaign, +) { + campaignID, err := campaign.ID.Get() + if err != nil { + return + } + hasRecipients, err := c.CampaignRecipientRepository.HasRecipientsByCampaignID( + ctx, + &campaignID, + ) + if err != nil { + // leaving the settings alone keeps the campaign consistent with whatever + // its recipients already resolve through + c.Logger.Errorw("failed to check for existing recipients, keeping lure settings", + "error", err, + ) + return + } + if hasRecipients { + return + } + + 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 || cTemplate == nil { + c.Logger.Errorw("could not read lure settings from template, leaving the snapshot unwritten", + "campaignID", campaignID.String(), + "templateID", templateID.String(), + "error", err, + ) + return + } + 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) + + if err := c.CampaignRepository.SetLureSettingsByID( + ctx, + &campaignID, + mode, + algorithm, + length, + ); err != nil { + // the in memory campaign still carries them, so this run allocates + // correctly even unpersisted + c.Logger.Errorw("failed to persist lure settings snapshot", "error", err) + } +} + func (c *Campaign) schedule( ctx context.Context, session *model.Session, @@ -329,6 +539,9 @@ func (c *Campaign) schedule( if !isAuthorized { return errs.ErrAuthorizationFailed } + // settle the lure settings before any 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{} @@ -425,8 +638,15 @@ func (c *Campaign) schedule( rid := recp.ID.MustGet() recipientIDs[i] = &rid } + // enough for the worst case where every supplied recipient is new, unused + // draws are discarded. before the delete below, so an exhausted code space + // leaves the schedule untouched rather than half applied. + allocator, err := c.newLureCodeAllocator(ctx, campaign, len(recipients)) + if err != nil { + return errs.Wrap(err) + } // c.Logger.Debugw("keeping recpient IDs", recipientIDs) - err := c.CampaignRecipientRepository.DeleteRecipientsNotIn( + err = c.CampaignRecipientRepository.DeleteRecipientsNotIn( ctx, &campaignID, recipientIDs, @@ -462,8 +682,12 @@ func (c *Campaign) schedule( CampaignID: nullable.NewNullableWithValue(campaignID), SelfManaged: nullable.NewNullableWithValue(true), } + if err := allocator.applyTo(campaignRecipients[i]); err != nil { + c.Logger.Errorw("failed to allocate lure code", "error", err) + return err + } // save campaign-recipient - _, err = c.CampaignRecipientRepository.Insert(ctx, campaignRecipients[i]) + err = c.insertScheduledRecipient(ctx, allocator, campaignRecipients[i]) if err != nil { c.Logger.Errorw("failed to create campaign", "error", err) return errs.Wrap(err) @@ -503,6 +727,13 @@ func (c *Campaign) schedule( } scheduledEvent := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_SCHEDULED] + // before any row is written, 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,7 +760,11 @@ func (c *Campaign) schedule( SendAt: nullable.NewNullableWithValue(jitteredStartAt), NotableEventID: nullable.NewNullableWithValue(*scheduledEvent), } - _, err := c.CampaignRecipientRepository.Insert(ctx, campaignRecipient) + if err := allocator.applyTo(campaignRecipient); err != nil { + c.Logger.Errorw("failed to allocate lure code", "error", err) + return err + } + err := c.insertScheduledRecipient(ctx, allocator, campaignRecipient) if err != nil { c.Logger.Errorw("failed to create campaign", "error", err) return errs.Wrap(err) @@ -606,7 +841,11 @@ func (c *Campaign) schedule( SendAt: nullable.NewNullableWithValue(jitteredTime), NotableEventID: nullable.NewNullableWithValue(*scheduledEvent), } - _, err := c.CampaignRecipientRepository.Insert(ctx, campaignRecipient) + if err := allocator.applyTo(campaignRecipient); err != nil { + c.Logger.Errorw("failed to allocate lure code", "error", err) + return err + } + err := c.insertScheduledRecipient(ctx, allocator, campaignRecipient) if err != nil { c.Logger.Errorw("failed to create campaign", "error", err) return errs.Wrap(err) @@ -652,8 +891,12 @@ func (c *Campaign) schedule( SendAt: nullable.NewNullableWithValue(jitteredSentAt), NotableEventID: nullable.NewNullableWithValue(*scheduledEvent), } + if err := allocator.applyTo(campaignRecipients[i]); err != nil { + c.Logger.Errorw("failed to allocate lure code", "error", err) + return err + } // save - _, err = c.CampaignRecipientRepository.Insert(ctx, campaignRecipients[i]) + err = c.insertScheduledRecipient(ctx, allocator, campaignRecipients[i]) if err != nil { c.Logger.Errorw("failed to create campaign", "error", err) return errs.Wrap(err) @@ -863,6 +1106,13 @@ 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 this cannot be derived from one page + 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 +2892,8 @@ 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 - } + // the only builder that knows about proxy first pages and path mode codes + (*t)["URL"] = customCampaignURL // build per-recipient template funcs so that {{MicrosoftDeviceCode}} resolves // to a real device code for this campaign recipient. @@ -3736,10 +3983,32 @@ 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. +// +// Holding a code is signal enough for the path form without consulting the +// campaign: a generated code exists only for a path mode campaign, and an +// operator set code is a deliberate request for that exact link. 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 +4081,142 @@ func (c *Campaign) getPhishingDomainForProxy(ctx context.Context, proxy *model.P } // SetSentAtByCampaignRecipientID sets the sent at time for a recipient +// ErrLureCodeTaken is returned when the 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 guessing it reaches the +// landing page rendered for that recipient. That is the trade for a link that +// has to be read aloud or typed, and why this is a per recipient action rather +// than a campaign wide setting. +// +// A code held by another recipient fails with a conflict describing the owner. +// Passing reclaim releases that older code first, so 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, cannot be . or contain .. and cannot contain spaces or %s", + lure.MaxCustomLength, + lure.DisallowedCustomCharacters, + ), + ) + } + 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) + // 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 instance wide, so the holder may belong to another + // company. naming it would disclose that company's campaign to someone + // who only guessed a code, so only a shared company gets details. + if companyIDsEqual(campaign.CompanyID, holder.Campaign.CompanyID) { + 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 +4990,8 @@ 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 - } + // the only builder that knows about proxy first pages and path mode codes + (*t)["URL"] = customCampaignURL // custom headers support the same per recipient variables as the subject and body applyCustomSMTPHeaders(m, smtpConfig.Headers, t, recipientDeviceFuncs, c.Logger) diff --git a/backend/service/campaignTemplate.go b/backend/service/campaignTemplate.go index 3b0cdc1..ddf864c 100644 --- a/backend/service/campaignTemplate.go +++ b/backend/service/campaignTemplate.go @@ -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("")) } + // default to the 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) diff --git a/backend/service/lureCode.go b/backend/service/lureCode.go new file mode 100644 index 0000000..8770c94 --- /dev/null +++ b/backend/service/lureCode.go @@ -0,0 +1,98 @@ +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 the redraws when some drawn codes are already taken. +// Each round clears all but a vanishing fraction of a healthy space, so running +// out means the space is too small rather than the draw 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 batch is drawn up front and checked in chunked queries rather than +// inserting each code and retrying on conflict, so a ten thousand recipient +// campaign costs a couple of dozen queries. +// +// Running out of rounds is the only exhaustion signal. 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 on the code, the column uniqueness sits on, so internal duplicates + // are rejected on the same terms the database will + pool := make(map[string]lure.Code, n) + for round := 0; round < allocateRounds; round++ { + // the shortfall in one pass. this repeats only for codes the map rejected + // as internal duplicates + for len(pool) < n { + codes, err := lure.GenerateBatch(algorithm, length, n-len(pool)) + if err != nil { + return nil, errs.Wrap(err) + } + for _, code := range codes { + 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, + ), + ) +} diff --git a/backend/service/templateService.go b/backend/service/templateService.go index 156e5ef..e4617a5 100644 --- a/backend/service/templateService.go +++ b/backend/service/templateService.go @@ -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,9 @@ func (t *Template) CreatePhishingPageWithCampaignAndRecipient( queryParams := parsedURL.Query() + // the page flow url keeps the query form even in path mode, because the + // encrypted state param driving the next page travels in the query string + // regardless. path mode covers the delivered link, the one a recipient reads. // only add campaign parameters if they don't already exist if !queryParams.Has(urlIdentifier) { queryParams.Set(urlIdentifier, id) diff --git a/backend/vo/http.go b/backend/vo/http.go index 273e93a..131f792 100644 --- a/backend/vo/http.go +++ b/backend/vo/http.go @@ -133,7 +133,11 @@ type URLPath struct { inner string } -// NewURLPath creates a new URL path +// NewURLPath creates a new URL path. +// +// The leading slash is added because callers write the path straight after the +// domain, where hello would give https://example.comhello. Stored rows are read +// back through here and normalised too. An empty path already joins correctly. func NewURLPath(s string) (*URLPath, error) { s = strings.TrimSpace(s) p, err := url.Parse(s) @@ -143,8 +147,12 @@ func NewURLPath(s string) (*URLPath, error) { "URLPath", ) } + path := p.Path + if path != "" && !strings.HasPrefix(path, "/") { + path = "/" + path + } return &URLPath{ - inner: p.Path, + inner: path, }, nil } diff --git a/frontend/src/lib/api/api.js b/frontend/src/lib/api/api.js index 9a1fa5f..0a3702b 100644 --- a/frontend/src/lib/api/api.js +++ b/frontend/src/lib/api/api.js @@ -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} + */ + 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} */ 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} */ 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 }); }, diff --git a/frontend/src/routes/campaign-template/+page.svelte b/frontend/src/routes/campaign-template/+page.svelte index 9a8c93a..09a5c0f 100644 --- a/frontend/src/routes/campaign-template/+page.svelte +++ b/frontend/src/routes/campaign-template/+page.svelte @@ -66,7 +66,65 @@ 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))); + }; + + /** @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`; + }; + + // mirrors the server, which stores the path with the slash added + /** @param {string} path */ + const withLeadingSlash = (path) => (path && !path.startsWith('/') ? `/${path}` : path); + + // shows the delivered link so the choice is concrete + /** @param {Object} values */ + const lureURLExample = (values) => { + const path = withLeadingSlash(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 +409,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 +451,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 +513,10 @@ apiSender: null, urlIdentifier: 'id', stateIdentifier: 'session', - urlPath: '' + urlPath: '', + lureURLMode: 'query', + lureCodeAlgo: 'crockford32', + lureCodeLength: 12 }; modalError = ''; showAdvancedOptions = false; @@ -562,6 +629,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 +641,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 +1231,62 @@ Simulation URLs to allow:\n${allowListingData.simulationUrl}\n placeholder="/employee/login">URL Path +
+ +

+ {lureURLExample(formValues)} +

+
+ {#if formValues.lureURLMode === 'path'} +
+ ({ + value, + label: spec.label + }))}>Code format + {#if LURE_CODE_ALGOS[formValues.lureCodeAlgo]} +

+ {LURE_CODE_ALGOS[formValues.lureCodeAlgo].note} +

+ {/if} +
+
+ Code length (6 to 16) + {#if lureCodeKeyspace(formValues.lureCodeAlgo, Number(formValues.lureCodeLength))} +

+ {lureCodeKeyspace( + formValues.lureCodeAlgo, + Number(formValues.lureCodeLength) + )} +

+ {/if} +
+ {/if}
Query param key State param key recipientGroupMap.byKey(id)); campaign.notableEventName = t.notableEventName; campaign.scheduleAt = t.scheduleAt ?? null; + // whether any recipient carries an operator set code. the recipient + // table is paginated, so it cannot be derived from the rows on screen + campaign.hasCustomLureCodes = !!t.hasCustomLureCodes; + campaign.lureURLMode = t.lureURLMode ?? 'query'; if (t.sendStartAt === null && t.sendEndAt === null) { isSelfManaged = true; } @@ -671,6 +686,114 @@ sendMessageRecipient = null; }; + const LURE_CODE_MAX_LENGTH = 64; + + const LURE_CODE_DISALLOWED = `/ \\ % ? # " ' < >`; + + // mirrors lure.IsCandidate on the server. names the character that failed + // rather than restating every rule, which sits under the field. + /** @param {string} value @returns {string} empty when the value can be used */ + const validateLureCode = (value) => { + if (!value) { + return 'Enter a lure URL.'; + } + if (value.length > LURE_CODE_MAX_LENGTH) { + return `Too long: ${value.length} characters, the maximum is ${LURE_CODE_MAX_LENGTH}.`; + } + // a lone dot names a directory, which the browser resolves away before + // the request is sent, so it could never arrive back + if (value === '.') { + return 'Cannot be . on its own, a browser resolves that away.'; + } + // a doubled dot anywhere makes the whole path unresolvable, so a code + // carrying one would be delivered and never work + if (value.includes('..')) { + return 'Cannot contain .. anywhere, the link would never resolve.'; + } + const disallowed = value.match(/[/%?#\\"'<>]/); + if (disallowed) { + return `Cannot contain: ${disallowed[0]}`; + } + for (const character of value) { + const code = character.codePointAt(0) ?? 0; + if (code === 0x20) { + return 'Cannot contain spaces.'; + } + if (code <= 0x20 || code === 0x7f) { + return 'Cannot contain tabs, line breaks or other control characters.'; + } + } + return ''; + }; + + // path the template puts in front of the code, used for the preview + $: campaignURLPath = (campaign?.template?.urlPath || '').replace(/\/$/, ''); + + // a path mode campaign gives every recipient a code, so the column is worth + // showing even before anyone sets one by hand + $: showLureCodeColumn = campaign.hasCustomLureCodes || campaign.lureURLMode === 'path'; + + /** @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; + } + const invalid = validateLureCode(lureCodeValue); + if (invalid) { + lureCodeError = invalid; + 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(); + // the campaign carries what decides the lure URL column, so it reloads + // alongside the rows + await Promise.all([setCampaign(), 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 +2306,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' }, + ...(showLureCodeColumn ? [{ column: 'Lure URL', size: 'small' }] : []) ]} sortable={[ 'First name', @@ -2277,6 +2401,9 @@ + {#if showLureCodeColumn} + + {/if} {#if !campaign.sentAt} @@ -2327,6 +2454,27 @@ : ''} on:click={() => onClickCopyURL(recp.id)} /> + showLureCodeModal(recp)} + /> + + {#if lureCodeRecipient} +
+
+

{lureCodeRecipient.name}

+

{lureCodeRecipient.email}

+
+ + Lure URL + +

+ {lureCodeValue + ? `https://${campaign.template?.domain?.name ?? 'domain'}${campaignURLPath}/${lureCodeValue}` + : 'Matched exactly as written, so case and separators are kept.'} +

+ +

+ Up to {LURE_CODE_MAX_LENGTH} characters. Cannot contain spaces, + .. + or + {LURE_CODE_DISALLOWED} +

+ + {#if lureCodeRecipient.sentAt} +

+ Already sent on {new Date(lureCodeRecipient.sentAt).toLocaleString()}. Changing this + stops the link the recipient is holding from working. +

+ {/if} + + {#if lureCodeError} +
+ +
+ {/if} + + {#if lureCodeConflict} +
+

+ {#if lureCodeConflict.campaignName} + Already used by campaign {lureCodeConflict.campaignName}{lureCodeConflict.isClosed ? ' (closed)' : ' (still running)'}. + {:else} + + Already used by a campaign outside this company. + {/if} +

+

+ Reclaiming frees it for this recipient. Anyone still holding the old link will land + in this campaign instead. +

+ +
+ {/if} + +
+ + +
+
+ {/if} +
+