add campaign with url paths

Signed-off-by: RonniSkansing <rskansing@gmail.com>
This commit is contained in:
RonniSkansing
2026-08-10 18:24:37 +02:00
parent dfcfafd502
commit adc9ae43b0
31 changed files with 2338 additions and 72 deletions
+12
View File
@@ -42,6 +42,7 @@ type JSONResponseHandler interface {
Forbidden(g *gin.Context)
BadRequest(g *gin.Context)
BadRequestMessage(g *gin.Context, message string)
Conflict(g *gin.Context, message string, data any)
ValidationFailed(g *gin.Context, field string, err error)
ServerError(g *gin.Context)
ServerErrorMessage(g *gin.Context, message string)
@@ -130,6 +131,17 @@ func (r *jsonResponseHandler) BadRequestMessage(g *gin.Context, message string)
g.Abort()
}
// Conflict responds 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)
+2
View File
@@ -192,6 +192,7 @@ const (
ROUTE_V1_CAMPAIGN_RECIPIENT_EMAIL = "/api/v1/campaign/recipient/:id/email"
ROUTE_V1_CAMPAIGN_RECIPIENT_URL = "/api/v1/campaign/recipient/:id/url"
ROUTE_V1_CAMPAIGN_RECIPIENT_SET_SENT = "/api/v1/campaign/recipient/:id/sent"
ROUTE_V1_CAMPAIGN_RECIPIENT_LURE_CODE = "/api/v1/campaign/recipient/:id/lure-code"
ROUTE_V1_CAMPAIGN_RECIPIENT_SEND_EMAIL = "/api/v1/campaign/recipient/:id/send"
// asset
ROUTE_V1_ASSET = "/api/v1/asset"
@@ -533,6 +534,7 @@ func setupRoutes(
GET(ROUTE_V1_CAMPAIGN_RECIPIENT_EMAIL, middleware.SessionHandler, controllers.Campaign.GetCampaignEmail).
GET(ROUTE_V1_CAMPAIGN_RECIPIENT_URL, middleware.SessionHandler, controllers.Campaign.GetCampaignURL).
POST(ROUTE_V1_CAMPAIGN_RECIPIENT_SET_SENT, middleware.SessionHandler, controllers.Campaign.SetSentAtByCampaignRecipientID).
PUT(ROUTE_V1_CAMPAIGN_RECIPIENT_LURE_CODE, middleware.SessionHandler, controllers.Campaign.SetLureCode).
POST(ROUTE_V1_CAMPAIGN_RECIPIENT_SEND_EMAIL, middleware.SessionHandler, controllers.Campaign.SendEmailByCampaignRecipientID).
// asset
GET(ROUTE_V1_ASSET_DOMAIN_VIEW, middleware.SessionHandler, controllers.Asset.GetContentByID).
+4
View File
@@ -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)
+5
View File
@@ -212,8 +212,13 @@ func NewServices(
Common: common,
RemoteBrowserRepository: repositories.RemoteBrowser,
}
lureCode := &service.LureCode{
CampaignRecipientRepository: repositories.CampaignRecipient,
Logger: common.Logger,
}
campaign := &service.Campaign{
Common: common,
LureCodeService: lureCode,
CampaignRepository: repositories.Campaign,
CampaignRecipientRepository: repositories.CampaignRecipient,
RecipientRepository: repositories.Recipient,
+43
View File
@@ -927,6 +927,49 @@ func (c *Campaign) UpdateByID(g *gin.Context) {
c.Response.OK(g, gin.H{})
}
// setLureCodeRequest is the body for assigning an operator chosen lure code.
type setLureCodeRequest struct {
Code string `json:"code"`
// Reclaim releases the code from whichever recipient 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
+20
View File
@@ -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
}
+8
View File
@@ -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
+109
View File
@@ -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), " ")
}
+11
View File
@@ -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"`
+78
View File
@@ -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)
}
+126
View File
@@ -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
}
+226
View File
@@ -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")
}
}
+42
View File
@@ -10,6 +10,7 @@ import (
"github.com/oapi-codegen/nullable"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/lure"
"github.com/phishingclub/phishingclub/utils"
"github.com/phishingclub/phishingclub/validate"
"github.com/phishingclub/phishingclub/vo"
@@ -69,6 +70,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 {
+9
View File
@@ -30,6 +30,11 @@ type CampaignRecipient struct {
Recipient *Recipient `json:"recipient"`
NotableEventID nullable.Nullable[uuid.UUID] `json:"notableEventID"`
NotableEventName string `json:"notableEventName"`
// LureCode is the identifier 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
}
+43
View File
@@ -1,13 +1,16 @@
package model
import (
"fmt"
"time"
"github.com/go-errors/errors"
"github.com/google/uuid"
"github.com/oapi-codegen/nullable"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/lure"
"github.com/phishingclub/phishingclub/validate"
"github.com/phishingclub/phishingclub/vo"
)
@@ -54,6 +57,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
View File
@@ -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
+28
View File
@@ -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
}
+270
View File
@@ -119,6 +119,13 @@ func (r *CampaignRecipient) Insert(
row := campaignRecipient.ToDBMap()
row["id"] = id
AddTimestamps(row)
// ToDBMap omits the lure code, so it is carried 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
}
+3
View File
@@ -953,6 +953,9 @@ func ToCampaignTemplate(row *database.CampaignTemplate) (*model.CampaignTemplate
StateIdentifierID: stateIdentifierID,
StateIdentifier: stateIdentifier,
URLPath: urlPath,
LureURLMode: nullable.NewNullableWithValue(row.LureURLMode),
LureCodeAlgo: nullable.NewNullableWithValue(row.LureCodeAlgo),
LureCodeLength: nullable.NewNullableWithValue(row.LureCodeLength),
IsUsable: isUsable,
}, nil
}
+157 -26
View File
@@ -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
}
+123
View File
@@ -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)
}
}
}
+2 -4
View File
@@ -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
View File
@@ -29,6 +29,7 @@ import (
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/log"
"github.com/phishingclub/phishingclub/lure"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/remotebrowser"
"github.com/phishingclub/phishingclub/repository"
@@ -42,6 +43,7 @@ import (
// Campaign is the Campaign service
type Campaign struct {
Common
LureCodeService *LureCode
CampaignRepository *repository.Campaign
CampaignRecipientRepository *repository.CampaignRecipient
RecipientRepository *repository.Recipient
@@ -315,6 +317,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)
+21
View File
@@ -9,6 +9,7 @@ import (
"github.com/oapi-codegen/nullable"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/lure"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/validate"
@@ -85,6 +86,17 @@ func (c *CampaignTemplate) Create(
if !campaignTemplate.URLPath.IsSpecified() || campaignTemplate.URLPath.IsNull() {
campaignTemplate.URLPath = nullable.NewNullableWithValue(*vo.NewURLPathMust(""))
}
// 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)
+98
View File
@@ -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,
),
)
}
+4 -7
View File
@@ -54,13 +54,7 @@ func (t *Template) CreateMail(
rid := campaignRecipient.ID.MustGet()
ridStr := rid.String()
baseURL := "https://" + domainName
url := fmt.Sprintf(
"%s%s?%s=%s",
baseURL,
urlPath,
idKey,
ridStr,
)
url := BuildLureURL(baseURL, urlPath, idKey, campaignRecipient)
// set body
trackingPixelPath := fmt.Sprintf(
"%s/wf/open?upn=%s",
@@ -416,6 +410,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
View File
@@ -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
}
+40 -4
View File
@@ -911,6 +911,24 @@ export class API {
return await getJSON(this.getPath(`/campaign/recipient/${campaignRecipientID}/url`));
},
/**
* Set an operator chosen lure code for a single campaign recipient.
*
* Responds 409 with the owning campaign when the code is already in use.
* Repeat the call with reclaim true to take it over.
*
* @param {string} campaignRecipientID
* @param {string} code
* @param {boolean} [reclaim]
* @return {Promise<ApiResponse>}
*/
setLureCode: async (campaignRecipientID, code, reclaim = false) => {
return await putJSON(this.getPath(`/campaign/recipient/${campaignRecipientID}/lure-code`), {
code,
reclaim
});
},
/**
* Delete all device codes for a campaign so every recipient gets a fresh
* code (and picks up any proxy change) on their next page visit.
@@ -1088,6 +1106,9 @@ export class API {
* @param {string} template.stateIdentifierID
* @param {string} template.urlPath
* @param {string} template.emailID
* @param {string} template.lureURLMode
* @param {string} template.lureCodeAlgo
* @param {number} template.lureCodeLength
* @returns {Promise<ApiResponse>}
*/
create: async ({
@@ -1106,7 +1127,10 @@ export class API {
stateIdentifierID,
afterLandingPageRedirectURL,
emailID: emailID,
urlPath: urlPath
urlPath: urlPath,
lureURLMode,
lureCodeAlgo,
lureCodeLength
}) => {
return await postJSON(this.getPath('/campaign/template'), {
name: name,
@@ -1124,7 +1148,10 @@ export class API {
urlIdentifierID: urlIdentifierID,
stateIdentifierID: stateIdentifierID,
emailID: emailID,
urlPath: urlPath
urlPath: urlPath,
lureURLMode: lureURLMode,
lureCodeAlgo: lureCodeAlgo,
lureCodeLength: lureCodeLength
});
},
@@ -1149,6 +1176,9 @@ export class API {
* @param {string} template.urlIdentifierID
* @param {string} template.stateIdentifierID
* @param {string} template.urlPath
* @param {string} template.lureURLMode
* @param {string} template.lureCodeAlgo
* @param {number} template.lureCodeLength
* @returns {Promise<ApiResponse>}
*/
update: async ({
@@ -1168,7 +1198,10 @@ export class API {
emailID: emailID,
urlIdentifierID: urlIdentifierID,
stateIdentifierID: stateIdentifierID,
urlPath: urlPath
urlPath: urlPath,
lureURLMode,
lureCodeAlgo,
lureCodeLength
}) => {
return await postJSON(this.getPath(`/campaign/template/${id}`), {
name: name,
@@ -1186,7 +1219,10 @@ export class API {
emailID: emailID,
urlIdentifierID: urlIdentifierID,
stateIdentifierID: stateIdentifierID,
urlPath: urlPath
urlPath: urlPath,
lureURLMode: lureURLMode,
lureCodeAlgo: lureCodeAlgo,
lureCodeLength: lureCodeLength
});
},
@@ -66,7 +66,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
+246 -1
View File
@@ -47,6 +47,8 @@
import IconButton from '$lib/components/IconButton.svelte';
import papaparse from 'papaparse';
import FormFooter from '$lib/components/FormFooter.svelte';
import TextField from '$lib/components/TextField.svelte';
import FormError from '$lib/components/FormError.svelte';
import TextFieldSelect from '$lib/components/TextFieldSelect.svelte';
import { resourceContext } from '$lib/store/resourceContext';
import RemoteBrowserStream from '$lib/components/remote-browser/RemoteBrowserStream.svelte';
@@ -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}
+16 -1
View File
@@ -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 \