mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-07-30 23:47:23 +02:00
add path param lure customization
Signed-off-by: RonniSkansing <rskansing@gmail.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package data
|
||||
|
||||
const (
|
||||
// LureURLModeQuery carries the recipient identifier as a query parameter,
|
||||
// for example https://example.com/login?id=<uuid>.
|
||||
LureURLModeQuery = "query"
|
||||
// LureURLModePath carries the recipient identifier as the last path
|
||||
// segment, for example https://example.com/login/4H7K9QM2XR3T. This form
|
||||
// survives being read aloud or sent over SMS, where a query string with a
|
||||
// UUID does not.
|
||||
LureURLModePath = "path"
|
||||
)
|
||||
|
||||
// IsValidLureURLMode reports whether mode is a known lure URL mode.
|
||||
func IsValidLureURLMode(mode string) bool {
|
||||
switch mode {
|
||||
case LureURLModeQuery, LureURLModePath:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -21,6 +21,18 @@ type CampaignTemplate struct {
|
||||
|
||||
URLPath string `gorm:"not null;default:'';index"`
|
||||
|
||||
// LureURLMode selects how a delivered lure URL carries the recipient
|
||||
// identifier. "query" appends ?id=<uuid>, "path" appends /<code> after
|
||||
// URLPath. The resolver accepts both forms regardless of this setting, so
|
||||
// changing it never breaks links that are already out.
|
||||
LureURLMode string `gorm:"not null;default:'query'"`
|
||||
// LureCodeAlgo names the generator that allocates codes for campaigns
|
||||
// created from this template.
|
||||
LureCodeAlgo string `gorm:"not null;default:'crockford32'"`
|
||||
// LureCodeLength is the number of characters in a generated code. Shorter
|
||||
// codes are easier to read aloud and easier to guess.
|
||||
LureCodeLength int `gorm:"not null;default:12"`
|
||||
|
||||
// IsUsable indicates if a template is usable based on if it has all the required
|
||||
// data such as domainID, landingPage and etc to be used in a campaign
|
||||
IsUsable bool `gorm:"not null;default:false;index"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/oapi-codegen/nullable"
|
||||
"github.com/phishingclub/phishingclub/data"
|
||||
"github.com/phishingclub/phishingclub/errs"
|
||||
"github.com/phishingclub/phishingclub/lure"
|
||||
"github.com/phishingclub/phishingclub/validate"
|
||||
"github.com/phishingclub/phishingclub/vo"
|
||||
)
|
||||
@@ -54,6 +57,13 @@ type CampaignTemplate struct {
|
||||
|
||||
URLPath nullable.Nullable[vo.URLPath] `json:"urlPath"`
|
||||
|
||||
// lure URL settings. These are defaults, snapshotted onto a campaign when it
|
||||
// is scheduled, so editing them here never changes a campaign already
|
||||
// running.
|
||||
LureURLMode nullable.Nullable[string] `json:"lureURLMode"`
|
||||
LureCodeAlgo nullable.Nullable[string] `json:"lureCodeAlgo"`
|
||||
LureCodeLength nullable.Nullable[int] `json:"lureCodeLength"`
|
||||
|
||||
EmailID nullable.Nullable[uuid.UUID] `json:"emailID"`
|
||||
Email *Email `json:"email"`
|
||||
|
||||
@@ -91,6 +101,22 @@ func (c *CampaignTemplate) Validate() error {
|
||||
}
|
||||
// URLPath is optional, no validation needed
|
||||
|
||||
if v, err := c.LureURLMode.Get(); err == nil && !data.IsValidLureURLMode(v) {
|
||||
return errs.NewValidationError(
|
||||
errors.New("lure URL mode must be query or path"),
|
||||
)
|
||||
}
|
||||
if v, err := c.LureCodeAlgo.Get(); err == nil && !lure.IsValidAlgorithm(lure.Algorithm(v)) {
|
||||
return errs.NewValidationError(
|
||||
errors.New("unknown lure code algorithm"),
|
||||
)
|
||||
}
|
||||
if v, err := c.LureCodeLength.Get(); err == nil && (v < lure.MinLength || v > lure.MaxLength) {
|
||||
return errs.NewValidationError(
|
||||
fmt.Errorf("lure code length must be between %d and %d", lure.MinLength, lure.MaxLength),
|
||||
)
|
||||
}
|
||||
|
||||
// validate that only one type is set per stage
|
||||
// before landing page: can have neither (optional), or one type, but not both
|
||||
_, errBeforePage := c.BeforeLandingPageID.Get()
|
||||
@@ -261,6 +287,24 @@ func (c *CampaignTemplate) ToDBMap() map[string]any {
|
||||
}
|
||||
}
|
||||
}
|
||||
if c.LureURLMode.IsSpecified() {
|
||||
m["lure_url_mode"] = data.LureURLModeQuery
|
||||
if v, err := c.LureURLMode.Get(); err == nil && data.IsValidLureURLMode(v) {
|
||||
m["lure_url_mode"] = v
|
||||
}
|
||||
}
|
||||
if c.LureCodeAlgo.IsSpecified() {
|
||||
m["lure_code_algo"] = string(lure.DefaultAlgorithm)
|
||||
if v, err := c.LureCodeAlgo.Get(); err == nil && lure.IsValidAlgorithm(lure.Algorithm(v)) {
|
||||
m["lure_code_algo"] = v
|
||||
}
|
||||
}
|
||||
if c.LureCodeLength.IsSpecified() {
|
||||
m["lure_code_length"] = lure.DefaultLength
|
||||
if v, err := c.LureCodeLength.Get(); err == nil && v >= lure.MinLength && v <= lure.MaxLength {
|
||||
m["lure_code_length"] = v
|
||||
}
|
||||
}
|
||||
|
||||
_, errDomain := c.DomainID.Get()
|
||||
_, errSMTP := c.SMTPConfigurationID.Get()
|
||||
|
||||
+28
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -953,6 +953,9 @@ func ToCampaignTemplate(row *database.CampaignTemplate) (*model.CampaignTemplate
|
||||
StateIdentifierID: stateIdentifierID,
|
||||
StateIdentifier: stateIdentifier,
|
||||
URLPath: urlPath,
|
||||
LureURLMode: nullable.NewNullableWithValue(row.LureURLMode),
|
||||
LureCodeAlgo: nullable.NewNullableWithValue(row.LureCodeAlgo),
|
||||
LureCodeLength: nullable.NewNullableWithValue(row.LureCodeLength),
|
||||
IsUsable: isUsable,
|
||||
}, nil
|
||||
}
|
||||
|
||||
+130
-26
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -870,11 +870,11 @@ func (a *APISender) buildRequestWithCustomURL(
|
||||
}
|
||||
|
||||
// override campaign URL if custom one is provided
|
||||
// GetLandingPageURLByCampaignRecipientID is the single builder that knows
|
||||
// about proxy first pages and about path mode lure codes, so its result is
|
||||
// always the one to use
|
||||
if customCampaignURL != "" {
|
||||
templateURL := fmt.Sprintf("https://%s%s?%s=%s", domainName, urlPath, urlKey, campaignRecipient.ID.MustGet().String())
|
||||
if customCampaignURL != templateURL {
|
||||
(*t)["URL"] = customCampaignURL
|
||||
}
|
||||
(*t)["URL"] = customCampaignURL
|
||||
}
|
||||
|
||||
// setup headers
|
||||
|
||||
+300
-13
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -911,6 +911,24 @@ export class API {
|
||||
return await getJSON(this.getPath(`/campaign/recipient/${campaignRecipientID}/url`));
|
||||
},
|
||||
|
||||
/**
|
||||
* Set an operator chosen lure code for a single campaign recipient.
|
||||
*
|
||||
* Responds 409 with the owning campaign when the code is already in use.
|
||||
* Repeat the call with reclaim true to take it over.
|
||||
*
|
||||
* @param {string} campaignRecipientID
|
||||
* @param {string} code
|
||||
* @param {boolean} [reclaim]
|
||||
* @return {Promise<ApiResponse>}
|
||||
*/
|
||||
setLureCode: async (campaignRecipientID, code, reclaim = false) => {
|
||||
return await putJSON(this.getPath(`/campaign/recipient/${campaignRecipientID}/lure-code`), {
|
||||
code,
|
||||
reclaim
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete all device codes for a campaign so every recipient gets a fresh
|
||||
* code (and picks up any proxy change) on their next page visit.
|
||||
@@ -1088,6 +1106,9 @@ export class API {
|
||||
* @param {string} template.stateIdentifierID
|
||||
* @param {string} template.urlPath
|
||||
* @param {string} template.emailID
|
||||
* @param {string} template.lureURLMode
|
||||
* @param {string} template.lureCodeAlgo
|
||||
* @param {number} template.lureCodeLength
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
create: async ({
|
||||
@@ -1106,7 +1127,10 @@ export class API {
|
||||
stateIdentifierID,
|
||||
afterLandingPageRedirectURL,
|
||||
emailID: emailID,
|
||||
urlPath: urlPath
|
||||
urlPath: urlPath,
|
||||
lureURLMode,
|
||||
lureCodeAlgo,
|
||||
lureCodeLength
|
||||
}) => {
|
||||
return await postJSON(this.getPath('/campaign/template'), {
|
||||
name: name,
|
||||
@@ -1124,7 +1148,10 @@ export class API {
|
||||
urlIdentifierID: urlIdentifierID,
|
||||
stateIdentifierID: stateIdentifierID,
|
||||
emailID: emailID,
|
||||
urlPath: urlPath
|
||||
urlPath: urlPath,
|
||||
lureURLMode: lureURLMode,
|
||||
lureCodeAlgo: lureCodeAlgo,
|
||||
lureCodeLength: lureCodeLength
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1149,6 +1176,9 @@ export class API {
|
||||
* @param {string} template.urlIdentifierID
|
||||
* @param {string} template.stateIdentifierID
|
||||
* @param {string} template.urlPath
|
||||
* @param {string} template.lureURLMode
|
||||
* @param {string} template.lureCodeAlgo
|
||||
* @param {number} template.lureCodeLength
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
update: async ({
|
||||
@@ -1168,7 +1198,10 @@ export class API {
|
||||
emailID: emailID,
|
||||
urlIdentifierID: urlIdentifierID,
|
||||
stateIdentifierID: stateIdentifierID,
|
||||
urlPath: urlPath
|
||||
urlPath: urlPath,
|
||||
lureURLMode,
|
||||
lureCodeAlgo,
|
||||
lureCodeLength
|
||||
}) => {
|
||||
return await postJSON(this.getPath(`/campaign/template/${id}`), {
|
||||
name: name,
|
||||
@@ -1186,7 +1219,10 @@ export class API {
|
||||
emailID: emailID,
|
||||
urlIdentifierID: urlIdentifierID,
|
||||
stateIdentifierID: stateIdentifierID,
|
||||
urlPath: urlPath
|
||||
urlPath: urlPath,
|
||||
lureURLMode: lureURLMode,
|
||||
lureCodeAlgo: lureCodeAlgo,
|
||||
lureCodeLength: lureCodeLength
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -66,7 +66,62 @@
|
||||
apiSender: null,
|
||||
urlIdentifier: 'id',
|
||||
stateIdentifier: 'session',
|
||||
urlPath: ''
|
||||
urlPath: '',
|
||||
lureURLMode: 'query',
|
||||
lureCodeAlgo: 'crockford32',
|
||||
lureCodeLength: 12
|
||||
};
|
||||
|
||||
// codes are matched exactly, so the choice is about which glyphs appear and
|
||||
// how many characters are needed, not about how forgiving matching is
|
||||
const LURE_CODE_ALGOS = {
|
||||
crockford32: {
|
||||
label: 'Crockford base32',
|
||||
symbols: 32,
|
||||
sample: '4H7K9QM2XR3T',
|
||||
note: 'Upper case only, without I L O U.'
|
||||
},
|
||||
base58: {
|
||||
label: 'Base58',
|
||||
symbols: 58,
|
||||
sample: 'zQ8fRt2mKp9x',
|
||||
note: 'Mixed case, without 0 O I l.'
|
||||
}
|
||||
};
|
||||
|
||||
const LURE_CODE_MIN_LENGTH = 6;
|
||||
const LURE_CODE_MAX_LENGTH = 16;
|
||||
|
||||
// the backend rejects anything outside these bounds, so correct it here
|
||||
// rather than bouncing the whole form back with a validation error
|
||||
/** @param {string|number} value */
|
||||
const clampLureCodeLength = (value) => {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) {
|
||||
return 12;
|
||||
}
|
||||
return Math.min(LURE_CODE_MAX_LENGTH, Math.max(LURE_CODE_MIN_LENGTH, Math.round(n)));
|
||||
};
|
||||
|
||||
// size of the code space, which depends on how many symbols the algorithm uses
|
||||
/** @param {string} algo @param {number} length */
|
||||
const lureCodeKeyspace = (algo, length) => {
|
||||
const spec = LURE_CODE_ALGOS[algo];
|
||||
if (!spec || !length || length < LURE_CODE_MIN_LENGTH || length > LURE_CODE_MAX_LENGTH) {
|
||||
return '';
|
||||
}
|
||||
return `${(spec.symbols ** length).toLocaleString('en-US', { maximumFractionDigits: 0 })} combinations`;
|
||||
};
|
||||
|
||||
// shape of the link that gets delivered, so the choice is concrete
|
||||
/** @param {Object} values */
|
||||
const lureURLExample = (values) => {
|
||||
const path = values.urlPath || '';
|
||||
if (values.lureURLMode === 'path') {
|
||||
const sample = LURE_CODE_ALGOS[values.lureCodeAlgo]?.sample ?? '4H7K9QM2XR3T';
|
||||
return `https://domain${path.replace(/\/$/, '')}/${sample}`;
|
||||
}
|
||||
return `https://domain${path}?${values.urlIdentifier || 'id'}=6ba7b810-9dad-11d1-80b4-00c04fd430c8`;
|
||||
};
|
||||
|
||||
let contextCompanyID = null;
|
||||
@@ -351,6 +406,9 @@
|
||||
urlIdentifierID: identifierMap.byValueOrNull(formValues.urlIdentifier),
|
||||
stateIdentifierID: identifierMap.byValueOrNull(formValues.stateIdentifier),
|
||||
urlPath: formValues.urlPath || '',
|
||||
lureURLMode: formValues.lureURLMode,
|
||||
lureCodeAlgo: formValues.lureCodeAlgo,
|
||||
lureCodeLength: clampLureCodeLength(formValues.lureCodeLength),
|
||||
companyID: contextCompanyID
|
||||
});
|
||||
if (!res.success) {
|
||||
@@ -390,7 +448,10 @@
|
||||
afterLandingPageRedirectURL: formValues.afterLandingPageRedirectURL || '',
|
||||
urlIdentifierID: identifierMap.byValueOrNull(formValues.urlIdentifier),
|
||||
stateIdentifierID: identifierMap.byValueOrNull(formValues.stateIdentifier),
|
||||
urlPath: formValues.urlPath || ''
|
||||
urlPath: formValues.urlPath || '',
|
||||
lureURLMode: formValues.lureURLMode,
|
||||
lureCodeAlgo: formValues.lureCodeAlgo,
|
||||
lureCodeLength: clampLureCodeLength(formValues.lureCodeLength)
|
||||
});
|
||||
if (!res.success) {
|
||||
modalError = res.error;
|
||||
@@ -449,7 +510,10 @@
|
||||
apiSender: null,
|
||||
urlIdentifier: 'id',
|
||||
stateIdentifier: 'session',
|
||||
urlPath: ''
|
||||
urlPath: '',
|
||||
lureURLMode: 'query',
|
||||
lureCodeAlgo: 'crockford32',
|
||||
lureCodeLength: 12
|
||||
};
|
||||
modalError = '';
|
||||
showAdvancedOptions = false;
|
||||
@@ -562,6 +626,9 @@
|
||||
formValues.urlIdentifier = identifierMap.byKey(template.urlIdentifierID);
|
||||
formValues.stateIdentifier = identifierMap.byKey(template.stateIdentifierID);
|
||||
formValues.urlPath = template.urlPath || '';
|
||||
formValues.lureURLMode = template.lureURLMode || 'query';
|
||||
formValues.lureCodeAlgo = template.lureCodeAlgo || 'crockford32';
|
||||
formValues.lureCodeLength = template.lureCodeLength || 12;
|
||||
|
||||
// set advanced options visibility based on template configuration
|
||||
showAdvancedOptions = !!(
|
||||
@@ -571,6 +638,7 @@
|
||||
(template.urlIdentifierID && identifierMap.byKey(template.urlIdentifierID) !== 'id') ||
|
||||
(template.stateIdentifierID &&
|
||||
identifierMap.byKey(template.stateIdentifierID) !== 'session') ||
|
||||
(template.lureURLMode && template.lureURLMode !== 'query') ||
|
||||
template.apiSenderID
|
||||
) // Show advanced if using External API
|
||||
);
|
||||
@@ -1160,10 +1228,62 @@ Simulation URLs to allow:\n${allowListingData.simulationUrl}\n
|
||||
placeholder="/employee/login">URL Path</TextField
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<SelectSquare
|
||||
label="Lure URL format"
|
||||
width="small"
|
||||
center={false}
|
||||
options={[
|
||||
{ value: 'query', label: 'Query parameter' },
|
||||
{ value: 'path', label: 'Path code' }
|
||||
]}
|
||||
bind:value={formValues.lureURLMode}
|
||||
/>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-300 break-all">
|
||||
{lureURLExample(formValues)}
|
||||
</p>
|
||||
</div>
|
||||
{#if formValues.lureURLMode === 'path'}
|
||||
<div>
|
||||
<TextFieldSelect
|
||||
id="lureCodeAlgo"
|
||||
toolTipText="Symbol set the generated code is drawn from."
|
||||
required
|
||||
bind:value={formValues.lureCodeAlgo}
|
||||
options={Object.entries(LURE_CODE_ALGOS).map(([value, spec]) => ({
|
||||
value,
|
||||
label: spec.label
|
||||
}))}>Code format</TextFieldSelect
|
||||
>
|
||||
{#if LURE_CODE_ALGOS[formValues.lureCodeAlgo]}
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
{LURE_CODE_ALGOS[formValues.lureCodeAlgo].note}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<TextField
|
||||
type="number"
|
||||
min={6}
|
||||
max={16}
|
||||
toolTipText="Number of characters in the generated code, between 6 and 16."
|
||||
bind:value={formValues.lureCodeLength}
|
||||
placeholder="12">Code length (6 to 16)</TextField
|
||||
>
|
||||
{#if lureCodeKeyspace(formValues.lureCodeAlgo, Number(formValues.lureCodeLength))}
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
{lureCodeKeyspace(
|
||||
formValues.lureCodeAlgo,
|
||||
Number(formValues.lureCodeLength)
|
||||
)}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
<TextFieldSelect
|
||||
id="urlIdentifier"
|
||||
toolTipText="This is the query param key used in the phishing URL."
|
||||
toolTipText="Query param key carrying the recipient. With the path code format the delivered link does not use it, but the pages after the first click still do."
|
||||
required
|
||||
bind:value={formValues.urlIdentifier}
|
||||
options={identifierMap.values()}>Query param key</TextFieldSelect
|
||||
@@ -1172,7 +1292,7 @@ Simulation URLs to allow:\n${allowListingData.simulationUrl}\n
|
||||
<div>
|
||||
<TextFieldSelect
|
||||
id="stateIdentifier"
|
||||
toolTipText="This is the query param key used for state."
|
||||
toolTipText="Query param key carrying page flow state. Always a query param, including with the path code format."
|
||||
required
|
||||
bind:value={formValues.stateIdentifier}
|
||||
options={identifierMap.values()}>State param key</TextFieldSelect
|
||||
|
||||
@@ -47,6 +47,8 @@
|
||||
import IconButton from '$lib/components/IconButton.svelte';
|
||||
import papaparse from 'papaparse';
|
||||
import FormFooter from '$lib/components/FormFooter.svelte';
|
||||
import TextField from '$lib/components/TextField.svelte';
|
||||
import FormError from '$lib/components/FormError.svelte';
|
||||
import TextFieldSelect from '$lib/components/TextFieldSelect.svelte';
|
||||
import { resourceContext } from '$lib/store/resourceContext';
|
||||
import RemoteBrowserStream from '$lib/components/remote-browser/RemoteBrowserStream.svelte';
|
||||
@@ -148,6 +150,7 @@
|
||||
let isAnonymizeDataModalVisible = false;
|
||||
let isSendMessageModalVisible = false;
|
||||
let isSetAsSentModalVisible = false;
|
||||
let isLureCodeModalVisible = false;
|
||||
let isSessionSushiModalVisible = false;
|
||||
let isTrackingPixelWarningVisible = false;
|
||||
let isReportedCSVModalVisible = false;
|
||||
@@ -175,6 +178,12 @@
|
||||
}
|
||||
let sendMessageRecipient = null;
|
||||
let setAsSentRecipient = null;
|
||||
let lureCodeRecipient = null;
|
||||
let lureCodeValue = '';
|
||||
let lureCodeError = '';
|
||||
/** set when the requested code is held by another recipient */
|
||||
let lureCodeConflict = null;
|
||||
let lureCodeSubmitting = false;
|
||||
let lastPoll3399Nano = '';
|
||||
|
||||
// live remote browser sessions
|
||||
@@ -671,6 +680,73 @@
|
||||
sendMessageRecipient = null;
|
||||
};
|
||||
|
||||
// a custom lure URL is stored byte for byte, so the only rules are the ones
|
||||
// that would otherwise make it unreachable: it has to fit the column and sit
|
||||
// in one path segment without escaping
|
||||
/** @param {string} value */
|
||||
const isLureCodeValid = (value) => !!value && value.length <= 64 && !/[/%?#\\\s]/.test(value);
|
||||
|
||||
// path the template puts in front of the code, used for the preview
|
||||
$: campaignURLPath = (campaign?.template?.urlPath || '').replace(/\/$/, '');
|
||||
|
||||
/** @param {Object} recp */
|
||||
const showLureCodeModal = (recp) => {
|
||||
lureCodeRecipient = {
|
||||
id: recp.id,
|
||||
name: `${recp.recipient?.firstName || ''} ${recp.recipient?.lastName || ''}`.trim(),
|
||||
email: recp.recipient?.email,
|
||||
sentAt: recp.sentAt,
|
||||
current: recp.lureCode || ''
|
||||
};
|
||||
lureCodeValue = recp.lureCode || '';
|
||||
lureCodeError = '';
|
||||
lureCodeConflict = null;
|
||||
isLureCodeModalVisible = true;
|
||||
};
|
||||
|
||||
const closeLureCodeModal = () => {
|
||||
isLureCodeModalVisible = false;
|
||||
lureCodeRecipient = null;
|
||||
lureCodeValue = '';
|
||||
lureCodeError = '';
|
||||
lureCodeConflict = null;
|
||||
};
|
||||
|
||||
/** @param {boolean} reclaim */
|
||||
const submitLureCode = async (reclaim = false) => {
|
||||
if (!lureCodeRecipient) {
|
||||
return;
|
||||
}
|
||||
if (!isLureCodeValid(lureCodeValue)) {
|
||||
lureCodeError = 'Up to 64 characters. Cannot contain a slash, space, %, ? or #.';
|
||||
return;
|
||||
}
|
||||
lureCodeSubmitting = true;
|
||||
lureCodeError = '';
|
||||
try {
|
||||
const res = await api.campaign.setLureCode(lureCodeRecipient.id, lureCodeValue, reclaim);
|
||||
if (!res.success) {
|
||||
// 409 carries the campaign currently holding the code so the
|
||||
// operator can decide whether to take it over
|
||||
if (res.statusCode === 409) {
|
||||
lureCodeConflict = res.data || {};
|
||||
lureCodeError = res.error || 'Lure code is already in use';
|
||||
return;
|
||||
}
|
||||
lureCodeError = res.error || 'Failed to set lure URL';
|
||||
return;
|
||||
}
|
||||
addToast('Lure URL updated', 'Success');
|
||||
closeLureCodeModal();
|
||||
await refreshCampaignRecipients();
|
||||
} catch (e) {
|
||||
lureCodeError = 'Failed to set lure URL';
|
||||
console.error('failed to set lure code', e);
|
||||
} finally {
|
||||
lureCodeSubmitting = false;
|
||||
}
|
||||
};
|
||||
|
||||
/** @param {string} campaignRecipientID @param {Object} recipient */
|
||||
const showSetAsSentModal = (campaignRecipientID, recipient) => {
|
||||
setAsSentRecipient = {
|
||||
@@ -2183,7 +2259,8 @@
|
||||
{ column: 'Status', size: 'small' },
|
||||
{ column: 'Send at', title: 'Scheduled', size: 'small' },
|
||||
{ column: 'Sent at', title: 'Delivered', size: 'small' },
|
||||
{ column: 'Cancelled at', size: 'small' }
|
||||
{ column: 'Cancelled at', size: 'small' },
|
||||
...(campaign.hasCustomLureCodes ? [{ column: 'Lure URL', size: 'small' }] : [])
|
||||
]}
|
||||
sortable={[
|
||||
'First name',
|
||||
@@ -2277,6 +2354,9 @@
|
||||
<TableCell value={recp?.sendAt} isDate />
|
||||
<TableCell value={recp?.sentAt} isDate />
|
||||
<TableCell value={recp?.cancelledAt} isDate />
|
||||
{#if campaign.hasCustomLureCodes}
|
||||
<TableCell value={recp?.lureCode || ''} />
|
||||
{/if}
|
||||
{#if !campaign.sentAt}
|
||||
<TableCellEmpty />
|
||||
<TableCellAction>
|
||||
@@ -2327,6 +2407,27 @@
|
||||
: ''}
|
||||
on:click={() => onClickCopyURL(recp.id)}
|
||||
/>
|
||||
<TableUpdateButton
|
||||
name="Set custom lure URL"
|
||||
disabled={!!campaign.closedAt ||
|
||||
!!campaign.anonymizedAt ||
|
||||
!recp.recipient ||
|
||||
isContextMismatch()}
|
||||
title={isContextMismatch()
|
||||
? campaign.companyID
|
||||
? 'Switch to company view to perform this action'
|
||||
: 'Switch to global view to perform this action'
|
||||
: campaign.closedAt
|
||||
? 'Campaign is closed'
|
||||
: campaign.anonymizedAt
|
||||
? 'Campaign is anonymized'
|
||||
: !recp.recipient
|
||||
? 'Recipient not available'
|
||||
: recp.sentAt
|
||||
? 'Changing this will break the link already sent to this recipient'
|
||||
: 'Choose the identifier used in this recipient lure URL'}
|
||||
on:click={() => showLureCodeModal(recp)}
|
||||
/>
|
||||
<TableUpdateButton
|
||||
name="Copy email content"
|
||||
disabled={!!campaign.closedAt || !!campaign.anonymizedAt || isContextMismatch()}
|
||||
@@ -2884,6 +2985,96 @@
|
||||
</div>
|
||||
</Alert>
|
||||
|
||||
<Modal
|
||||
headerText="Set custom lure URL"
|
||||
visible={isLureCodeModalVisible}
|
||||
onClose={closeLureCodeModal}
|
||||
>
|
||||
{#if lureCodeRecipient}
|
||||
<div class="py-6 w-full max-w-xl">
|
||||
<div class="pb-4 mb-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<p class="font-medium text-gray-900 dark:text-gray-100">{lureCodeRecipient.name}</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300">{lureCodeRecipient.email}</p>
|
||||
</div>
|
||||
|
||||
<TextField
|
||||
bind:value={lureCodeValue}
|
||||
maxLength={64}
|
||||
placeholder="special-42">Lure URL</TextField
|
||||
>
|
||||
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-300 break-all">
|
||||
{lureCodeValue
|
||||
? `https://${campaign.template?.domain?.name ?? 'domain'}${campaignURLPath}/${lureCodeValue}`
|
||||
: 'Matched exactly as written, so case and separators are kept.'}
|
||||
</p>
|
||||
|
||||
{#if lureCodeRecipient.sentAt}
|
||||
<p class="mt-4 text-sm text-amber-600 dark:text-amber-500">
|
||||
Already sent on {new Date(lureCodeRecipient.sentAt).toLocaleString()}. Changing this
|
||||
stops the link the recipient is holding from working.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if lureCodeError}
|
||||
<div class="mt-4">
|
||||
<FormError message={lureCodeError} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if lureCodeConflict}
|
||||
<div
|
||||
class="mt-4 rounded-md border border-amber-400 dark:border-amber-600 p-4 text-sm space-y-3"
|
||||
>
|
||||
<p class="text-gray-800 dark:text-gray-100">
|
||||
{#if lureCodeConflict.campaignName}
|
||||
Already used by campaign <span class="font-medium"
|
||||
>{lureCodeConflict.campaignName}</span
|
||||
>{lureCodeConflict.isClosed ? ' (closed)' : ' (still running)'}.
|
||||
{:else}
|
||||
Already used by a campaign in another company{lureCodeConflict.isClosed
|
||||
? ' (closed)'
|
||||
: ' (still running)'}.
|
||||
{/if}
|
||||
</p>
|
||||
<p class="text-gray-600 dark:text-gray-300">
|
||||
Reclaiming frees it for this recipient. Anyone still holding the old link will land
|
||||
in this campaign instead.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lureCodeSubmitting}
|
||||
on:click={() => submitLureCode(true)}
|
||||
class="rounded-md border border-amber-500 px-4 py-2 font-medium text-amber-700 dark:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-900/30 disabled:opacity-50 transition-colors duration-200"
|
||||
>
|
||||
Reclaim
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="mt-8 pt-4 flex justify-end gap-3 border-t border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
on:click={closeLureCodeModal}
|
||||
class="rounded-md px-4 py-2 font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lureCodeSubmitting}
|
||||
on:click={() => submitLureCode(false)}
|
||||
class="rounded-md bg-cta-blue dark:bg-blue-700 px-6 py-2 font-medium text-white hover:bg-blue-700 dark:hover:bg-blue-600 disabled:opacity-50 transition-colors duration-200"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Modal>
|
||||
|
||||
<Alert
|
||||
headline="Set as Message Sent"
|
||||
bind:visible={isSetAsSentModalVisible}
|
||||
|
||||
Reference in New Issue
Block a user