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 }