Files
phishingclub/backend/server/shared.go
T
2026-07-27 17:57:53 +02:00

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
}