mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-08-18 08:27:14 +02:00
add campaign with url paths
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 409 with enough detail for the caller to offer a
|
||||
// resolution, such as naming the campaign already holding a requested lure
|
||||
// code.
|
||||
func (r *jsonResponseHandler) Conflict(g *gin.Context, message string, data any) {
|
||||
g.JSON(
|
||||
http.StatusConflict,
|
||||
r.newResponse(false, data, message),
|
||||
)
|
||||
g.Abort()
|
||||
}
|
||||
|
||||
func (r *jsonResponseHandler) unwrapErrorMessage(err error) string {
|
||||
message := err.Error()
|
||||
unwrapped := errors.Unwrap(err)
|
||||
|
||||
@@ -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,8 @@ func (s *Server) checkAndServePhishingPage(
|
||||
c.Request,
|
||||
s.repositories.Identifier,
|
||||
s.repositories.CampaignRecipient,
|
||||
domain,
|
||||
true,
|
||||
)
|
||||
if err != nil {
|
||||
s.logger.Debugw("failed to get campaign recipient from URL parameters",
|
||||
@@ -1870,6 +1872,8 @@ func (s *Server) renderDenyPage(
|
||||
c.Request,
|
||||
s.repositories.Identifier,
|
||||
s.repositories.CampaignRecipient,
|
||||
domain,
|
||||
true,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get campaign recipient for deny page: %s", err)
|
||||
|
||||
@@ -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 holds it. Set only
|
||||
// after the caller has been shown which campaign that is.
|
||||
Reclaim bool `json:"reclaim"`
|
||||
}
|
||||
|
||||
// SetLureCode assigns an operator chosen lure code to a single campaign recipient
|
||||
func (c *Campaign) SetLureCode(g *gin.Context) {
|
||||
// handle session
|
||||
session, _, ok := c.handleSession(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// parse request
|
||||
id, ok := c.handleParseIDParam(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req setLureCodeRequest
|
||||
if ok := c.handleParseRequest(g, &req); !ok {
|
||||
return
|
||||
}
|
||||
conflict, err := c.CampaignService.SetLureCodeByCampaignRecipientID(
|
||||
g.Request.Context(),
|
||||
session,
|
||||
id,
|
||||
req.Code,
|
||||
req.Reclaim,
|
||||
)
|
||||
// an expected outcome the operator can resolve, so it carries the owner
|
||||
// rather than reporting a plain failure
|
||||
if errors.Is(err, service.ErrLureCodeTaken) {
|
||||
c.Response.Conflict(g, "Lure code is already in use", conflict)
|
||||
return
|
||||
}
|
||||
if ok := c.handleErrors(g, err); !ok {
|
||||
return
|
||||
}
|
||||
c.Response.OK(g, gin.H{})
|
||||
}
|
||||
|
||||
// SetSentAtByCampaignRecipientID sets the sent at time for a campaign recipient
|
||||
func (c *Campaign) SetSentAtByCampaignRecipientID(g *gin.Context) {
|
||||
// handle session
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package data
|
||||
|
||||
const (
|
||||
// LureURLModeQuery carries the recipient as a query parameter, for example
|
||||
// https://example.com/login?id=<uuid>.
|
||||
LureURLModeQuery = "query"
|
||||
// LureURLModePath carries the recipient as the last path segment, for example
|
||||
// https://example.com/login/4H7K9QM2XR3T. Survives being read aloud or sent
|
||||
// over SMS, where a query string with a UUID does not.
|
||||
LureURLModePath = "path"
|
||||
)
|
||||
|
||||
// IsValidLureURLMode reports whether mode is a known lure URL mode.
|
||||
func IsValidLureURLMode(mode string) bool {
|
||||
switch mode {
|
||||
case LureURLModeQuery, LureURLModePath:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -64,6 +64,14 @@ type Campaign struct {
|
||||
WebhookIncludeData string `gorm:"not null;default:'full'"`
|
||||
WebhookEvents int `gorm:"not null;default:0"`
|
||||
|
||||
// Snapshotted from the campaign template while the campaign holds no
|
||||
// recipients. Read from here rather than the template so a later addition to
|
||||
// a self managed campaign matches the recipients already scheduled, and a
|
||||
// template edit cannot change the form of URLs already delivered.
|
||||
LureURLMode string `gorm:"not null;default:'query'"`
|
||||
LureCodeAlgo string `gorm:"not null;default:'crockford32'"`
|
||||
LureCodeLength int `gorm:"not null;default:12"`
|
||||
|
||||
// has one
|
||||
CampaignTemplateID *uuid.UUID `gorm:"index;type:uuid;"`
|
||||
CampaignTemplate *CampaignTemplate
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -45,8 +49,113 @@ type CampaignRecipient struct {
|
||||
// NotableEventID is the most notable event for this recipient
|
||||
NotableEvent *Event `gorm:"foreignKey:NotableEventID;references:ID"`
|
||||
NotableEventID *uuid.UUID `gorm:"type:uuid;index"`
|
||||
|
||||
// LureCode replaces the campaign recipient UUID in a lure URL, for example
|
||||
// https://example.com/4H7K9QM2XR3T. Stored and matched byte for byte, so an
|
||||
// operator picking Special-42 gets that link verbatim.
|
||||
//
|
||||
// Releasing sets this to null rather than flagging it, which keeps a
|
||||
// reclaimed code from showing against its former owner and lets the unique
|
||||
// index below need nothing but IS NOT NULL.
|
||||
LureCode *string `gorm:"type:varchar(64)"`
|
||||
// LureCodeCustom marks a code the operator set by hand, which cannot be told
|
||||
// apart from a generated one by shape.
|
||||
LureCodeCustom bool `gorm:"not null;default:false"`
|
||||
}
|
||||
|
||||
func (CampaignRecipient) TableName() string {
|
||||
return CAMPAIGN_RECIPIENT_TABLE_NAME
|
||||
}
|
||||
|
||||
// lureCodeIndexName is the partial unique index backing lure code allocation.
|
||||
const lureCodeIndexName = "idx_campaign_recipients_lure_code"
|
||||
|
||||
// lureCodeCustomIndexName is the index answering whether a campaign carries any
|
||||
// operator set code.
|
||||
const lureCodeCustomIndexName = "idx_campaign_recipients_lure_code_custom"
|
||||
|
||||
// Migrate creates the indexes the lure code feature reads.
|
||||
//
|
||||
// The unique predicate must exclude rows carrying no code, or every recipient in
|
||||
// a query mode campaign enters the index under the same value and the second
|
||||
// insert fails. It covers reuse too, since releasing a code nulls it and the row
|
||||
// leaves the index.
|
||||
//
|
||||
// Raw SQL rather than gorm index tags, because the generic migrator drops the
|
||||
// WHERE clause and would silently build a full unique index.
|
||||
func (CampaignRecipient) Migrate(db *gorm.DB) error {
|
||||
createUnique := func() error {
|
||||
return ensureIndex(db, lureCodeIndexName, `CREATE UNIQUE INDEX `+lureCodeIndexName+`
|
||||
ON campaign_recipients (lure_code)
|
||||
WHERE lure_code IS NOT NULL`)
|
||||
}
|
||||
if err := createUnique(); err != nil {
|
||||
// creation fails when two rows already hold the same code. keep the first
|
||||
// row for each and release the rest. only after a failure, because the
|
||||
// scan and grouping are too costly to pay on every startup.
|
||||
if dedupErr := db.Exec(`UPDATE campaign_recipients SET lure_code = NULL
|
||||
WHERE lure_code IS NOT NULL
|
||||
AND rowid NOT IN (
|
||||
SELECT MIN(rowid) FROM campaign_recipients
|
||||
WHERE lure_code IS NOT NULL
|
||||
GROUP BY lure_code
|
||||
)`).Error; dedupErr != nil {
|
||||
return dedupErr
|
||||
}
|
||||
if err := createUnique(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// only rows with an operator set code enter the index, rather than every
|
||||
// recipient ever created. sqlite uses a partial index only where the query
|
||||
// repeats the predicate, so HasCustomLureCodesByCampaignID writes the same
|
||||
// lure_code_custom = 1 literal. a bare column predicate would not match.
|
||||
return ensureIndex(db, lureCodeCustomIndexName, `CREATE INDEX `+lureCodeCustomIndexName+`
|
||||
ON campaign_recipients (campaign_id)
|
||||
WHERE lure_code_custom = 1`)
|
||||
}
|
||||
|
||||
// ensureIndex creates an index, replacing whatever carries the name when its
|
||||
// definition differs.
|
||||
//
|
||||
// The whole definition is compared, not just existence: an index left by an
|
||||
// earlier release keeps working and goes unnoticed, while a predicate that no
|
||||
// longer matches the query stops the planner using it at all. The ddl must omit
|
||||
// IF NOT EXISTS, because sqlite drops those words before storing the statement
|
||||
// and the two would never compare equal.
|
||||
func ensureIndex(db *gorm.DB, name string, ddl string) error {
|
||||
// through the underlying pool rather than gorm, whose Row returns nil when it
|
||||
// could not build the statement and would panic here during startup
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var definition sql.NullString
|
||||
err = sqlDB.QueryRow(
|
||||
`SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ?`,
|
||||
name,
|
||||
).Scan(&definition)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
if err == nil {
|
||||
// an index sqlite created for itself carries no definition and is not ours
|
||||
if !definition.Valid {
|
||||
return nil
|
||||
}
|
||||
if normalizeSQL(definition.String) == normalizeSQL(ddl) {
|
||||
return nil
|
||||
}
|
||||
if err := db.Exec(`DROP INDEX IF EXISTS ` + name).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return db.Exec(ddl).Error
|
||||
}
|
||||
|
||||
// normalizeSQL collapses whitespace so a statement read back from sqlite_master
|
||||
// compares equal to the indented literal it was created from.
|
||||
func normalizeSQL(s string) string {
|
||||
return strings.Join(strings.Fields(s), " ")
|
||||
}
|
||||
|
||||
@@ -21,6 +21,17 @@ type CampaignTemplate struct {
|
||||
|
||||
URLPath string `gorm:"not null;default:'';index"`
|
||||
|
||||
// LureURLMode selects how a delivered lure URL carries the recipient.
|
||||
// "query" appends ?id=<uuid>, "path" appends /<code> after URLPath. The
|
||||
// resolver accepts both whatever this says, so changing it never breaks a
|
||||
// link already delivered.
|
||||
LureURLMode string `gorm:"not null;default:'query'"`
|
||||
// LureCodeAlgo names the generator used for campaigns from this template.
|
||||
LureCodeAlgo string `gorm:"not null;default:'crockford32'"`
|
||||
// LureCodeLength is the character count of a generated code. Shorter is
|
||||
// easier to read aloud and easier to guess.
|
||||
LureCodeLength int `gorm:"not null;default:12"`
|
||||
|
||||
// IsUsable indicates if a template is usable based on if it has all the required
|
||||
// data such as domainID, landingPage and etc to be used in a campaign
|
||||
IsUsable bool `gorm:"not null;default:false;index"`
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// Package lure builds and resolves the short recipient identifiers that appear
|
||||
// in a lure URL, for example https://example.com/account/4H7K9QM2XR3T.
|
||||
//
|
||||
// A code is stored and matched byte for byte. Base58 treats the two cases as
|
||||
// different symbols, so folding would collapse distinct codes, and applying the
|
||||
// same rule to every algorithm keeps one matching behaviour rather than one per
|
||||
// alphabet.
|
||||
package lure
|
||||
|
||||
import "strings"
|
||||
|
||||
// Alphabet is the Crockford base32 alphabet. It omits I, L, O and U so a code
|
||||
// read aloud cannot be confused with a similar glyph or spell a word.
|
||||
const Alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
||||
|
||||
// Base58Alphabet is the Bitcoin base58 alphabet. Keeping both cases needs fewer
|
||||
// characters for the same number of combinations, and 0, O, I and l are dropped
|
||||
// as hard to tell apart in print.
|
||||
const Base58Alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
|
||||
|
||||
// Bounds on a generated code. The lower bound is low enough to be guessable,
|
||||
// which is an operator choice for short lived campaigns.
|
||||
const (
|
||||
MinLength = 6
|
||||
MaxLength = 16
|
||||
DefaultLength = 12
|
||||
)
|
||||
|
||||
// MaxCustomLength is the only limit on an operator written code, and it exists
|
||||
// because the column is varchar(64).
|
||||
const MaxCustomLength = 64
|
||||
|
||||
// DisallowedCustomCharacters lists what IsCandidate rejects, as the symbols
|
||||
// themselves, so an error message shows what to look for.
|
||||
const DisallowedCustomCharacters = `/ \ % ? # " ' < >`
|
||||
|
||||
// IsCandidate reports whether a path segment or query value could be a lure code
|
||||
// and is therefore worth a database probe.
|
||||
//
|
||||
// An operator written code may be any text, so no alphabet applies. This rules
|
||||
// out only what could never be a code: too long for the column, or needing an
|
||||
// escape to sit in a single path segment. A single dot is allowed so a lure can
|
||||
// end in invoice.pdf, at the cost of one indexed probe per static asset request.
|
||||
func IsCandidate(s string) bool {
|
||||
if s == "" || len(s) > MaxCustomLength {
|
||||
return false
|
||||
}
|
||||
// a browser resolves a lone dot away before sending, and LastPathSegment
|
||||
// refuses a whole path carrying a doubled dot rather than cleaning it, so
|
||||
// neither shape could arrive back intact
|
||||
if s == "." || strings.Contains(s, "..") {
|
||||
return false
|
||||
}
|
||||
// would split the segment or need escaping
|
||||
if strings.ContainsAny(s, "/%?#\\") {
|
||||
return false
|
||||
}
|
||||
// the finished URL is written into a mail body rendered with text/template,
|
||||
// which does not escape it
|
||||
if strings.ContainsAny(s, "\"'<>") {
|
||||
return false
|
||||
}
|
||||
// a code carrying one could never be matched back from a request path
|
||||
for _, r := range s {
|
||||
if r <= 0x20 || r == 0x7f {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// IsValidCustom reports whether an operator supplied code can be stored. The
|
||||
// rules are the same ones that keep a code reachable, so a stored code always
|
||||
// resolves. A path with more than one segment comes from the template URL path,
|
||||
// not from the code.
|
||||
func IsValidCustom(s string) bool {
|
||||
return IsCandidate(s)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package lure
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/phishingclub/phishingclub/random"
|
||||
)
|
||||
|
||||
// Algorithm selects how a generated code is produced. The resolver keys only on
|
||||
// the stored code and never reads this, so a new algorithm can be added without
|
||||
// touching the request path.
|
||||
type Algorithm string
|
||||
|
||||
const (
|
||||
// AlgorithmCrockford32 draws from the Crockford base32 alphabet.
|
||||
AlgorithmCrockford32 Algorithm = "crockford32"
|
||||
// AlgorithmBase58 draws from the Bitcoin base58 alphabet.
|
||||
AlgorithmBase58 Algorithm = "base58"
|
||||
)
|
||||
|
||||
// DefaultAlgorithm is used when a campaign template has no explicit choice.
|
||||
const DefaultAlgorithm = AlgorithmCrockford32
|
||||
|
||||
// IsValidAlgorithm reports whether a is a known generator.
|
||||
func IsValidAlgorithm(a Algorithm) bool {
|
||||
switch a {
|
||||
case AlgorithmCrockford32, AlgorithmBase58:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// AlphabetFor returns the symbol set an algorithm draws from.
|
||||
func AlphabetFor(a Algorithm) string {
|
||||
switch a {
|
||||
case AlgorithmBase58:
|
||||
return Base58Alphabet
|
||||
default:
|
||||
return Alphabet
|
||||
}
|
||||
}
|
||||
|
||||
// Code is an allocated identifier, stored and matched exactly as it appears
|
||||
// here.
|
||||
type Code struct {
|
||||
Display string
|
||||
}
|
||||
|
||||
// NewCustomCode builds a Code from an operator supplied string, kept verbatim.
|
||||
func NewCustomCode(s string) Code {
|
||||
return Code{Display: s}
|
||||
}
|
||||
|
||||
// Generate returns a new random code of the given length.
|
||||
func Generate(algorithm Algorithm, length int) (Code, error) {
|
||||
codes, err := GenerateBatch(algorithm, length, 1)
|
||||
if err != nil {
|
||||
return Code{}, err
|
||||
}
|
||||
return codes[0], nil
|
||||
}
|
||||
|
||||
// maxRandomBlock caps a single entropy read so a large batch does not ask for
|
||||
// one allocation the size of the whole batch.
|
||||
const maxRandomBlock = 64 * 1024
|
||||
|
||||
// GenerateBatch returns n new random codes of the given length.
|
||||
//
|
||||
// Entropy is drawn in blocks covering many codes, because scheduling a large
|
||||
// campaign asks for tens of thousands at once. Symbols are drawn by rejection
|
||||
// sampling: a byte taken modulo an alphabet size that does not divide 256 would
|
||||
// favour the symbols at the start and shrink the real key space.
|
||||
func GenerateBatch(algorithm Algorithm, length int, n int) ([]Code, error) {
|
||||
if !IsValidAlgorithm(algorithm) {
|
||||
return nil, fmt.Errorf("unknown lure code algorithm: %s", algorithm)
|
||||
}
|
||||
if length < MinLength || length > MaxLength {
|
||||
return nil, fmt.Errorf(
|
||||
"lure code length must be between %d and %d, got %d",
|
||||
MinLength,
|
||||
MaxLength,
|
||||
length,
|
||||
)
|
||||
}
|
||||
if n <= 0 {
|
||||
return []Code{}, nil
|
||||
}
|
||||
alphabet := AlphabetFor(algorithm)
|
||||
size := len(alphabet)
|
||||
// largest multiple of the alphabet size within a byte range, above which a
|
||||
// draw is discarded. an alphabet dividing 256 gives 256, which is why this is
|
||||
// an int and the comparison below widens the byte rather than narrowing it.
|
||||
limit := 256 / size * size
|
||||
|
||||
// symbols wanted plus headroom for discarded draws. running short only costs
|
||||
// another read, so this is a guess rather than a bound.
|
||||
block := n*length + (n*length)/4 + 16
|
||||
if block > maxRandomBlock {
|
||||
block = maxRandomBlock
|
||||
}
|
||||
|
||||
codes := make([]Code, 0, n)
|
||||
out := make([]byte, 0, length)
|
||||
buf := []byte{}
|
||||
for len(codes) < n {
|
||||
if len(buf) == 0 {
|
||||
drawn, err := random.GenerateRandomBytes(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate lure code: %w", err)
|
||||
}
|
||||
buf = drawn
|
||||
}
|
||||
b := buf[0]
|
||||
buf = buf[1:]
|
||||
if int(b) >= limit {
|
||||
continue
|
||||
}
|
||||
out = append(out, alphabet[int(b)%size])
|
||||
if len(out) == length {
|
||||
// string copies, so out can be reused
|
||||
codes = append(codes, Code{Display: string(out)})
|
||||
out = out[:0]
|
||||
}
|
||||
}
|
||||
return codes, nil
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package lure
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsCandidate(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{"4H7K9QM2XR3T", true},
|
||||
{"zQ8fRt2mKp9x", true},
|
||||
// an operator written code may be any text, so no alphabet is applied
|
||||
{"special-42", true},
|
||||
{"Special_42", true},
|
||||
{"hello", true},
|
||||
// a single dot is allowed so a lure can end in invoice.pdf
|
||||
{"invoice.pdf", true},
|
||||
{"main.4f3a2b1c.js", true},
|
||||
// values that could not sit in a single path segment unescaped
|
||||
{"hello world", false},
|
||||
{"a/b", false},
|
||||
{"a%2Fb", false},
|
||||
{"a?b", false},
|
||||
{"a#b", false},
|
||||
{"", false},
|
||||
// a browser resolves a lone dot away before sending, and the resolver
|
||||
// refuses a whole path carrying a doubled dot rather than cleaning it
|
||||
{".", false},
|
||||
{"..", false},
|
||||
{"...", false},
|
||||
{"invoice..pdf", false},
|
||||
{"v1..2", false},
|
||||
// a single dot elsewhere in the segment is still fine
|
||||
{".hidden", true},
|
||||
{strings.Repeat("a", MaxCustomLength), true},
|
||||
{strings.Repeat("a", MaxCustomLength+1), false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := IsCandidate(c.in); got != c.want {
|
||||
t.Errorf("IsCandidate(%q) = %v, want %v", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCandidateRejectsUnescapableValues(t *testing.T) {
|
||||
for _, in := range []string{"a/b", "%2Fetc", "a\\b", "a b"} {
|
||||
if IsCandidate(in) {
|
||||
t.Errorf("IsCandidate(%q) should be false", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCustomCodeIsKeptVerbatim(t *testing.T) {
|
||||
// an operator written code reaches the URL as chosen, so Special-42
|
||||
// and special-42 stay distinct links
|
||||
for _, in := range []string{"special-42", "Special_42", "HR-Survey-2026", "invoice.pdf"} {
|
||||
if got := NewCustomCode(in).Display; got != in {
|
||||
t.Errorf("display form was altered: %q -> %q", in, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateLength(t *testing.T) {
|
||||
for _, algorithm := range []Algorithm{AlgorithmCrockford32, AlgorithmBase58} {
|
||||
for length := MinLength; length <= MaxLength; length++ {
|
||||
code, err := Generate(algorithm, length)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate(%s, %d) failed: %v", algorithm, length, err)
|
||||
}
|
||||
if len(code.Display) != length {
|
||||
t.Errorf("Generate(%s, %d) gave length %d", algorithm, length, len(code.Display))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateStaysInsideItsAlphabet(t *testing.T) {
|
||||
for _, algorithm := range []Algorithm{AlgorithmCrockford32, AlgorithmBase58} {
|
||||
alphabet := AlphabetFor(algorithm)
|
||||
for i := 0; i < 500; i++ {
|
||||
code, err := Generate(algorithm, MaxLength)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate failed: %v", err)
|
||||
}
|
||||
for _, r := range code.Display {
|
||||
if !strings.ContainsRune(alphabet, r) {
|
||||
t.Fatalf("%s produced %q outside its alphabet: %q", algorithm, string(r), code.Display)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateCoversEachAlphabet(t *testing.T) {
|
||||
// rejection sampling must reach every symbol and accept enough draws to
|
||||
// terminate. an alphabet dividing 256 gives a limit of 256, which held in a
|
||||
// byte would be zero and reject everything.
|
||||
for _, algorithm := range []Algorithm{AlgorithmCrockford32, AlgorithmBase58} {
|
||||
alphabet := AlphabetFor(algorithm)
|
||||
seen := map[rune]bool{}
|
||||
for i := 0; i < 8000; i++ {
|
||||
code, err := Generate(algorithm, MaxLength)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate failed: %v", err)
|
||||
}
|
||||
for _, r := range code.Display {
|
||||
seen[r] = true
|
||||
}
|
||||
}
|
||||
for _, r := range alphabet {
|
||||
if !seen[r] {
|
||||
t.Errorf("%s never generated symbol %q", algorithm, string(r))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBase58UsesBothCases(t *testing.T) {
|
||||
// base58 treats the two cases as distinct symbols, which is why matching is
|
||||
// never folded
|
||||
lower := false
|
||||
upper := false
|
||||
for i := 0; i < 500 && (!lower || !upper); i++ {
|
||||
code, err := Generate(AlgorithmBase58, MaxLength)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate failed: %v", err)
|
||||
}
|
||||
if strings.ToLower(code.Display) != code.Display {
|
||||
upper = true
|
||||
}
|
||||
if strings.ToUpper(code.Display) != code.Display {
|
||||
lower = true
|
||||
}
|
||||
}
|
||||
if !lower || !upper {
|
||||
t.Error("base58 should produce both cases")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateExcludesConfusableGlyphs(t *testing.T) {
|
||||
for i := 0; i < 500; i++ {
|
||||
crockford, err := Generate(AlgorithmCrockford32, MaxLength)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate failed: %v", err)
|
||||
}
|
||||
if strings.ContainsAny(crockford.Display, "ILOU") {
|
||||
t.Fatalf("crockford code contains an excluded glyph: %q", crockford.Display)
|
||||
}
|
||||
base58, err := Generate(AlgorithmBase58, MaxLength)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate failed: %v", err)
|
||||
}
|
||||
if strings.ContainsAny(base58.Display, "0OIl") {
|
||||
t.Fatalf("base58 code contains an excluded glyph: %q", base58.Display)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRejectsBadInput(t *testing.T) {
|
||||
if _, err := Generate("nope", DefaultLength); err == nil {
|
||||
t.Error("expected an error for an unknown algorithm")
|
||||
}
|
||||
if _, err := Generate(AlgorithmCrockford32, MinLength-1); err == nil {
|
||||
t.Error("expected an error for a length below the minimum")
|
||||
}
|
||||
if _, err := Generate(AlgorithmCrockford32, MaxLength+1); err == nil {
|
||||
t.Error("expected an error for a length above the maximum")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateBatch(t *testing.T) {
|
||||
// a schedule run draws every code at once, so the batch must return exactly
|
||||
// what was asked for and stay in its alphabet across entropy block refills
|
||||
for _, algorithm := range []Algorithm{AlgorithmCrockford32, AlgorithmBase58} {
|
||||
alphabet := AlphabetFor(algorithm)
|
||||
codes, err := GenerateBatch(algorithm, DefaultLength, 5000)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateBatch(%s) failed: %v", algorithm, err)
|
||||
}
|
||||
if len(codes) != 5000 {
|
||||
t.Fatalf("GenerateBatch(%s) gave %d codes, want 5000", algorithm, len(codes))
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, code := range codes {
|
||||
if len(code.Display) != DefaultLength {
|
||||
t.Fatalf("code %q is not %d characters", code.Display, DefaultLength)
|
||||
}
|
||||
for _, r := range code.Display {
|
||||
if !strings.ContainsRune(alphabet, r) {
|
||||
t.Fatalf("%s produced %q outside its alphabet: %q", algorithm, string(r), code.Display)
|
||||
}
|
||||
}
|
||||
seen[code.Display] = true
|
||||
}
|
||||
// the allocator dedupes, so a batch need not be distinct, but a broken draw
|
||||
// repeating one code shows up here
|
||||
if len(seen) < 4990 {
|
||||
t.Errorf("%s produced only %d distinct codes out of 5000", algorithm, len(seen))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateBatchRejectsBadInput(t *testing.T) {
|
||||
if _, err := GenerateBatch("nope", DefaultLength, 1); err == nil {
|
||||
t.Error("expected an error for an unknown algorithm")
|
||||
}
|
||||
if _, err := GenerateBatch(AlgorithmCrockford32, MinLength-1, 1); err == nil {
|
||||
t.Error("expected an error for a length below the minimum")
|
||||
}
|
||||
codes, err := GenerateBatch(AlgorithmCrockford32, DefaultLength, 0)
|
||||
if err != nil || len(codes) != 0 {
|
||||
t.Errorf("GenerateBatch with n 0 = %v,%v want empty,nil", codes, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsValidAlgorithm(t *testing.T) {
|
||||
if !IsValidAlgorithm(AlgorithmCrockford32) || !IsValidAlgorithm(AlgorithmBase58) {
|
||||
t.Error("both shipped algorithms should be valid")
|
||||
}
|
||||
if IsValidAlgorithm("base64") {
|
||||
t.Error("unknown algorithm should not validate")
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/oapi-codegen/nullable"
|
||||
"github.com/phishingclub/phishingclub/data"
|
||||
"github.com/phishingclub/phishingclub/errs"
|
||||
"github.com/phishingclub/phishingclub/lure"
|
||||
"github.com/phishingclub/phishingclub/utils"
|
||||
"github.com/phishingclub/phishingclub/validate"
|
||||
"github.com/phishingclub/phishingclub/vo"
|
||||
@@ -69,6 +70,16 @@ type Campaign struct {
|
||||
// webhooks configuration with per-webhook settings
|
||||
Webhooks nullable.Nullable[[]*CampaignWebhook] `json:"webhooks,omitempty"`
|
||||
|
||||
// snapshotted from the campaign template while the campaign holds no
|
||||
// recipients. read only, see ToDBMap.
|
||||
LureURLMode nullable.Nullable[string] `json:"lureURLMode"`
|
||||
LureCodeAlgo nullable.Nullable[string] `json:"lureCodeAlgo"`
|
||||
LureCodeLength nullable.Nullable[int] `json:"lureCodeLength"`
|
||||
|
||||
// HasCustomLureCodes decides whether the recipient table shows the code
|
||||
// column, which pagination makes impossible to derive from one page.
|
||||
HasCustomLureCodes bool `json:"hasCustomLureCodes"`
|
||||
|
||||
// must not be set by a user
|
||||
NotableEventID nullable.Nullable[uuid.UUID] `json:"notableEventID"`
|
||||
NotableEventName string `json:"notableEventName"`
|
||||
@@ -551,10 +562,41 @@ func (c *Campaign) ToDBMap() map[string]any {
|
||||
m["jitter_max"] = v
|
||||
}
|
||||
}
|
||||
// the lure settings are left out. a create or update request carries whatever
|
||||
// a caller put in those fields, so the snapshot is written only by
|
||||
// SetLureSettingsByID, from the template, at the first schedule.
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// LureSettings returns the snapshotted lure URL settings, falling back to the
|
||||
// defaults for campaigns created before the columns existed.
|
||||
//
|
||||
// Each value is rechecked rather than trusted, so anything unusable becomes a
|
||||
// default here instead of failing the schedule during allocation.
|
||||
func (c *Campaign) LureSettings() (mode string, algorithm lure.Algorithm, length int) {
|
||||
mode = data.LureURLModeQuery
|
||||
algorithm = lure.DefaultAlgorithm
|
||||
length = lure.DefaultLength
|
||||
if v, err := c.LureURLMode.Get(); err == nil && data.IsValidLureURLMode(v) {
|
||||
mode = v
|
||||
}
|
||||
if v, err := c.LureCodeAlgo.Get(); err == nil && lure.IsValidAlgorithm(lure.Algorithm(v)) {
|
||||
algorithm = lure.Algorithm(v)
|
||||
}
|
||||
if v, err := c.LureCodeLength.Get(); err == nil && v >= lure.MinLength && v <= lure.MaxLength {
|
||||
length = v
|
||||
}
|
||||
return mode, algorithm, length
|
||||
}
|
||||
|
||||
// UsesLureCodePath reports whether this campaign's lure URLs carry the recipient
|
||||
// as a path segment.
|
||||
func (c *Campaign) UsesLureCodePath() bool {
|
||||
mode, _, _ := c.LureSettings()
|
||||
return mode == data.LureURLModePath
|
||||
}
|
||||
|
||||
// Close sets the close at timestamp to now
|
||||
// dont confuse with method Closed
|
||||
func (c *Campaign) Close() error {
|
||||
|
||||
@@ -30,6 +30,11 @@ type CampaignRecipient struct {
|
||||
Recipient *Recipient `json:"recipient"`
|
||||
NotableEventID nullable.Nullable[uuid.UUID] `json:"notableEventID"`
|
||||
NotableEventName string `json:"notableEventName"`
|
||||
// LureCode is the identifier in the lure URL, stored and matched byte for
|
||||
// byte. Null releases it for reuse.
|
||||
LureCode nullable.Nullable[string] `json:"lureCode"`
|
||||
// LureCodeCustom marks a code set by the operator rather than generated.
|
||||
LureCodeCustom nullable.Nullable[bool] `json:"lureCodeCustom"`
|
||||
}
|
||||
|
||||
// Validate validates the campaign recipient
|
||||
@@ -104,6 +109,10 @@ func (c *CampaignRecipient) ToDBMap() map[string]any {
|
||||
m["notable_event_id"] = v
|
||||
}
|
||||
}
|
||||
// the lure code is left out. callers load a whole recipient, change one
|
||||
// field and write it back, so a code carried here would be restated on every
|
||||
// such write and collide with the unique index once another recipient holds
|
||||
// it. written only by Insert, SetLureCodeByID and ReleaseLureCodeByID.
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/oapi-codegen/nullable"
|
||||
"github.com/phishingclub/phishingclub/data"
|
||||
"github.com/phishingclub/phishingclub/errs"
|
||||
"github.com/phishingclub/phishingclub/lure"
|
||||
"github.com/phishingclub/phishingclub/validate"
|
||||
"github.com/phishingclub/phishingclub/vo"
|
||||
)
|
||||
@@ -54,6 +57,12 @@ type CampaignTemplate struct {
|
||||
|
||||
URLPath nullable.Nullable[vo.URLPath] `json:"urlPath"`
|
||||
|
||||
// defaults, snapshotted onto a campaign when it is scheduled, so editing them
|
||||
// here never changes a campaign already running.
|
||||
LureURLMode nullable.Nullable[string] `json:"lureURLMode"`
|
||||
LureCodeAlgo nullable.Nullable[string] `json:"lureCodeAlgo"`
|
||||
LureCodeLength nullable.Nullable[int] `json:"lureCodeLength"`
|
||||
|
||||
EmailID nullable.Nullable[uuid.UUID] `json:"emailID"`
|
||||
Email *Email `json:"email"`
|
||||
|
||||
@@ -91,6 +100,22 @@ func (c *CampaignTemplate) Validate() error {
|
||||
}
|
||||
// URLPath is optional, no validation needed
|
||||
|
||||
if v, err := c.LureURLMode.Get(); err == nil && !data.IsValidLureURLMode(v) {
|
||||
return errs.NewValidationError(
|
||||
errors.New("lure URL mode must be query or path"),
|
||||
)
|
||||
}
|
||||
if v, err := c.LureCodeAlgo.Get(); err == nil && !lure.IsValidAlgorithm(lure.Algorithm(v)) {
|
||||
return errs.NewValidationError(
|
||||
errors.New("unknown lure code algorithm"),
|
||||
)
|
||||
}
|
||||
if v, err := c.LureCodeLength.Get(); err == nil && (v < lure.MinLength || v > lure.MaxLength) {
|
||||
return errs.NewValidationError(
|
||||
fmt.Errorf("lure code length must be between %d and %d", lure.MinLength, lure.MaxLength),
|
||||
)
|
||||
}
|
||||
|
||||
// validate that only one type is set per stage
|
||||
// before landing page: can have neither (optional), or one type, but not both
|
||||
_, errBeforePage := c.BeforeLandingPageID.Get()
|
||||
@@ -261,6 +286,24 @@ func (c *CampaignTemplate) ToDBMap() map[string]any {
|
||||
}
|
||||
}
|
||||
}
|
||||
if c.LureURLMode.IsSpecified() {
|
||||
m["lure_url_mode"] = data.LureURLModeQuery
|
||||
if v, err := c.LureURLMode.Get(); err == nil && data.IsValidLureURLMode(v) {
|
||||
m["lure_url_mode"] = v
|
||||
}
|
||||
}
|
||||
if c.LureCodeAlgo.IsSpecified() {
|
||||
m["lure_code_algo"] = string(lure.DefaultAlgorithm)
|
||||
if v, err := c.LureCodeAlgo.Get(); err == nil && lure.IsValidAlgorithm(lure.Algorithm(v)) {
|
||||
m["lure_code_algo"] = v
|
||||
}
|
||||
}
|
||||
if c.LureCodeLength.IsSpecified() {
|
||||
m["lure_code_length"] = lure.DefaultLength
|
||||
if v, err := c.LureCodeLength.Get(); err == nil && v >= lure.MinLength && v <= lure.MaxLength {
|
||||
m["lure_code_length"] = v
|
||||
}
|
||||
}
|
||||
|
||||
_, errDomain := c.DomainID.Get()
|
||||
_, errSMTP := c.SMTPConfigurationID.Get()
|
||||
|
||||
+34
-4
@@ -325,7 +325,7 @@ func (m *ProxyHandler) initializeRequestContext(ctx context.Context, req *http.R
|
||||
}
|
||||
|
||||
// check for campaign recipient id
|
||||
campaignRecipientID, paramName := m.getCampaignRecipientIDFromURLParams(req)
|
||||
campaignRecipientID, paramName := m.getCampaignRecipientIDFromURLParams(req, domain)
|
||||
|
||||
reqCtx := &RequestContext{
|
||||
PhishDomain: req.Host,
|
||||
@@ -3038,14 +3038,27 @@ func (m *ProxyHandler) sameSiteToString(sameSite http.SameSite) string {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ProxyHandler) getCampaignRecipientIDFromURLParams(req *http.Request) (*uuid.UUID, string) {
|
||||
func (m *ProxyHandler) getCampaignRecipientIDFromURLParams(
|
||||
req *http.Request,
|
||||
domain *database.Domain,
|
||||
) (*uuid.UUID, string) {
|
||||
ctx := req.Context()
|
||||
|
||||
campaignRecipient, paramName, err := server.GetCampaignRecipientFromURLParams(
|
||||
// every path and query here mirrors the target site, so a code shaped segment
|
||||
// or value could belong to the target rather than a recipient. once a session
|
||||
// cookie exists the recipient is known, so both code forms go off: that ends
|
||||
// the misattribution risk, which here also means an established session being
|
||||
// torn down mid flow, and spares every subresource an extra lookup. a UUID
|
||||
// cannot collide with target content by accident, so it stays on.
|
||||
allowLureCodeLookup := !m.hasValidSessionCookie(req)
|
||||
|
||||
campaignRecipient, match, err := server.GetCampaignRecipientFromURLParams(
|
||||
ctx,
|
||||
req,
|
||||
m.IdentifierRepository,
|
||||
m.CampaignRecipientRepository,
|
||||
domain,
|
||||
allowLureCodeLookup,
|
||||
)
|
||||
if err != nil {
|
||||
m.logger.Errorw("failed to get identifiers for URL param extraction", "error", err)
|
||||
@@ -3056,8 +3069,15 @@ func (m *ProxyHandler) getCampaignRecipientIDFromURLParams(req *http.Request) (*
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
// take a consumed code out of the path before anything downstream sees the
|
||||
// URL. rewrite rules compare the path exactly and whatever is left is
|
||||
// forwarded to the target. the query form is stripped later via ParamName.
|
||||
if match.PathSegment != "" {
|
||||
req.URL.Path = server.TrimLastPathSegment(req.URL.Path)
|
||||
}
|
||||
|
||||
campaignRecipientID := campaignRecipient.ID.MustGet()
|
||||
return &campaignRecipientID, paramName
|
||||
return &campaignRecipientID, match.ParamName
|
||||
}
|
||||
|
||||
// applyEarlyRequestHeaderReplacements applies request header replacements before client creation
|
||||
@@ -3878,6 +3898,16 @@ func (m *ProxyHandler) IsValidProxyCookie(cookie string) bool {
|
||||
return m.isValidSessionCookie(cookie)
|
||||
}
|
||||
|
||||
// hasValidSessionCookie reports whether the request carries a live proxy
|
||||
// session, meaning the recipient behind it is already established.
|
||||
func (m *ProxyHandler) hasValidSessionCookie(req *http.Request) bool {
|
||||
sessionCookie, err := req.Cookie(m.cookieName)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return m.isValidSessionCookie(sessionCookie.Value)
|
||||
}
|
||||
|
||||
// checkResponseRules checks if any response rules match the current request
|
||||
func (m *ProxyHandler) checkResponseRules(req *http.Request, reqCtx *RequestContext) *http.Response {
|
||||
// check global response rules first
|
||||
|
||||
@@ -1520,6 +1520,31 @@ func (r *Campaign) HasEvent(
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// SetLureSettingsByID writes the lure URL settings taken from the campaign
|
||||
// template. The only path that writes them, because a create or update request
|
||||
// carries whatever a caller put in those fields and they must come from the
|
||||
// template instead.
|
||||
func (r *Campaign) SetLureSettingsByID(
|
||||
ctx context.Context,
|
||||
id *uuid.UUID,
|
||||
mode string,
|
||||
algorithm string,
|
||||
length int,
|
||||
) error {
|
||||
row := map[string]any{
|
||||
"lure_url_mode": mode,
|
||||
"lure_code_algo": algorithm,
|
||||
"lure_code_length": length,
|
||||
}
|
||||
AddUpdatedAt(row)
|
||||
res := r.DB.
|
||||
Model(&database.Campaign{}).
|
||||
Where("id = ?", id).
|
||||
Updates(row)
|
||||
|
||||
return res.Error
|
||||
}
|
||||
|
||||
// UpdateByID updates a campaign by id
|
||||
// does not update the campaign recipient groups and campaign recipients
|
||||
func (r *Campaign) UpdateByID(
|
||||
@@ -2131,6 +2156,9 @@ func ToCampaign(row *database.Campaign) (*model.Campaign, error) {
|
||||
Webhooks: webhooks,
|
||||
NotableEventID: notableEventID,
|
||||
NotableEventName: notableEventName,
|
||||
LureURLMode: nullable.NewNullableWithValue(row.LureURLMode),
|
||||
LureCodeAlgo: nullable.NewNullableWithValue(row.LureCodeAlgo),
|
||||
LureCodeLength: nullable.NewNullableWithValue(row.LureCodeLength),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -119,6 +119,13 @@ func (r *CampaignRecipient) Insert(
|
||||
row := campaignRecipient.ToDBMap()
|
||||
row["id"] = id
|
||||
AddTimestamps(row)
|
||||
// ToDBMap omits the lure code, so it is carried here
|
||||
if code, err := campaignRecipient.LureCode.Get(); err == nil && code != "" {
|
||||
row["lure_code"] = code
|
||||
}
|
||||
if custom, err := campaignRecipient.LureCodeCustom.Get(); err == nil {
|
||||
row["lure_code_custom"] = custom
|
||||
}
|
||||
|
||||
res := r.DB.
|
||||
Model(&database.CampaignRecipient{}).
|
||||
@@ -130,6 +137,30 @@ func (r *CampaignRecipient) Insert(
|
||||
return &id, nil
|
||||
}
|
||||
|
||||
// SetLureCodeByID assigns an operator chosen code to one recipient. The only
|
||||
// path that writes a code after insert, and it touches nothing else, so a caller
|
||||
// holding a stale recipient model cannot restate a code through it.
|
||||
func (r *CampaignRecipient) SetLureCodeByID(
|
||||
ctx context.Context,
|
||||
id *uuid.UUID,
|
||||
code string,
|
||||
) error {
|
||||
row := map[string]any{
|
||||
"lure_code": code,
|
||||
"lure_code_custom": true,
|
||||
}
|
||||
AddUpdatedAt(row)
|
||||
res := r.DB.
|
||||
Model(&database.CampaignRecipient{}).
|
||||
Where(
|
||||
fmt.Sprintf("%s = ?", TableColumnID(database.CAMPAIGN_RECIPIENT_TABLE_NAME)),
|
||||
id.String(),
|
||||
).
|
||||
Updates(row)
|
||||
|
||||
return res.Error
|
||||
}
|
||||
|
||||
// DeleteRecipientsNotIn deletes recipients in campaign that are
|
||||
// not in the slice recipient ids supplied
|
||||
func (r *CampaignRecipient) DeleteRecipientsNotIn(
|
||||
@@ -273,6 +304,235 @@ func (r *CampaignRecipient) GetByCampaignRecipientID(
|
||||
return ToCampaignRecipient(&dbCampaignRecipient)
|
||||
}
|
||||
|
||||
// GetByLureCodeOnDomain gets a campaign recipient by the code carried in a
|
||||
// request, but only when the code belongs to a campaign reachable on the domain
|
||||
// serving it.
|
||||
//
|
||||
// The match is case sensitive, keeping Special-42 distinct from special-42 as
|
||||
// base58 requires. A released code is null and so drops out without a separate
|
||||
// predicate, which is also what stops an anonymized recipient's link working.
|
||||
//
|
||||
// The domain predicate keeps a guessed code from reaching across the instance.
|
||||
// Codes are unique instance wide and short enough to guess, so without it a
|
||||
// guess on any domain would resolve a recipient of any campaign, including
|
||||
// another company's. A campaign is reachable on its template's own domain and on
|
||||
// a proxy domain whose proxy the template uses for one of its pages.
|
||||
func (r *CampaignRecipient) GetByLureCodeOnDomain(
|
||||
ctx context.Context,
|
||||
code string,
|
||||
domain *database.Domain,
|
||||
) (*model.CampaignRecipient, error) {
|
||||
if code == "" || domain == nil {
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
var dbCampaignRecipient database.CampaignRecipient
|
||||
query := r.DB.
|
||||
Model(&database.CampaignRecipient{}).
|
||||
Joins(fmt.Sprintf(
|
||||
"JOIN `%s` ON %s = %s",
|
||||
database.CAMPAIGN_TABLE,
|
||||
TableColumnID(database.CAMPAIGN_TABLE),
|
||||
TableColumn(database.CAMPAIGN_RECIPIENT_TABLE_NAME, "campaign_id"),
|
||||
)).
|
||||
Joins(fmt.Sprintf(
|
||||
"JOIN `%s` ON %s = %s",
|
||||
database.CAMPAIGN_TEMPLATE_TABLE,
|
||||
TableColumnID(database.CAMPAIGN_TEMPLATE_TABLE),
|
||||
TableColumn(database.CAMPAIGN_TABLE, "campaign_template_id"),
|
||||
)).
|
||||
Where(
|
||||
fmt.Sprintf("%s = ?", TableColumn(database.CAMPAIGN_RECIPIENT_TABLE_NAME, "lure_code")),
|
||||
code,
|
||||
).
|
||||
// restating the index predicate. equality already excludes nulls, so no
|
||||
// result changes, but the planner matches the partial unique index without
|
||||
// deriving it. this sits on the request path, where a scan is paid on
|
||||
// every visit.
|
||||
Where(
|
||||
fmt.Sprintf("%s IS NOT NULL", TableColumn(database.CAMPAIGN_RECIPIENT_TABLE_NAME, "lure_code")),
|
||||
)
|
||||
|
||||
if domain.ProxyID != nil {
|
||||
// a proxy domain serves whichever template routes a page through that
|
||||
// proxy, while the template's own domain still serves evasion and deny.
|
||||
// the parentheses are written here rather than left to the builder:
|
||||
// unparenthesised this would widen to match any campaign using the proxy
|
||||
// regardless of the rest of the clause, and it is what confines a guessed
|
||||
// code to one domain.
|
||||
query = query.Where(
|
||||
fmt.Sprintf(
|
||||
"(%s = ? OR %s = ? OR %s = ? OR %s = ?)",
|
||||
TableColumn(database.CAMPAIGN_TEMPLATE_TABLE, "domain_id"),
|
||||
TableColumn(database.CAMPAIGN_TEMPLATE_TABLE, "before_landing_proxy_id"),
|
||||
TableColumn(database.CAMPAIGN_TEMPLATE_TABLE, "landing_proxy_id"),
|
||||
TableColumn(database.CAMPAIGN_TEMPLATE_TABLE, "after_landing_proxy_id"),
|
||||
),
|
||||
domain.ID,
|
||||
domain.ProxyID,
|
||||
domain.ProxyID,
|
||||
domain.ProxyID,
|
||||
)
|
||||
} else {
|
||||
query = query.Where(
|
||||
fmt.Sprintf("%s = ?", TableColumn(database.CAMPAIGN_TEMPLATE_TABLE, "domain_id")),
|
||||
domain.ID,
|
||||
)
|
||||
}
|
||||
|
||||
res := query.
|
||||
Select(TableColumnAll(database.CAMPAIGN_RECIPIENT_TABLE_NAME)).
|
||||
First(&dbCampaignRecipient)
|
||||
|
||||
if res.Error != nil {
|
||||
return nil, res.Error
|
||||
}
|
||||
return ToCampaignRecipient(&dbCampaignRecipient)
|
||||
}
|
||||
|
||||
// findTakenLureCodesChunkSize keeps each IN clause inside the sqlite bound
|
||||
// variable limit, 999 on the most restrictive builds.
|
||||
const findTakenLureCodesChunkSize = 500
|
||||
|
||||
// FindTakenLureCodes returns the subset of codes already claimed by a recipient
|
||||
// whose code has not been released.
|
||||
//
|
||||
// Querying lure_code, the column carrying the uniqueness constraint, catches a
|
||||
// collision with an operator written code here rather than through a failed
|
||||
// insert part way through scheduling. Probing a whole batch costs a handful of
|
||||
// queries instead of one insert and retry per recipient.
|
||||
func (r *CampaignRecipient) FindTakenLureCodes(
|
||||
ctx context.Context,
|
||||
codes []string,
|
||||
) ([]string, error) {
|
||||
taken := []string{}
|
||||
for start := 0; start < len(codes); start += findTakenLureCodesChunkSize {
|
||||
end := start + findTakenLureCodesChunkSize
|
||||
if end > len(codes) {
|
||||
end = len(codes)
|
||||
}
|
||||
chunk := []string{}
|
||||
res := r.DB.
|
||||
Model(&database.CampaignRecipient{}).
|
||||
Where(
|
||||
fmt.Sprintf("%s IN ?", TableColumn(database.CAMPAIGN_RECIPIENT_TABLE_NAME, "lure_code")),
|
||||
codes[start:end],
|
||||
).
|
||||
Where(
|
||||
fmt.Sprintf("%s IS NOT NULL", TableColumn(database.CAMPAIGN_RECIPIENT_TABLE_NAME, "lure_code")),
|
||||
).
|
||||
Pluck("lure_code", &chunk)
|
||||
|
||||
if res.Error != nil {
|
||||
return nil, res.Error
|
||||
}
|
||||
taken = append(taken, chunk...)
|
||||
}
|
||||
return taken, nil
|
||||
}
|
||||
|
||||
// GetActiveByLureCode returns the recipient holding a code, so the owning
|
||||
// campaign can be named when an operator tries to reuse it.
|
||||
func (r *CampaignRecipient) GetActiveByLureCode(
|
||||
ctx context.Context,
|
||||
code string,
|
||||
) (*model.CampaignRecipient, error) {
|
||||
var dbCampaignRecipient database.CampaignRecipient
|
||||
res := r.DB.
|
||||
Preload("Campaign").
|
||||
Where(
|
||||
fmt.Sprintf("%s = ?", TableColumn(database.CAMPAIGN_RECIPIENT_TABLE_NAME, "lure_code")),
|
||||
code,
|
||||
).
|
||||
Where(
|
||||
fmt.Sprintf("%s IS NOT NULL", TableColumn(database.CAMPAIGN_RECIPIENT_TABLE_NAME, "lure_code")),
|
||||
).
|
||||
First(&dbCampaignRecipient)
|
||||
|
||||
if res.Error != nil {
|
||||
return nil, res.Error
|
||||
}
|
||||
return ToCampaignRecipient(&dbCampaignRecipient)
|
||||
}
|
||||
|
||||
// ReleaseLureCodeByID frees a recipient's code for reuse. Nulling rather than
|
||||
// flagging takes it out of the unique index and stops it showing against a
|
||||
// recipient who no longer owns it. The row and its events are untouched.
|
||||
func (r *CampaignRecipient) ReleaseLureCodeByID(
|
||||
ctx context.Context,
|
||||
id *uuid.UUID,
|
||||
) error {
|
||||
row := map[string]any{
|
||||
"lure_code": nil,
|
||||
}
|
||||
AddUpdatedAt(row)
|
||||
res := r.DB.
|
||||
Model(&database.CampaignRecipient{}).
|
||||
Where(
|
||||
fmt.Sprintf("%s = ?", TableColumnID(database.CAMPAIGN_RECIPIENT_TABLE_NAME)),
|
||||
id.String(),
|
||||
).
|
||||
Updates(row)
|
||||
|
||||
return res.Error
|
||||
}
|
||||
|
||||
// HasRecipientsByCampaignID reports whether the campaign still holds any
|
||||
// recipient row. Scheduling reads it to decide whether the lure settings are
|
||||
// settled: a campaign holding recipients has links out resolving through those
|
||||
// rows. A non self managed reschedule deletes them first, which is what lets it
|
||||
// pick up a template change, its old links being dead either way.
|
||||
func (r *CampaignRecipient) HasRecipientsByCampaignID(
|
||||
ctx context.Context,
|
||||
campaignID *uuid.UUID,
|
||||
) (bool, error) {
|
||||
// campaign_id leads the campaign and recipient unique index, so this stops at
|
||||
// the first entry rather than counting the campaign
|
||||
ids := []string{}
|
||||
res := r.DB.
|
||||
Model(&database.CampaignRecipient{}).
|
||||
Where(
|
||||
fmt.Sprintf("%s = ?", TableColumn(database.CAMPAIGN_RECIPIENT_TABLE_NAME, "campaign_id")),
|
||||
campaignID,
|
||||
).
|
||||
Limit(1).
|
||||
Pluck("id", &ids)
|
||||
|
||||
if res.Error != nil {
|
||||
return false, res.Error
|
||||
}
|
||||
return len(ids) > 0, nil
|
||||
}
|
||||
|
||||
// HasCustomLureCodesByCampaignID reports whether any recipient in the campaign
|
||||
// carries an operator set code. The recipient table is paginated, so this cannot
|
||||
// be derived from one page of rows.
|
||||
func (r *CampaignRecipient) HasCustomLureCodesByCampaignID(
|
||||
ctx context.Context,
|
||||
campaignID *uuid.UUID,
|
||||
) (bool, error) {
|
||||
// the flag is a literal because sqlite chooses the partial index at prepare
|
||||
// time, where a bound value is unknown. keep it in step with the predicate
|
||||
// in database.CampaignRecipient.Migrate. campaign_id is plucked so the
|
||||
// answer comes from the index without reading the row.
|
||||
ids := []string{}
|
||||
res := r.DB.
|
||||
Model(&database.CampaignRecipient{}).
|
||||
Where(
|
||||
fmt.Sprintf("%s = ?", TableColumn(database.CAMPAIGN_RECIPIENT_TABLE_NAME, "campaign_id")),
|
||||
campaignID,
|
||||
).
|
||||
Where(
|
||||
fmt.Sprintf("%s = 1", TableColumn(database.CAMPAIGN_RECIPIENT_TABLE_NAME, "lure_code_custom")),
|
||||
).
|
||||
Limit(1).
|
||||
Pluck("campaign_id", &ids)
|
||||
|
||||
if res.Error != nil {
|
||||
return false, res.Error
|
||||
}
|
||||
return len(ids) > 0, nil
|
||||
}
|
||||
|
||||
// GetUnsendRecipients gets campaign recipients that were never attempted and are
|
||||
// not already cancelled (cancelled_at IS NULL AND last_attempt_at IS NULL). Used
|
||||
// at campaign close to cancel only sends that never started; recipients that were
|
||||
@@ -461,8 +721,11 @@ func (r *CampaignRecipient) Anonymize(
|
||||
recipientID *uuid.UUID,
|
||||
anonymizedID *uuid.UUID,
|
||||
) error {
|
||||
// releasing here stops the recipient's lure link resolving and hands the code
|
||||
// back for reuse
|
||||
row := map[string]interface{}{
|
||||
"anonymized_id": anonymizedID.String(),
|
||||
"lure_code": nil,
|
||||
}
|
||||
AddUpdatedAt(row)
|
||||
db := r.DB.Model(&database.CampaignRecipient{})
|
||||
@@ -627,6 +890,11 @@ func ToCampaignRecipient(row *database.CampaignRecipient) (*model.CampaignRecipi
|
||||
notableEventID = nullable.NewNullableWithValue(*row.NotableEventID)
|
||||
notableEventName = cache.EventNameByID[row.NotableEventID.String()]
|
||||
}
|
||||
var lureCode nullable.Nullable[string]
|
||||
lureCode.SetNull()
|
||||
if row.LureCode != nil {
|
||||
lureCode = nullable.NewNullableWithValue(*row.LureCode)
|
||||
}
|
||||
return &model.CampaignRecipient{
|
||||
ID: id,
|
||||
CancelledAt: cancelledAt,
|
||||
@@ -641,5 +909,7 @@ func ToCampaignRecipient(row *database.CampaignRecipient) (*model.CampaignRecipi
|
||||
Recipient: recipient,
|
||||
NotableEventID: notableEventID,
|
||||
NotableEventName: notableEventName,
|
||||
LureCode: lureCode,
|
||||
LureCodeCustom: nullable.NewNullableWithValue(row.LureCodeCustom),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+157
-26
@@ -3,56 +3,187 @@ package server
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/phishingclub/phishingclub/database"
|
||||
"github.com/phishingclub/phishingclub/lure"
|
||||
"github.com/phishingclub/phishingclub/model"
|
||||
"github.com/phishingclub/phishingclub/repository"
|
||||
)
|
||||
|
||||
// GetCampaignRecipientFromURLParams extracts campaign recipient information from URL parameters
|
||||
// by checking all identifiers against query parameters and finding the first matching campaign recipient.
|
||||
// returns the campaign recipient object, parameter name, and any error encountered.
|
||||
// LastPathSegment returns the final segment of a decoded request path, with any
|
||||
// trailing slash trimmed. Mail clients and message previews append one, so
|
||||
// /account/4H7K9QM2XR3T/ must resolve the same as the link without it.
|
||||
//
|
||||
// Traversal is refused rather than cleaned, because a segment needing a clean is
|
||||
// never a lure code. lure.IsCandidate refuses a doubled dot on the same terms,
|
||||
// so a code that can be stored can always be resolved back.
|
||||
func LastPathSegment(path string) (string, bool) {
|
||||
if path == "" || strings.Contains(path, "..") {
|
||||
return "", false
|
||||
}
|
||||
trimmed := strings.TrimSuffix(path, "/")
|
||||
if trimmed == "" {
|
||||
return "", false
|
||||
}
|
||||
index := strings.LastIndex(trimmed, "/")
|
||||
if index < 0 {
|
||||
return trimmed, true
|
||||
}
|
||||
segment := trimmed[index+1:]
|
||||
if segment == "" {
|
||||
return "", false
|
||||
}
|
||||
return segment, true
|
||||
}
|
||||
|
||||
// lureCodeFromPath returns the trailing path segment of a request URL when it
|
||||
// could be a lure code.
|
||||
//
|
||||
// URL.Path is already decoded, so an encoded separator has become a real one by
|
||||
// the time it is read and would silently change which segment is last. RawPath
|
||||
// is the only place that separator is still visible.
|
||||
//
|
||||
// Only the separator is refused, not every re encoding. A code may carry
|
||||
// characters Go escapes in a path, such as a bracket or anything non ASCII, and
|
||||
// each sets RawPath on its own. Refusing on RawPath alone would make all of
|
||||
// those unreachable while IsValidCustom still accepted them.
|
||||
func lureCodeFromPath(u *url.URL) (string, bool) {
|
||||
if u == nil {
|
||||
return "", false
|
||||
}
|
||||
if strings.Contains(u.RawPath, "%2f") || strings.Contains(u.RawPath, "%2F") {
|
||||
return "", false
|
||||
}
|
||||
segment, ok := LastPathSegment(u.Path)
|
||||
if !ok || !lure.IsCandidate(segment) {
|
||||
return "", false
|
||||
}
|
||||
return segment, true
|
||||
}
|
||||
|
||||
// TrimLastPathSegment removes the final segment of a path, taking a consumed
|
||||
// lure code back out of the URL before the request is forwarded on.
|
||||
func TrimLastPathSegment(path string) string {
|
||||
trimmed := strings.TrimSuffix(path, "/")
|
||||
index := strings.LastIndex(trimmed, "/")
|
||||
if index <= 0 {
|
||||
return "/"
|
||||
}
|
||||
return trimmed[:index]
|
||||
}
|
||||
|
||||
// LureMatch records how a request identified its recipient, so a caller can undo
|
||||
// whatever carried the identifier before passing the request on.
|
||||
type LureMatch struct {
|
||||
// ParamName is the query parameter that matched. Empty when the identifier
|
||||
// came from the path.
|
||||
ParamName string
|
||||
// PathSegment is the trailing path segment that matched. Empty when the
|
||||
// identifier came from the query.
|
||||
PathSegment string
|
||||
}
|
||||
|
||||
// GetCampaignRecipientFromURLParams resolves the campaign recipient a request
|
||||
// belongs to.
|
||||
//
|
||||
// Three forms are accepted, in this order:
|
||||
//
|
||||
// - a query parameter holding a campaign recipient UUID, the original form
|
||||
// - a query parameter holding a lure code, so an operator set code such as
|
||||
// special-42 works as ?id=special-42 too
|
||||
// - the last path segment as a lure code, https://example.com/acc/4H7K9QM2XR3T
|
||||
//
|
||||
// All three are accepted whatever the campaign's mode. The mode only decides
|
||||
// which form is emitted, so changing it never breaks a delivered link.
|
||||
//
|
||||
// Query beats path. A query identifier states which recipient a request belongs
|
||||
// to, while a path segment only looks like a code: a template whose URL path
|
||||
// ends in /careers collides with any custom code holding that string.
|
||||
//
|
||||
// Both code forms resolve only against campaigns reachable on the serving
|
||||
// domain. A code is short enough to guess, so without that a guess made on any
|
||||
// domain would reach a recipient of any campaign, including another company's.
|
||||
//
|
||||
// allowLureCodeLookup turns the code forms off, leaving only the UUID. The proxy
|
||||
// does that once a session cookie exists, because there every path and query
|
||||
// mirrors the target site and the recipient is already known. A UUID cannot
|
||||
// collide with target site content by accident, so it stays on.
|
||||
func GetCampaignRecipientFromURLParams(
|
||||
ctx context.Context,
|
||||
req *http.Request,
|
||||
identifierRepo *repository.Identifier,
|
||||
campaignRecipientRepo *repository.CampaignRecipient,
|
||||
) (*model.CampaignRecipient, string, error) {
|
||||
domain *database.Domain,
|
||||
allowLureCodeLookup bool,
|
||||
) (*model.CampaignRecipient, LureMatch, error) {
|
||||
// get all identifiers
|
||||
identifiers, err := identifierRepo.GetAll(ctx, &repository.IdentifierOption{})
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
return nil, LureMatch{}, err
|
||||
}
|
||||
|
||||
// a code resolves only against the serving domain, so without one there is
|
||||
// nothing to match it to
|
||||
lookupCodes := allowLureCodeLookup && domain != nil
|
||||
|
||||
query := req.URL.Query()
|
||||
var matchingParams []struct {
|
||||
var matchingUUIDParams []struct {
|
||||
name string
|
||||
id *uuid.UUID
|
||||
}
|
||||
var matchingCodeParams []struct {
|
||||
name string
|
||||
value string
|
||||
}
|
||||
|
||||
// collect all query parameters that match identifier names and can be parsed as UUIDs
|
||||
// split matching identifier params by whether they carry a UUID or a code
|
||||
for _, identifier := range identifiers.Rows {
|
||||
if name := identifier.Name.MustGet(); query.Has(name) {
|
||||
if id, err := uuid.Parse(query.Get(name)); err == nil {
|
||||
matchingParams = append(matchingParams, struct {
|
||||
name string
|
||||
id *uuid.UUID
|
||||
}{name: name, id: &id})
|
||||
name := identifier.Name.MustGet()
|
||||
if !query.Has(name) {
|
||||
continue
|
||||
}
|
||||
value := query.Get(name)
|
||||
if id, err := uuid.Parse(value); err == nil {
|
||||
matchingUUIDParams = append(matchingUUIDParams, struct {
|
||||
name string
|
||||
id *uuid.UUID
|
||||
}{name: name, id: &id})
|
||||
continue
|
||||
}
|
||||
if lookupCodes && lure.IsCandidate(value) {
|
||||
matchingCodeParams = append(matchingCodeParams, struct {
|
||||
name string
|
||||
value string
|
||||
}{name: name, value: value})
|
||||
}
|
||||
}
|
||||
|
||||
// check each matching parameter to find a valid campaign recipient
|
||||
for _, param := range matchingUUIDParams {
|
||||
campaignRecipient, err := campaignRecipientRepo.GetByCampaignRecipientID(ctx, param.id)
|
||||
if err == nil && campaignRecipient != nil {
|
||||
return campaignRecipient, LureMatch{ParamName: param.name}, nil
|
||||
}
|
||||
}
|
||||
for _, param := range matchingCodeParams {
|
||||
campaignRecipient, err := campaignRecipientRepo.GetByLureCodeOnDomain(ctx, param.value, domain)
|
||||
if err == nil && campaignRecipient != nil {
|
||||
return campaignRecipient, LureMatch{ParamName: param.name}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// nothing in the query matched, so read the last path segment as a code
|
||||
if lookupCodes {
|
||||
if segment, ok := lureCodeFromPath(req.URL); ok {
|
||||
campaignRecipient, err := campaignRecipientRepo.GetByLureCodeOnDomain(ctx, segment, domain)
|
||||
if err == nil && campaignRecipient != nil {
|
||||
return campaignRecipient, LureMatch{PathSegment: segment}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(matchingParams) == 0 {
|
||||
return nil, "", nil
|
||||
}
|
||||
|
||||
// check each matching parameter to find a valid campaign recipient
|
||||
for _, param := range matchingParams {
|
||||
campaignRecipient, err := campaignRecipientRepo.GetByCampaignRecipientID(ctx, param.id)
|
||||
if err == nil && campaignRecipient != nil {
|
||||
return campaignRecipient, param.name, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, "", nil
|
||||
return nil, LureMatch{}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/phishingclub/phishingclub/lure"
|
||||
)
|
||||
|
||||
func TestLastPathSegment(t *testing.T) {
|
||||
cases := []struct {
|
||||
path string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{"/4H7K9QM2XR3T", "4H7K9QM2XR3T", true},
|
||||
{"/account/login/4H7K9QM2XR3T", "4H7K9QM2XR3T", true},
|
||||
// a trailing slash is added by some mail clients and previews
|
||||
{"/account/4H7K9QM2XR3T/", "4H7K9QM2XR3T", true},
|
||||
{"/logo.png", "logo.png", true},
|
||||
{"/", "", false},
|
||||
{"", "", false},
|
||||
// traversal is refused rather than cleaned
|
||||
{"/a/../b", "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, ok := LastPathSegment(c.path)
|
||||
if ok != c.ok || got != c.want {
|
||||
t.Errorf("LastPathSegment(%q) = %q,%v want %q,%v", c.path, got, ok, c.want, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLureCodeFromPath(t *testing.T) {
|
||||
// URL.Path is already decoded, so how the path was written can only be judged
|
||||
// on the parsed URL and not on a path string
|
||||
cases := []struct {
|
||||
raw string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{"https://example.com/4H7K9QM2XR3T", "4H7K9QM2XR3T", true},
|
||||
{"https://example.com/account/4H7K9QM2XR3T/", "4H7K9QM2XR3T", true},
|
||||
{"https://example.com/special-42", "special-42", true},
|
||||
{"https://example.com/", "", false},
|
||||
// an encoded separator decodes into a real one and would move which segment
|
||||
// is last, so the request is refused
|
||||
{"https://example.com/a%2Fb", "", false},
|
||||
{"https://example.com/a%2fb", "", false},
|
||||
// characters go escapes when re encoding set RawPath on their own, and
|
||||
// IsValidCustom accepts them, so they must still resolve
|
||||
{"https://example.com/invoice(1)", "invoice(1)", true},
|
||||
{"https://example.com/special!42", "special!42", true},
|
||||
{"https://example.com/a%5Bb%5D", "a[b]", true},
|
||||
{"https://example.com/caf%C3%A9-42", "café-42", true},
|
||||
// a redundant encoding resolves to the code it spells
|
||||
{"https://example.com/%41BCDEF", "ABCDEF", true},
|
||||
// values IsCandidate rules out never reach a database probe
|
||||
{"https://example.com/a%20b", "", false},
|
||||
{"https://example.com/..", "", false},
|
||||
{"https://example.com/invoice..pdf", "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
u, err := url.Parse(c.raw)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse %q: %v", c.raw, err)
|
||||
}
|
||||
got, ok := lureCodeFromPath(u)
|
||||
if ok != c.ok || got != c.want {
|
||||
t.Errorf("lureCodeFromPath(%q) = %q,%v want %q,%v", c.raw, got, ok, c.want, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorableCustomCodesResolveFromPath(t *testing.T) {
|
||||
// the two rules live in different packages, so a value only one side accepts
|
||||
// is a link that gets delivered and never resolves
|
||||
for _, code := range []string{
|
||||
"special-42",
|
||||
"Special_42",
|
||||
"HR-Survey-2026",
|
||||
"invoice.pdf",
|
||||
".hidden",
|
||||
"invoice..pdf",
|
||||
"v1..2",
|
||||
"..",
|
||||
".",
|
||||
"a b",
|
||||
} {
|
||||
u, err := url.Parse("https://example.com/login/" + code)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse a URL carrying %q: %v", code, err)
|
||||
}
|
||||
got, ok := lureCodeFromPath(u)
|
||||
storable := lure.IsValidCustom(code)
|
||||
if storable != ok {
|
||||
t.Errorf("IsValidCustom(%q) = %v but lureCodeFromPath = %v", code, storable, ok)
|
||||
}
|
||||
if ok && got != code {
|
||||
t.Errorf("lureCodeFromPath returned %q for %q", got, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrimLastPathSegment(t *testing.T) {
|
||||
// a consumed code must leave the path before forwarding, or rewrite rules
|
||||
// that compare the path exactly stop matching
|
||||
cases := []struct {
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{"/signin/4H7K9QM2XR3T", "/signin"},
|
||||
{"/a/b/4H7K9QM2XR3T", "/a/b"},
|
||||
{"/4H7K9QM2XR3T", "/"},
|
||||
{"/signin/4H7K9QM2XR3T/", "/signin"},
|
||||
{"/", "/"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := TrimLastPathSegment(c.path); got != c.want {
|
||||
t.Errorf("TrimLastPathSegment(%q) = %q, want %q", c.path, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -870,11 +870,9 @@ func (a *APISender) buildRequestWithCustomURL(
|
||||
}
|
||||
|
||||
// override campaign URL if custom one is provided
|
||||
// the only builder that knows about proxy first pages and path mode codes
|
||||
if customCampaignURL != "" {
|
||||
templateURL := fmt.Sprintf("https://%s%s?%s=%s", domainName, urlPath, urlKey, campaignRecipient.ID.MustGet().String())
|
||||
if customCampaignURL != templateURL {
|
||||
(*t)["URL"] = customCampaignURL
|
||||
}
|
||||
(*t)["URL"] = customCampaignURL
|
||||
}
|
||||
|
||||
// setup headers
|
||||
|
||||
+420
-18
@@ -29,6 +29,7 @@ import (
|
||||
"github.com/phishingclub/phishingclub/database"
|
||||
"github.com/phishingclub/phishingclub/errs"
|
||||
"github.com/phishingclub/phishingclub/log"
|
||||
"github.com/phishingclub/phishingclub/lure"
|
||||
"github.com/phishingclub/phishingclub/model"
|
||||
"github.com/phishingclub/phishingclub/remotebrowser"
|
||||
"github.com/phishingclub/phishingclub/repository"
|
||||
@@ -42,6 +43,7 @@ import (
|
||||
// Campaign is the Campaign service
|
||||
type Campaign struct {
|
||||
Common
|
||||
LureCodeService *LureCode
|
||||
CampaignRepository *repository.Campaign
|
||||
CampaignRecipientRepository *repository.CampaignRecipient
|
||||
RecipientRepository *repository.Recipient
|
||||
@@ -315,6 +317,214 @@ func applyJitter(baseTime time.Time, jitterMin, jitterMax int, startBound, endBo
|
||||
return jitteredTime
|
||||
}
|
||||
|
||||
// lureCodeAllocator hands out codes drawn up front for one schedule run. A
|
||||
// campaign not using codes gets an empty allocator, so its recipients keep
|
||||
// resolving by campaign recipient UUID alone.
|
||||
type lureCodeAllocator struct {
|
||||
codes []lure.Code
|
||||
next int
|
||||
state lureCodeAllocatorState
|
||||
}
|
||||
|
||||
// lureCodeAllocatorState carries what a redraw needs.
|
||||
type lureCodeAllocatorState struct {
|
||||
// false for a query mode campaign, which separates wanting no codes from a
|
||||
// batch that ran short
|
||||
enabled bool
|
||||
algorithm lure.Algorithm
|
||||
length int
|
||||
}
|
||||
|
||||
// applyTo sets the next unused code on a recipient.
|
||||
//
|
||||
// Over drawing is harmless, an uninserted code is never claimed. Running short
|
||||
// is not: the recipient would fall back to a query URL in a campaign whose point
|
||||
// is the path form. The batch covers the worst case, so a shortfall is a bug.
|
||||
func (a *lureCodeAllocator) applyTo(campaignRecipient *model.CampaignRecipient) error {
|
||||
var display nullable.Nullable[string]
|
||||
display.SetNull()
|
||||
if a != nil && a.state.enabled {
|
||||
if a.next >= len(a.codes) {
|
||||
return errs.Wrap(errors.New(
|
||||
"ran out of allocated lure codes while scheduling this campaign",
|
||||
))
|
||||
}
|
||||
display = nullable.NewNullableWithValue(a.codes[a.next].Display)
|
||||
a.next++
|
||||
}
|
||||
campaignRecipient.LureCode = display
|
||||
return nil
|
||||
}
|
||||
|
||||
// newLureCodeAllocator draws every code a schedule run needs in one batch.
|
||||
func (c *Campaign) newLureCodeAllocator(
|
||||
ctx context.Context,
|
||||
campaign *model.Campaign,
|
||||
count int,
|
||||
) (*lureCodeAllocator, error) {
|
||||
if !campaign.UsesLureCodePath() || count <= 0 || c.LureCodeService == nil {
|
||||
return &lureCodeAllocator{}, nil
|
||||
}
|
||||
_, algorithm, length := campaign.LureSettings()
|
||||
codes, err := c.LureCodeService.AllocateBatch(ctx, algorithm, length, count)
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
return &lureCodeAllocator{
|
||||
codes: codes,
|
||||
state: lureCodeAllocatorState{
|
||||
enabled: true,
|
||||
algorithm: algorithm,
|
||||
length: length,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// lureCodeInsertRetries bounds the redraws on insert. Allocation checks a code
|
||||
// is free, but the insert lands later, so a concurrent schedule run or an
|
||||
// operator setting a custom code can claim it in between.
|
||||
const lureCodeInsertRetries = 3
|
||||
|
||||
// isLureCodeConflict reports whether an insert failed because the code was taken
|
||||
// between allocation and insert.
|
||||
//
|
||||
// Error translation is off on the gorm session, so a duplicate key does not
|
||||
// surface as gorm.ErrDuplicatedKey. Matching the message instead is narrowed to
|
||||
// this column so an unrelated constraint is never swallowed and retried.
|
||||
func isLureCodeConflict(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
return true
|
||||
}
|
||||
message := err.Error()
|
||||
return strings.Contains(message, "UNIQUE constraint failed") &&
|
||||
strings.Contains(message, "campaign_recipients.lure_code")
|
||||
}
|
||||
|
||||
// insertScheduledRecipient inserts one scheduled recipient, redrawing its code if
|
||||
// it was claimed between allocation and insert. The loop has no transaction to
|
||||
// roll back, so a losing race would otherwise abort a half written schedule.
|
||||
func (c *Campaign) insertScheduledRecipient(
|
||||
ctx context.Context,
|
||||
allocator *lureCodeAllocator,
|
||||
campaignRecipient *model.CampaignRecipient,
|
||||
) error {
|
||||
for attempt := 0; ; attempt++ {
|
||||
_, err := c.CampaignRecipientRepository.Insert(ctx, campaignRecipient)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if attempt >= lureCodeInsertRetries ||
|
||||
!isLureCodeConflict(err) ||
|
||||
allocator == nil ||
|
||||
!allocator.state.enabled ||
|
||||
c.LureCodeService == nil {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
c.Logger.Infow("lure code was claimed between allocation and insert, redrawing",
|
||||
"attempt", attempt+1,
|
||||
)
|
||||
codes, drawErr := c.LureCodeService.AllocateBatch(
|
||||
ctx,
|
||||
allocator.state.algorithm,
|
||||
allocator.state.length,
|
||||
1,
|
||||
)
|
||||
if drawErr != nil || len(codes) == 0 {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
campaignRecipient.LureCode = nullable.NewNullableWithValue(codes[0].Display)
|
||||
}
|
||||
}
|
||||
|
||||
// snapshotLureSettings copies the template's lure URL settings onto the campaign
|
||||
// while it still holds no recipients.
|
||||
//
|
||||
// A campaign holding recipients has links out that resolve through those rows,
|
||||
// so reading the template again could hand a new recipient a different URL form
|
||||
// than the ones already delivered. That is the self managed case, where a
|
||||
// reschedule keeps existing recipients. A non self managed reschedule deletes
|
||||
// them all first and so does read the template again, which is safe because its
|
||||
// recipients are recreated with new IDs and the old links die either way.
|
||||
//
|
||||
// Nothing here is fatal, so a lure setting cannot stop a campaign scheduling. A
|
||||
// template that could not be read leaves the snapshot unwritten rather than
|
||||
// settling on the defaults, because the write closes the question for good and a
|
||||
// transient error would otherwise mark a path mode campaign as query mode with
|
||||
// no way back while it holds recipients.
|
||||
func (c *Campaign) snapshotLureSettings(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
campaign *model.Campaign,
|
||||
) {
|
||||
campaignID, err := campaign.ID.Get()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
hasRecipients, err := c.CampaignRecipientRepository.HasRecipientsByCampaignID(
|
||||
ctx,
|
||||
&campaignID,
|
||||
)
|
||||
if err != nil {
|
||||
// leaving the settings alone keeps the campaign consistent with whatever
|
||||
// its recipients already resolve through
|
||||
c.Logger.Errorw("failed to check for existing recipients, keeping lure settings",
|
||||
"error", err,
|
||||
)
|
||||
return
|
||||
}
|
||||
if hasRecipients {
|
||||
return
|
||||
}
|
||||
|
||||
mode := data.LureURLModeQuery
|
||||
algorithm := string(lure.DefaultAlgorithm)
|
||||
length := lure.DefaultLength
|
||||
|
||||
if templateID, err := campaign.TemplateID.Get(); err == nil {
|
||||
cTemplate, err := c.CampaignTemplateService.GetByID(
|
||||
ctx,
|
||||
session,
|
||||
&templateID,
|
||||
&repository.CampaignTemplateOption{},
|
||||
)
|
||||
if err != nil || cTemplate == nil {
|
||||
c.Logger.Errorw("could not read lure settings from template, leaving the snapshot unwritten",
|
||||
"campaignID", campaignID.String(),
|
||||
"templateID", templateID.String(),
|
||||
"error", err,
|
||||
)
|
||||
return
|
||||
}
|
||||
if v, err := cTemplate.LureURLMode.Get(); err == nil && data.IsValidLureURLMode(v) {
|
||||
mode = v
|
||||
}
|
||||
if v, err := cTemplate.LureCodeAlgo.Get(); err == nil && lure.IsValidAlgorithm(lure.Algorithm(v)) {
|
||||
algorithm = v
|
||||
}
|
||||
if v, err := cTemplate.LureCodeLength.Get(); err == nil && v >= lure.MinLength && v <= lure.MaxLength {
|
||||
length = v
|
||||
}
|
||||
}
|
||||
campaign.LureURLMode = nullable.NewNullableWithValue(mode)
|
||||
campaign.LureCodeAlgo = nullable.NewNullableWithValue(algorithm)
|
||||
campaign.LureCodeLength = nullable.NewNullableWithValue(length)
|
||||
|
||||
if err := c.CampaignRepository.SetLureSettingsByID(
|
||||
ctx,
|
||||
&campaignID,
|
||||
mode,
|
||||
algorithm,
|
||||
length,
|
||||
); err != nil {
|
||||
// the in memory campaign still carries them, so this run allocates
|
||||
// correctly even unpersisted
|
||||
c.Logger.Errorw("failed to persist lure settings snapshot", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Campaign) schedule(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
@@ -329,6 +539,9 @@ func (c *Campaign) schedule(
|
||||
if !isAuthorized {
|
||||
return errs.ErrAuthorizationFailed
|
||||
}
|
||||
// settle the lure settings before any row is written, so every recipient in
|
||||
// this run is allocated under the same rules
|
||||
c.snapshotLureSettings(ctx, session, campaign)
|
||||
|
||||
// get all recipients and remove duplicates
|
||||
recipients := []*model.Recipient{}
|
||||
@@ -425,8 +638,15 @@ func (c *Campaign) schedule(
|
||||
rid := recp.ID.MustGet()
|
||||
recipientIDs[i] = &rid
|
||||
}
|
||||
// enough for the worst case where every supplied recipient is new, unused
|
||||
// draws are discarded. before the delete below, so an exhausted code space
|
||||
// leaves the schedule untouched rather than half applied.
|
||||
allocator, err := c.newLureCodeAllocator(ctx, campaign, len(recipients))
|
||||
if err != nil {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
// c.Logger.Debugw("keeping recpient IDs", recipientIDs)
|
||||
err := c.CampaignRecipientRepository.DeleteRecipientsNotIn(
|
||||
err = c.CampaignRecipientRepository.DeleteRecipientsNotIn(
|
||||
ctx,
|
||||
&campaignID,
|
||||
recipientIDs,
|
||||
@@ -462,8 +682,12 @@ func (c *Campaign) schedule(
|
||||
CampaignID: nullable.NewNullableWithValue(campaignID),
|
||||
SelfManaged: nullable.NewNullableWithValue(true),
|
||||
}
|
||||
if err := allocator.applyTo(campaignRecipients[i]); err != nil {
|
||||
c.Logger.Errorw("failed to allocate lure code", "error", err)
|
||||
return err
|
||||
}
|
||||
// save campaign-recipient
|
||||
_, err = c.CampaignRecipientRepository.Insert(ctx, campaignRecipients[i])
|
||||
err = c.insertScheduledRecipient(ctx, allocator, campaignRecipients[i])
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to create campaign", "error", err)
|
||||
return errs.Wrap(err)
|
||||
@@ -503,6 +727,13 @@ func (c *Campaign) schedule(
|
||||
}
|
||||
scheduledEvent := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_SCHEDULED]
|
||||
|
||||
// before any row is written, so an exhausted code space fails before the
|
||||
// campaign is half scheduled
|
||||
allocator, err := c.newLureCodeAllocator(ctx, campaign, recipientsCount)
|
||||
if err != nil {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
// get jitter values if specified
|
||||
jitterMin := 0
|
||||
jitterMax := 0
|
||||
@@ -529,7 +760,11 @@ func (c *Campaign) schedule(
|
||||
SendAt: nullable.NewNullableWithValue(jitteredStartAt),
|
||||
NotableEventID: nullable.NewNullableWithValue(*scheduledEvent),
|
||||
}
|
||||
_, err := c.CampaignRecipientRepository.Insert(ctx, campaignRecipient)
|
||||
if err := allocator.applyTo(campaignRecipient); err != nil {
|
||||
c.Logger.Errorw("failed to allocate lure code", "error", err)
|
||||
return err
|
||||
}
|
||||
err := c.insertScheduledRecipient(ctx, allocator, campaignRecipient)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to create campaign", "error", err)
|
||||
return errs.Wrap(err)
|
||||
@@ -606,7 +841,11 @@ func (c *Campaign) schedule(
|
||||
SendAt: nullable.NewNullableWithValue(jitteredTime),
|
||||
NotableEventID: nullable.NewNullableWithValue(*scheduledEvent),
|
||||
}
|
||||
_, err := c.CampaignRecipientRepository.Insert(ctx, campaignRecipient)
|
||||
if err := allocator.applyTo(campaignRecipient); err != nil {
|
||||
c.Logger.Errorw("failed to allocate lure code", "error", err)
|
||||
return err
|
||||
}
|
||||
err := c.insertScheduledRecipient(ctx, allocator, campaignRecipient)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to create campaign", "error", err)
|
||||
return errs.Wrap(err)
|
||||
@@ -652,8 +891,12 @@ func (c *Campaign) schedule(
|
||||
SendAt: nullable.NewNullableWithValue(jitteredSentAt),
|
||||
NotableEventID: nullable.NewNullableWithValue(*scheduledEvent),
|
||||
}
|
||||
if err := allocator.applyTo(campaignRecipients[i]); err != nil {
|
||||
c.Logger.Errorw("failed to allocate lure code", "error", err)
|
||||
return err
|
||||
}
|
||||
// save
|
||||
_, err = c.CampaignRecipientRepository.Insert(ctx, campaignRecipients[i])
|
||||
err = c.insertScheduledRecipient(ctx, allocator, campaignRecipients[i])
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to create campaign", "error", err)
|
||||
return errs.Wrap(err)
|
||||
@@ -863,6 +1106,13 @@ func (c *Campaign) GetByID(
|
||||
c.Logger.Errorw("failed to get campaign by id", "error", err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
// the recipient table is paginated, so this cannot be derived from one page
|
||||
hasCustom, err := c.CampaignRecipientRepository.HasCustomLureCodesByCampaignID(ctx, id)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to check for custom lure codes", "error", err)
|
||||
} else {
|
||||
campaign.HasCustomLureCodes = hasCustom
|
||||
}
|
||||
// no audit on read
|
||||
return campaign, nil
|
||||
}
|
||||
@@ -2642,11 +2892,8 @@ func (c *Campaign) sendCampaignMessages(
|
||||
campaignCompanyID,
|
||||
)
|
||||
|
||||
// override campaign URL if it's different from template domain URL
|
||||
templateURL := fmt.Sprintf("https://%s%s?%s=%s", domainName.String(), urlPath, urlIdentifier.Name.MustGet(), recipientID.String())
|
||||
if customCampaignURL != templateURL {
|
||||
(*t)["URL"] = customCampaignURL
|
||||
}
|
||||
// the only builder that knows about proxy first pages and path mode codes
|
||||
(*t)["URL"] = customCampaignURL
|
||||
|
||||
// build per-recipient template funcs so that {{MicrosoftDeviceCode}} resolves
|
||||
// to a real device code for this campaign recipient.
|
||||
@@ -3736,10 +3983,32 @@ func (c *Campaign) GetLandingPageURLByCampaignRecipientID(
|
||||
}
|
||||
|
||||
// build final url
|
||||
separator := "?"
|
||||
url := fmt.Sprintf("%s%s%s%s=%s", baseURL, urlPath, separator, idIdentifier, campaignRecipientID.String())
|
||||
// no audit on read
|
||||
return url, nil
|
||||
return BuildLureURL(baseURL, urlPath, idIdentifier, campaignRecipient), nil
|
||||
}
|
||||
|
||||
// BuildLureURL assembles the URL delivered to a recipient.
|
||||
//
|
||||
// Holding a code is signal enough for the path form without consulting the
|
||||
// campaign: a generated code exists only for a path mode campaign, and an
|
||||
// operator set code is a deliberate request for that exact link. A recipient
|
||||
// with no code gets the query form carrying the campaign recipient UUID.
|
||||
func BuildLureURL(
|
||||
baseURL string,
|
||||
urlPath string,
|
||||
urlIdentifier string,
|
||||
campaignRecipient *model.CampaignRecipient,
|
||||
) string {
|
||||
if code, err := campaignRecipient.LureCode.Get(); err == nil && code != "" {
|
||||
return strings.TrimSuffix(baseURL+urlPath, "/") + "/" + code
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"%s%s?%s=%s",
|
||||
baseURL,
|
||||
urlPath,
|
||||
urlIdentifier,
|
||||
campaignRecipient.ID.MustGet().String(),
|
||||
)
|
||||
}
|
||||
|
||||
// getFirstPageProxy returns the proxy for the first page in the campaign flow
|
||||
@@ -3812,6 +4081,142 @@ func (c *Campaign) getPhishingDomainForProxy(ctx context.Context, proxy *model.P
|
||||
}
|
||||
|
||||
// SetSentAtByCampaignRecipientID sets the sent at time for a recipient
|
||||
// ErrLureCodeTaken is returned when the code is already claimed by a recipient
|
||||
// whose code has not been released.
|
||||
var ErrLureCodeTaken = errors.New("lure code is already in use")
|
||||
|
||||
// companyIDsEqual reports whether two optional company references point at the
|
||||
// same company, treating both unset as the global scope.
|
||||
func companyIDsEqual(a nullable.Nullable[uuid.UUID], b nullable.Nullable[uuid.UUID]) bool {
|
||||
aID, aErr := a.Get()
|
||||
bID, bErr := b.Get()
|
||||
if aErr != nil && bErr != nil {
|
||||
return true
|
||||
}
|
||||
if aErr != nil || bErr != nil {
|
||||
return false
|
||||
}
|
||||
return aID == bID
|
||||
}
|
||||
|
||||
// LureCodeConflict describes who currently holds a requested code, so the
|
||||
// operator can decide whether to reclaim it.
|
||||
type LureCodeConflict struct {
|
||||
CampaignID string `json:"campaignID"`
|
||||
CampaignName string `json:"campaignName"`
|
||||
IsClosed bool `json:"isClosed"`
|
||||
}
|
||||
|
||||
// SetLureCodeByCampaignRecipientID assigns an operator chosen code to a single
|
||||
// recipient.
|
||||
//
|
||||
// A custom code has close to no entropy, so anyone guessing it reaches the
|
||||
// landing page rendered for that recipient. That is the trade for a link that
|
||||
// has to be read aloud or typed, and why this is a per recipient action rather
|
||||
// than a campaign wide setting.
|
||||
//
|
||||
// A code held by another recipient fails with a conflict describing the owner.
|
||||
// Passing reclaim releases that older code first, so anyone still holding the
|
||||
// old link resolves into this campaign instead.
|
||||
func (c *Campaign) SetLureCodeByCampaignRecipientID(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
campaignRecipientID *uuid.UUID,
|
||||
code string,
|
||||
reclaim bool,
|
||||
) (*LureCodeConflict, error) {
|
||||
ae := NewAuditEvent("Campaign.SetLureCodeByCampaignRecipientID", session)
|
||||
ae.Details["campaignRecipientId"] = campaignRecipientID.String()
|
||||
ae.Details["lureCode"] = code
|
||||
ae.Details["reclaim"] = fmt.Sprintf("%t", reclaim)
|
||||
// check permissions
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
|
||||
c.LogAuthError(err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
if !isAuthorized {
|
||||
c.AuditLogNotAuthorized(ae)
|
||||
return nil, errs.ErrAuthorizationFailed
|
||||
}
|
||||
if !lure.IsValidCustom(code) {
|
||||
return nil, errs.NewValidationError(
|
||||
go_errors.Errorf(
|
||||
"lure URL must be 1 to %d characters, cannot be . or contain .. and cannot contain spaces or %s",
|
||||
lure.MaxCustomLength,
|
||||
lure.DisallowedCustomCharacters,
|
||||
),
|
||||
)
|
||||
}
|
||||
campaignRecipient, err := c.CampaignRecipientRepository.GetByID(
|
||||
ctx,
|
||||
campaignRecipientID,
|
||||
&repository.CampaignRecipientOption{
|
||||
WithCampaign: true,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to get campaign recipient by id", "error", err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
campaign := campaignRecipient.Campaign
|
||||
if campaign == nil || !campaign.IsActive() {
|
||||
return nil, errs.NewValidationError(errors.New("campaign is closed"))
|
||||
}
|
||||
if _, err := campaignRecipient.RecipientID.Get(); err != nil {
|
||||
return nil, errs.NewValidationError(errors.New("recipient is not available"))
|
||||
}
|
||||
|
||||
newCode := lure.NewCustomCode(code)
|
||||
// the same code again is a no op rather than a self conflict
|
||||
if current, err := campaignRecipient.LureCode.Get(); err == nil && current == newCode.Display {
|
||||
c.AuditLogAuthorized(ae)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
holder, err := c.CampaignRecipientRepository.GetActiveByLureCode(ctx, newCode.Display)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.Logger.Errorw("failed to check lure code owner", "error", err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
if holder != nil {
|
||||
conflict := &LureCodeConflict{}
|
||||
if holder.Campaign != nil {
|
||||
// codes are unique instance wide, so the holder may belong to another
|
||||
// company. naming it would disclose that company's campaign to someone
|
||||
// who only guessed a code, so only a shared company gets details.
|
||||
if companyIDsEqual(campaign.CompanyID, holder.Campaign.CompanyID) {
|
||||
holderCampaignID := holder.Campaign.ID.MustGet()
|
||||
conflict.CampaignID = holderCampaignID.String()
|
||||
if name, err := holder.Campaign.Name.Get(); err == nil {
|
||||
conflict.CampaignName = name.String()
|
||||
}
|
||||
conflict.IsClosed = !holder.Campaign.IsActive()
|
||||
}
|
||||
}
|
||||
if !reclaim {
|
||||
return conflict, ErrLureCodeTaken
|
||||
}
|
||||
holderID := holder.ID.MustGet()
|
||||
if err := c.CampaignRecipientRepository.ReleaseLureCodeByID(ctx, &holderID); err != nil {
|
||||
c.Logger.Errorw("failed to release lure code from previous owner", "error", err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
ae.Details["reclaimedFromCampaignRecipientId"] = holderID.String()
|
||||
}
|
||||
|
||||
if err := c.CampaignRecipientRepository.SetLureCodeByID(
|
||||
ctx,
|
||||
campaignRecipientID,
|
||||
newCode.Display,
|
||||
); err != nil {
|
||||
c.Logger.Errorw("failed to set lure code", "error", err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
c.AuditLogAuthorized(ae)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (c *Campaign) SetSentAtByCampaignRecipientID(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
@@ -4585,11 +4990,8 @@ func (c *Campaign) sendSingleEmailSMTP(
|
||||
campaignCompanyID,
|
||||
)
|
||||
|
||||
// override campaign URL if it's different from template domain URL
|
||||
templateURL := fmt.Sprintf("https://%s%s?%s=%s", domainName.String(), urlPath, urlIdentifier.Name.MustGet(), recipientID.String())
|
||||
if customCampaignURL != templateURL {
|
||||
(*t)["URL"] = customCampaignURL
|
||||
}
|
||||
// the only builder that knows about proxy first pages and path mode codes
|
||||
(*t)["URL"] = customCampaignURL
|
||||
|
||||
// custom headers support the same per recipient variables as the subject and body
|
||||
applyCustomSMTPHeaders(m, smtpConfig.Headers, t, recipientDeviceFuncs, c.Logger)
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/oapi-codegen/nullable"
|
||||
"github.com/phishingclub/phishingclub/data"
|
||||
"github.com/phishingclub/phishingclub/errs"
|
||||
"github.com/phishingclub/phishingclub/lure"
|
||||
"github.com/phishingclub/phishingclub/model"
|
||||
"github.com/phishingclub/phishingclub/repository"
|
||||
"github.com/phishingclub/phishingclub/validate"
|
||||
@@ -85,6 +86,17 @@ func (c *CampaignTemplate) Create(
|
||||
if !campaignTemplate.URLPath.IsSpecified() || campaignTemplate.URLPath.IsNull() {
|
||||
campaignTemplate.URLPath = nullable.NewNullableWithValue(*vo.NewURLPathMust(""))
|
||||
}
|
||||
// default to the query parameter behaviour, so a template created without
|
||||
// them keeps producing the URLs it always did
|
||||
if !campaignTemplate.LureURLMode.IsSpecified() || campaignTemplate.LureURLMode.IsNull() {
|
||||
campaignTemplate.LureURLMode = nullable.NewNullableWithValue(data.LureURLModeQuery)
|
||||
}
|
||||
if !campaignTemplate.LureCodeAlgo.IsSpecified() || campaignTemplate.LureCodeAlgo.IsNull() {
|
||||
campaignTemplate.LureCodeAlgo = nullable.NewNullableWithValue(string(lure.DefaultAlgorithm))
|
||||
}
|
||||
if !campaignTemplate.LureCodeLength.IsSpecified() || campaignTemplate.LureCodeLength.IsNull() {
|
||||
campaignTemplate.LureCodeLength = nullable.NewNullableWithValue(lure.DefaultLength)
|
||||
}
|
||||
// if no afterLandingPageRedirectURL set to ''
|
||||
if !campaignTemplate.AfterLandingPageRedirectURL.IsSpecified() || campaignTemplate.AfterLandingPageRedirectURL.IsNull() {
|
||||
campaignTemplate.AfterLandingPageRedirectURL = nullable.NewNullableWithValue(*vo.NewOptionalString255Must(""))
|
||||
@@ -725,6 +737,15 @@ func (c *CampaignTemplate) UpdateByID(
|
||||
incoming.URLPath.Set(*vo.NewURLPathMust(""))
|
||||
}
|
||||
}
|
||||
if v, err := campaignTemplate.LureURLMode.Get(); err == nil {
|
||||
incoming.LureURLMode.Set(v)
|
||||
}
|
||||
if v, err := campaignTemplate.LureCodeAlgo.Get(); err == nil {
|
||||
incoming.LureCodeAlgo.Set(v)
|
||||
}
|
||||
if v, err := campaignTemplate.LureCodeLength.Get(); err == nil {
|
||||
incoming.LureCodeLength.Set(v)
|
||||
}
|
||||
// validate
|
||||
if err := incoming.Validate(); err != nil {
|
||||
c.Logger.Errorw("failed to validate campaign template", "error", err)
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
"github.com/phishingclub/phishingclub/errs"
|
||||
"github.com/phishingclub/phishingclub/lure"
|
||||
"github.com/phishingclub/phishingclub/repository"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// allocateRounds bounds the redraws when some drawn codes are already taken.
|
||||
// Each round clears all but a vanishing fraction of a healthy space, so running
|
||||
// out means the space is too small rather than the draw unlucky.
|
||||
const allocateRounds = 3
|
||||
|
||||
// LureCode allocates the short identifiers used in lure URLs.
|
||||
type LureCode struct {
|
||||
CampaignRecipientRepository *repository.CampaignRecipient
|
||||
Logger *zap.SugaredLogger
|
||||
}
|
||||
|
||||
// AllocateBatch returns n distinct codes that are free at the time of the call.
|
||||
//
|
||||
// The batch is drawn up front and checked in chunked queries rather than
|
||||
// inserting each code and retrying on conflict, so a ten thousand recipient
|
||||
// campaign costs a couple of dozen queries.
|
||||
//
|
||||
// Running out of rounds is the only exhaustion signal. A capacity threshold
|
||||
// would need a count over every live code and would still only estimate what
|
||||
// the next draw hits.
|
||||
func (l *LureCode) AllocateBatch(
|
||||
ctx context.Context,
|
||||
algorithm lure.Algorithm,
|
||||
length int,
|
||||
n int,
|
||||
) ([]lure.Code, error) {
|
||||
if n <= 0 {
|
||||
return []lure.Code{}, nil
|
||||
}
|
||||
if !lure.IsValidAlgorithm(algorithm) {
|
||||
return nil, errs.NewValidationError(
|
||||
errors.Errorf("unknown lure code algorithm: %s", algorithm),
|
||||
)
|
||||
}
|
||||
if length < lure.MinLength || length > lure.MaxLength {
|
||||
return nil, errs.NewValidationError(
|
||||
errors.Errorf(
|
||||
"lure code length must be between %d and %d",
|
||||
lure.MinLength,
|
||||
lure.MaxLength,
|
||||
),
|
||||
)
|
||||
}
|
||||
// keyed on the code, the column uniqueness sits on, so internal duplicates
|
||||
// are rejected on the same terms the database will
|
||||
pool := make(map[string]lure.Code, n)
|
||||
for round := 0; round < allocateRounds; round++ {
|
||||
// the shortfall in one pass. this repeats only for codes the map rejected
|
||||
// as internal duplicates
|
||||
for len(pool) < n {
|
||||
codes, err := lure.GenerateBatch(algorithm, length, n-len(pool))
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
for _, code := range codes {
|
||||
pool[code.Display] = code
|
||||
}
|
||||
}
|
||||
candidates := make([]string, 0, len(pool))
|
||||
for display := range pool {
|
||||
candidates = append(candidates, display)
|
||||
}
|
||||
taken, err := l.CampaignRecipientRepository.FindTakenLureCodes(ctx, candidates)
|
||||
if err != nil {
|
||||
l.Logger.Errorw("failed to check taken lure codes", "error", err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
if len(taken) == 0 {
|
||||
codes := make([]lure.Code, 0, len(pool))
|
||||
for _, code := range pool {
|
||||
codes = append(codes, code)
|
||||
}
|
||||
return codes, nil
|
||||
}
|
||||
for _, display := range taken {
|
||||
delete(pool, display)
|
||||
}
|
||||
}
|
||||
return nil, errs.NewValidationError(
|
||||
errors.Errorf(
|
||||
"could not allocate %d unique lure codes at length %d, use a longer code length",
|
||||
n,
|
||||
length,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -54,13 +54,7 @@ func (t *Template) CreateMail(
|
||||
rid := campaignRecipient.ID.MustGet()
|
||||
ridStr := rid.String()
|
||||
baseURL := "https://" + domainName
|
||||
url := fmt.Sprintf(
|
||||
"%s%s?%s=%s",
|
||||
baseURL,
|
||||
urlPath,
|
||||
idKey,
|
||||
ridStr,
|
||||
)
|
||||
url := BuildLureURL(baseURL, urlPath, idKey, campaignRecipient)
|
||||
// set body
|
||||
trackingPixelPath := fmt.Sprintf(
|
||||
"%s/wf/open?upn=%s",
|
||||
@@ -416,6 +410,9 @@ func (t *Template) CreatePhishingPageWithCampaignAndRecipient(
|
||||
|
||||
queryParams := parsedURL.Query()
|
||||
|
||||
// the page flow url keeps the query form even in path mode, because the
|
||||
// encrypted state param driving the next page travels in the query string
|
||||
// regardless. path mode covers the delivered link, the one a recipient reads.
|
||||
// only add campaign parameters if they don't already exist
|
||||
if !queryParams.Has(urlIdentifier) {
|
||||
queryParams.Set(urlIdentifier, id)
|
||||
|
||||
+10
-2
@@ -133,7 +133,11 @@ type URLPath struct {
|
||||
inner string
|
||||
}
|
||||
|
||||
// NewURLPath creates a new URL path
|
||||
// NewURLPath creates a new URL path.
|
||||
//
|
||||
// The leading slash is added because callers write the path straight after the
|
||||
// domain, where hello would give https://example.comhello. Stored rows are read
|
||||
// back through here and normalised too. An empty path already joins correctly.
|
||||
func NewURLPath(s string) (*URLPath, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
p, err := url.Parse(s)
|
||||
@@ -143,8 +147,12 @@ func NewURLPath(s string) (*URLPath, error) {
|
||||
"URLPath",
|
||||
)
|
||||
}
|
||||
path := p.Path
|
||||
if path != "" && !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
return &URLPath{
|
||||
inner: p.Path,
|
||||
inner: path,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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,65 @@
|
||||
apiSender: null,
|
||||
urlIdentifier: 'id',
|
||||
stateIdentifier: 'session',
|
||||
urlPath: ''
|
||||
urlPath: '',
|
||||
lureURLMode: 'query',
|
||||
lureCodeAlgo: 'crockford32',
|
||||
lureCodeLength: 12
|
||||
};
|
||||
|
||||
// codes are matched exactly, so the choice is about which glyphs appear and
|
||||
// how many characters are needed, not about how forgiving matching is
|
||||
const LURE_CODE_ALGOS = {
|
||||
crockford32: {
|
||||
label: 'Crockford base32',
|
||||
symbols: 32,
|
||||
sample: '4H7K9QM2XR3T',
|
||||
note: 'Upper case only, without I L O U.'
|
||||
},
|
||||
base58: {
|
||||
label: 'Base58',
|
||||
symbols: 58,
|
||||
sample: 'zQ8fRt2mKp9x',
|
||||
note: 'Mixed case, without 0 O I l.'
|
||||
}
|
||||
};
|
||||
|
||||
const LURE_CODE_MIN_LENGTH = 6;
|
||||
const LURE_CODE_MAX_LENGTH = 16;
|
||||
|
||||
// the backend rejects anything outside these bounds, so correct it here
|
||||
// rather than bouncing the whole form back with a validation error
|
||||
/** @param {string|number} value */
|
||||
const clampLureCodeLength = (value) => {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) {
|
||||
return 12;
|
||||
}
|
||||
return Math.min(LURE_CODE_MAX_LENGTH, Math.max(LURE_CODE_MIN_LENGTH, Math.round(n)));
|
||||
};
|
||||
|
||||
/** @param {string} algo @param {number} length */
|
||||
const lureCodeKeyspace = (algo, length) => {
|
||||
const spec = LURE_CODE_ALGOS[algo];
|
||||
if (!spec || !length || length < LURE_CODE_MIN_LENGTH || length > LURE_CODE_MAX_LENGTH) {
|
||||
return '';
|
||||
}
|
||||
return `${(spec.symbols ** length).toLocaleString('en-US', { maximumFractionDigits: 0 })} combinations`;
|
||||
};
|
||||
|
||||
// mirrors the server, which stores the path with the slash added
|
||||
/** @param {string} path */
|
||||
const withLeadingSlash = (path) => (path && !path.startsWith('/') ? `/${path}` : path);
|
||||
|
||||
// shows the delivered link so the choice is concrete
|
||||
/** @param {Object} values */
|
||||
const lureURLExample = (values) => {
|
||||
const path = withLeadingSlash(values.urlPath || '');
|
||||
if (values.lureURLMode === 'path') {
|
||||
const sample = LURE_CODE_ALGOS[values.lureCodeAlgo]?.sample ?? '4H7K9QM2XR3T';
|
||||
return `https://domain${path.replace(/\/$/, '')}/${sample}`;
|
||||
}
|
||||
return `https://domain${path}?${values.urlIdentifier || 'id'}=6ba7b810-9dad-11d1-80b4-00c04fd430c8`;
|
||||
};
|
||||
|
||||
let contextCompanyID = null;
|
||||
@@ -351,6 +409,9 @@
|
||||
urlIdentifierID: identifierMap.byValueOrNull(formValues.urlIdentifier),
|
||||
stateIdentifierID: identifierMap.byValueOrNull(formValues.stateIdentifier),
|
||||
urlPath: formValues.urlPath || '',
|
||||
lureURLMode: formValues.lureURLMode,
|
||||
lureCodeAlgo: formValues.lureCodeAlgo,
|
||||
lureCodeLength: clampLureCodeLength(formValues.lureCodeLength),
|
||||
companyID: contextCompanyID
|
||||
});
|
||||
if (!res.success) {
|
||||
@@ -390,7 +451,10 @@
|
||||
afterLandingPageRedirectURL: formValues.afterLandingPageRedirectURL || '',
|
||||
urlIdentifierID: identifierMap.byValueOrNull(formValues.urlIdentifier),
|
||||
stateIdentifierID: identifierMap.byValueOrNull(formValues.stateIdentifier),
|
||||
urlPath: formValues.urlPath || ''
|
||||
urlPath: formValues.urlPath || '',
|
||||
lureURLMode: formValues.lureURLMode,
|
||||
lureCodeAlgo: formValues.lureCodeAlgo,
|
||||
lureCodeLength: clampLureCodeLength(formValues.lureCodeLength)
|
||||
});
|
||||
if (!res.success) {
|
||||
modalError = res.error;
|
||||
@@ -449,7 +513,10 @@
|
||||
apiSender: null,
|
||||
urlIdentifier: 'id',
|
||||
stateIdentifier: 'session',
|
||||
urlPath: ''
|
||||
urlPath: '',
|
||||
lureURLMode: 'query',
|
||||
lureCodeAlgo: 'crockford32',
|
||||
lureCodeLength: 12
|
||||
};
|
||||
modalError = '';
|
||||
showAdvancedOptions = false;
|
||||
@@ -562,6 +629,9 @@
|
||||
formValues.urlIdentifier = identifierMap.byKey(template.urlIdentifierID);
|
||||
formValues.stateIdentifier = identifierMap.byKey(template.stateIdentifierID);
|
||||
formValues.urlPath = template.urlPath || '';
|
||||
formValues.lureURLMode = template.lureURLMode || 'query';
|
||||
formValues.lureCodeAlgo = template.lureCodeAlgo || 'crockford32';
|
||||
formValues.lureCodeLength = template.lureCodeLength || 12;
|
||||
|
||||
// set advanced options visibility based on template configuration
|
||||
showAdvancedOptions = !!(
|
||||
@@ -571,6 +641,7 @@
|
||||
(template.urlIdentifierID && identifierMap.byKey(template.urlIdentifierID) !== 'id') ||
|
||||
(template.stateIdentifierID &&
|
||||
identifierMap.byKey(template.stateIdentifierID) !== 'session') ||
|
||||
(template.lureURLMode && template.lureURLMode !== 'query') ||
|
||||
template.apiSenderID
|
||||
) // Show advanced if using External API
|
||||
);
|
||||
@@ -1160,10 +1231,62 @@ Simulation URLs to allow:\n${allowListingData.simulationUrl}\n
|
||||
placeholder="/employee/login">URL Path</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 +1295,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';
|
||||
@@ -68,6 +70,8 @@
|
||||
closedAt: null,
|
||||
template: null,
|
||||
isTest: false,
|
||||
hasCustomLureCodes: false,
|
||||
lureURLMode: 'query',
|
||||
constraintWeekDays: null,
|
||||
constraintStartTime: null,
|
||||
constraintEndTime: null,
|
||||
@@ -148,6 +152,7 @@
|
||||
let isAnonymizeDataModalVisible = false;
|
||||
let isSendMessageModalVisible = false;
|
||||
let isSetAsSentModalVisible = false;
|
||||
let isLureCodeModalVisible = false;
|
||||
let isSessionSushiModalVisible = false;
|
||||
let isTrackingPixelWarningVisible = false;
|
||||
let isReportedCSVModalVisible = false;
|
||||
@@ -175,6 +180,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
|
||||
@@ -355,6 +366,10 @@
|
||||
campaign.recipientGroups = t.recipientGroupIDs.map((id) => recipientGroupMap.byKey(id));
|
||||
campaign.notableEventName = t.notableEventName;
|
||||
campaign.scheduleAt = t.scheduleAt ?? null;
|
||||
// whether any recipient carries an operator set code. the recipient
|
||||
// table is paginated, so it cannot be derived from the rows on screen
|
||||
campaign.hasCustomLureCodes = !!t.hasCustomLureCodes;
|
||||
campaign.lureURLMode = t.lureURLMode ?? 'query';
|
||||
if (t.sendStartAt === null && t.sendEndAt === null) {
|
||||
isSelfManaged = true;
|
||||
}
|
||||
@@ -671,6 +686,114 @@
|
||||
sendMessageRecipient = null;
|
||||
};
|
||||
|
||||
const LURE_CODE_MAX_LENGTH = 64;
|
||||
|
||||
const LURE_CODE_DISALLOWED = `/ \\ % ? # " ' < >`;
|
||||
|
||||
// mirrors lure.IsCandidate on the server. names the character that failed
|
||||
// rather than restating every rule, which sits under the field.
|
||||
/** @param {string} value @returns {string} empty when the value can be used */
|
||||
const validateLureCode = (value) => {
|
||||
if (!value) {
|
||||
return 'Enter a lure URL.';
|
||||
}
|
||||
if (value.length > LURE_CODE_MAX_LENGTH) {
|
||||
return `Too long: ${value.length} characters, the maximum is ${LURE_CODE_MAX_LENGTH}.`;
|
||||
}
|
||||
// a lone dot names a directory, which the browser resolves away before
|
||||
// the request is sent, so it could never arrive back
|
||||
if (value === '.') {
|
||||
return 'Cannot be . on its own, a browser resolves that away.';
|
||||
}
|
||||
// a doubled dot anywhere makes the whole path unresolvable, so a code
|
||||
// carrying one would be delivered and never work
|
||||
if (value.includes('..')) {
|
||||
return 'Cannot contain .. anywhere, the link would never resolve.';
|
||||
}
|
||||
const disallowed = value.match(/[/%?#\\"'<>]/);
|
||||
if (disallowed) {
|
||||
return `Cannot contain: ${disallowed[0]}`;
|
||||
}
|
||||
for (const character of value) {
|
||||
const code = character.codePointAt(0) ?? 0;
|
||||
if (code === 0x20) {
|
||||
return 'Cannot contain spaces.';
|
||||
}
|
||||
if (code <= 0x20 || code === 0x7f) {
|
||||
return 'Cannot contain tabs, line breaks or other control characters.';
|
||||
}
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
// path the template puts in front of the code, used for the preview
|
||||
$: campaignURLPath = (campaign?.template?.urlPath || '').replace(/\/$/, '');
|
||||
|
||||
// a path mode campaign gives every recipient a code, so the column is worth
|
||||
// showing even before anyone sets one by hand
|
||||
$: showLureCodeColumn = campaign.hasCustomLureCodes || campaign.lureURLMode === 'path';
|
||||
|
||||
/** @param {Object} recp */
|
||||
const showLureCodeModal = (recp) => {
|
||||
lureCodeRecipient = {
|
||||
id: recp.id,
|
||||
name: `${recp.recipient?.firstName || ''} ${recp.recipient?.lastName || ''}`.trim(),
|
||||
email: recp.recipient?.email,
|
||||
sentAt: recp.sentAt,
|
||||
current: recp.lureCode || ''
|
||||
};
|
||||
lureCodeValue = recp.lureCode || '';
|
||||
lureCodeError = '';
|
||||
lureCodeConflict = null;
|
||||
isLureCodeModalVisible = true;
|
||||
};
|
||||
|
||||
const closeLureCodeModal = () => {
|
||||
isLureCodeModalVisible = false;
|
||||
lureCodeRecipient = null;
|
||||
lureCodeValue = '';
|
||||
lureCodeError = '';
|
||||
lureCodeConflict = null;
|
||||
};
|
||||
|
||||
/** @param {boolean} reclaim */
|
||||
const submitLureCode = async (reclaim = false) => {
|
||||
if (!lureCodeRecipient) {
|
||||
return;
|
||||
}
|
||||
const invalid = validateLureCode(lureCodeValue);
|
||||
if (invalid) {
|
||||
lureCodeError = invalid;
|
||||
return;
|
||||
}
|
||||
lureCodeSubmitting = true;
|
||||
lureCodeError = '';
|
||||
try {
|
||||
const res = await api.campaign.setLureCode(lureCodeRecipient.id, lureCodeValue, reclaim);
|
||||
if (!res.success) {
|
||||
// 409 carries the campaign currently holding the code so the
|
||||
// operator can decide whether to take it over
|
||||
if (res.statusCode === 409) {
|
||||
lureCodeConflict = res.data || {};
|
||||
lureCodeError = res.error || 'Lure code is already in use';
|
||||
return;
|
||||
}
|
||||
lureCodeError = res.error || 'Failed to set lure URL';
|
||||
return;
|
||||
}
|
||||
addToast('Lure URL updated', 'Success');
|
||||
closeLureCodeModal();
|
||||
// the campaign carries what decides the lure URL column, so it reloads
|
||||
// alongside the rows
|
||||
await Promise.all([setCampaign(), refreshCampaignRecipients()]);
|
||||
} catch (e) {
|
||||
lureCodeError = 'Failed to set lure URL';
|
||||
console.error('failed to set lure code', e);
|
||||
} finally {
|
||||
lureCodeSubmitting = false;
|
||||
}
|
||||
};
|
||||
|
||||
/** @param {string} campaignRecipientID @param {Object} recipient */
|
||||
const showSetAsSentModal = (campaignRecipientID, recipient) => {
|
||||
setAsSentRecipient = {
|
||||
@@ -2183,7 +2306,8 @@
|
||||
{ column: 'Status', size: 'small' },
|
||||
{ column: 'Send at', title: 'Scheduled', size: 'small' },
|
||||
{ column: 'Sent at', title: 'Delivered', size: 'small' },
|
||||
{ column: 'Cancelled at', size: 'small' }
|
||||
{ column: 'Cancelled at', size: 'small' },
|
||||
...(showLureCodeColumn ? [{ column: 'Lure URL', size: 'small' }] : [])
|
||||
]}
|
||||
sortable={[
|
||||
'First name',
|
||||
@@ -2277,6 +2401,9 @@
|
||||
<TableCell value={recp?.sendAt} isDate />
|
||||
<TableCell value={recp?.sentAt} isDate />
|
||||
<TableCell value={recp?.cancelledAt} isDate />
|
||||
{#if showLureCodeColumn}
|
||||
<TableCell value={recp?.lureCode || ''} />
|
||||
{/if}
|
||||
{#if !campaign.sentAt}
|
||||
<TableCellEmpty />
|
||||
<TableCellAction>
|
||||
@@ -2327,6 +2454,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 +3032,103 @@
|
||||
</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={LURE_CODE_MAX_LENGTH}
|
||||
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>
|
||||
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Up to {LURE_CODE_MAX_LENGTH} characters. Cannot contain spaces,
|
||||
<span class="font-mono">..</span>
|
||||
or
|
||||
<span class="font-mono">{LURE_CODE_DISALLOWED}</span>
|
||||
</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}
|
||||
<!-- a holder outside this company is not described, so nothing
|
||||
about that campaign is disclosed to a guessed code -->
|
||||
Already used by a campaign outside this company.
|
||||
{/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}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: build down up up-low-mem fix-tls backend-purge backend-down purge logs backend-password dbgate-down dbgate-up geoip-fetch govulncheck test-proxy test-proxy-fast
|
||||
.PHONY: build down up up-low-mem fix-tls backend-purge backend-down purge logs backend-password dbgate-down dbgate-up geoip-fetch govulncheck test-proxy test-proxy-fast test-lure test-lure-fast
|
||||
up:
|
||||
sudo docker compose up -d backend frontend api-test-server pebble dbgate mailer dns test mitmproxy; \
|
||||
sudo docker compose logs -f --tail 1000 backend frontend;
|
||||
@@ -190,6 +190,21 @@ test-proxy:
|
||||
test-proxy-fast:
|
||||
sudo docker compose exec -w /app backend go test ./proxy/... -v
|
||||
|
||||
# lure URL code generation and the path segment helpers the request resolver
|
||||
# uses. same standalone container approach as test-proxy above.
|
||||
test-lure:
|
||||
sudo docker run --rm \
|
||||
-e GOCACHE=/gocache \
|
||||
-v $(CURDIR)/backend:/app \
|
||||
-v phishingclub_gocache:/gocache \
|
||||
-w /app \
|
||||
golang:1.25.10 \
|
||||
go test ./lure/... ./server/... -v
|
||||
|
||||
# same tests inside the already running backend container
|
||||
test-lure-fast:
|
||||
sudo docker compose exec -w /app backend go test ./lure/... ./server/... -v
|
||||
|
||||
# security
|
||||
govulncheck:
|
||||
sudo docker run --rm \
|
||||
|
||||
Reference in New Issue
Block a user