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

+ {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 !!value && value.length <= 64 && !/[/%?#\\\s]/.test(value); + + // path the template puts in front of the code, used for the preview + $: campaignURLPath = (campaign?.template?.urlPath || '').replace(/\/$/, ''); + + /** @param {Object} recp */ + const showLureCodeModal = (recp) => { + lureCodeRecipient = { + id: recp.id, + name: `${recp.recipient?.firstName || ''} ${recp.recipient?.lastName || ''}`.trim(), + email: recp.recipient?.email, + sentAt: recp.sentAt, + current: recp.lureCode || '' + }; + lureCodeValue = recp.lureCode || ''; + lureCodeError = ''; + lureCodeConflict = null; + isLureCodeModalVisible = true; + }; + + const closeLureCodeModal = () => { + isLureCodeModalVisible = false; + lureCodeRecipient = null; + lureCodeValue = ''; + lureCodeError = ''; + lureCodeConflict = null; + }; + + /** @param {boolean} reclaim */ + const submitLureCode = async (reclaim = false) => { + if (!lureCodeRecipient) { + return; + } + if (!isLureCodeValid(lureCodeValue)) { + lureCodeError = 'Up to 64 characters. Cannot contain a slash, space, %, ? or #.'; + return; + } + lureCodeSubmitting = true; + lureCodeError = ''; + try { + const res = await api.campaign.setLureCode(lureCodeRecipient.id, lureCodeValue, reclaim); + if (!res.success) { + // 409 carries the campaign currently holding the code so the + // operator can decide whether to take it over + if (res.statusCode === 409) { + lureCodeConflict = res.data || {}; + lureCodeError = res.error || 'Lure code is already in use'; + return; + } + lureCodeError = res.error || 'Failed to set lure URL'; + return; + } + addToast('Lure URL updated', 'Success'); + closeLureCodeModal(); + await refreshCampaignRecipients(); + } catch (e) { + lureCodeError = 'Failed to set lure URL'; + console.error('failed to set lure code', e); + } finally { + lureCodeSubmitting = false; + } + }; + /** @param {string} campaignRecipientID @param {Object} recipient */ const showSetAsSentModal = (campaignRecipientID, recipient) => { setAsSentRecipient = { @@ -2183,7 +2259,8 @@ { column: 'Status', size: 'small' }, { column: 'Send at', title: 'Scheduled', size: 'small' }, { column: 'Sent at', title: 'Delivered', size: 'small' }, - { column: 'Cancelled at', size: 'small' } + { column: 'Cancelled at', size: 'small' }, + ...(campaign.hasCustomLureCodes ? [{ column: 'Lure URL', size: 'small' }] : []) ]} sortable={[ 'First name', @@ -2277,6 +2354,9 @@ + {#if campaign.hasCustomLureCodes} + + {/if} {#if !campaign.sentAt} @@ -2327,6 +2407,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.'} +

+ + {#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 in another company{lureCodeConflict.isClosed + ? ' (closed)' + : ' (still running)'}. + {/if} +

+

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

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