mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-07-30 15:38:49 +02:00
163 lines
5.2 KiB
Go
163 lines
5.2 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/phishingclub/phishingclub/lure"
|
|
"github.com/phishingclub/phishingclub/model"
|
|
"github.com/phishingclub/phishingclub/repository"
|
|
)
|
|
|
|
// LastPathSegment returns the final segment of a request path, with any
|
|
// trailing slash trimmed. A mail client or message preview commonly appends
|
|
// one, so a link that arrives as /account/4H7K9QM2XR3T/ must resolve the same
|
|
// as one without.
|
|
//
|
|
// It refuses paths carrying traversal or an encoded slash rather than trying to
|
|
// clean them, because a segment that needs cleaning is never a lure code.
|
|
func LastPathSegment(path string) (string, bool) {
|
|
if path == "" || strings.Contains(path, "..") {
|
|
return "", false
|
|
}
|
|
if strings.Contains(path, "%2f") || strings.Contains(path, "%2F") {
|
|
return "", false
|
|
}
|
|
trimmed := strings.TrimSuffix(path, "/")
|
|
if trimmed == "" {
|
|
return "", false
|
|
}
|
|
index := strings.LastIndex(trimmed, "/")
|
|
if index < 0 {
|
|
return trimmed, true
|
|
}
|
|
segment := trimmed[index+1:]
|
|
if segment == "" {
|
|
return "", false
|
|
}
|
|
return segment, true
|
|
}
|
|
|
|
// TrimLastPathSegment removes the final segment of a path, which is how a
|
|
// consumed lure code is taken back out of the URL before the request is
|
|
// forwarded onward.
|
|
func TrimLastPathSegment(path string) string {
|
|
trimmed := strings.TrimSuffix(path, "/")
|
|
index := strings.LastIndex(trimmed, "/")
|
|
if index <= 0 {
|
|
return "/"
|
|
}
|
|
return trimmed[:index]
|
|
}
|
|
|
|
// LureMatch records how a request identified its recipient, so a caller can
|
|
// undo whatever carried the identifier before passing the request on.
|
|
type LureMatch struct {
|
|
// ParamName is the query parameter that matched. Empty when the identifier
|
|
// came from the path.
|
|
ParamName string
|
|
// PathSegment is the trailing path segment that matched. Empty when the
|
|
// identifier came from the query.
|
|
PathSegment string
|
|
}
|
|
|
|
// GetCampaignRecipientFromURLParams resolves the campaign recipient a request
|
|
// belongs to.
|
|
//
|
|
// Three forms are accepted, in this order:
|
|
//
|
|
// - a query parameter holding a campaign recipient UUID, the original form
|
|
// - a query parameter holding a lure code, so an operator set code such as
|
|
// special-42 works as ?id=special-42 too
|
|
// - the last path segment as a lure code, https://example.com/acc/4H7K9QM2XR3T
|
|
//
|
|
// All three are accepted regardless of the campaign's configured mode. The mode
|
|
// only decides which form is emitted, so changing it never breaks a link that
|
|
// has already been delivered.
|
|
//
|
|
// The query forms take priority over the path. An identifier in the query
|
|
// states which recipient a request belongs to, while a path segment only looks
|
|
// like a code: a query mode template whose URL path ends in a code shaped
|
|
// segment, say /careers, collides with any custom code holding that string.
|
|
//
|
|
// allowPathLookup lets a caller turn off the path form. The proxy switches it
|
|
// off once a session cookie exists, because on a proxy domain every path
|
|
// mirrors the target site and the recipient is already known by then.
|
|
func GetCampaignRecipientFromURLParams(
|
|
ctx context.Context,
|
|
req *http.Request,
|
|
identifierRepo *repository.Identifier,
|
|
campaignRecipientRepo *repository.CampaignRecipient,
|
|
allowPathLookup bool,
|
|
) (*model.CampaignRecipient, LureMatch, error) {
|
|
// get all identifiers
|
|
identifiers, err := identifierRepo.GetAll(ctx, &repository.IdentifierOption{})
|
|
if err != nil {
|
|
return nil, LureMatch{}, err
|
|
}
|
|
|
|
query := req.URL.Query()
|
|
var matchingUUIDParams []struct {
|
|
name string
|
|
id *uuid.UUID
|
|
}
|
|
var matchingCodeParams []struct {
|
|
name string
|
|
value string
|
|
}
|
|
|
|
// collect all query parameters that match identifier names, splitting them
|
|
// by whether they carry a campaign recipient UUID or a lure code
|
|
for _, identifier := range identifiers.Rows {
|
|
name := identifier.Name.MustGet()
|
|
if !query.Has(name) {
|
|
continue
|
|
}
|
|
value := query.Get(name)
|
|
if id, err := uuid.Parse(value); err == nil {
|
|
matchingUUIDParams = append(matchingUUIDParams, struct {
|
|
name string
|
|
id *uuid.UUID
|
|
}{name: name, id: &id})
|
|
continue
|
|
}
|
|
if lure.IsCandidate(value) {
|
|
matchingCodeParams = append(matchingCodeParams, struct {
|
|
name string
|
|
value string
|
|
}{name: name, value: value})
|
|
}
|
|
}
|
|
|
|
// check each matching parameter to find a valid campaign recipient
|
|
for _, param := range matchingUUIDParams {
|
|
campaignRecipient, err := campaignRecipientRepo.GetByCampaignRecipientID(ctx, param.id)
|
|
if err == nil && campaignRecipient != nil {
|
|
return campaignRecipient, LureMatch{ParamName: param.name}, nil
|
|
}
|
|
}
|
|
for _, param := range matchingCodeParams {
|
|
campaignRecipient, err := campaignRecipientRepo.GetByLureCode(ctx, param.value)
|
|
if err == nil && campaignRecipient != nil {
|
|
return campaignRecipient, LureMatch{ParamName: param.name}, nil
|
|
}
|
|
}
|
|
|
|
// nothing in the query identified a recipient, so fall back to reading the
|
|
// last path segment as a code
|
|
if allowPathLookup {
|
|
if segment, ok := LastPathSegment(req.URL.Path); ok {
|
|
if lure.IsCandidate(segment) {
|
|
campaignRecipient, err := campaignRecipientRepo.GetByLureCode(ctx, segment)
|
|
if err == nil && campaignRecipient != nil {
|
|
return campaignRecipient, LureMatch{PathSegment: segment}, nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil, LureMatch{}, nil
|
|
}
|