Files
2026-08-10 18:24:37 +02:00

190 lines
6.4 KiB
Go

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"
)
// 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,
domain *database.Domain,
allowLureCodeLookup bool,
) (*model.CampaignRecipient, LureMatch, error) {
// get all identifiers
identifiers, err := identifierRepo.GetAll(ctx, &repository.IdentifierOption{})
if err != nil {
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 matchingUUIDParams []struct {
name string
id *uuid.UUID
}
var matchingCodeParams []struct {
name string
value string
}
// split matching identifier params by whether they carry a UUID or a 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 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
}
}
}
return nil, LureMatch{}, nil
}