From fda18732f896538f357aa4da4ba445a26a6da899 Mon Sep 17 00:00:00 2001 From: Ronni Skansing Date: Thu, 16 Apr 2026 21:08:43 +0200 Subject: [PATCH] apply rewrite_urls rules when redirecting to proxy pages Signed-off-by: Ronni Skansing --- backend/app/server.go | 106 ++++++++++++++++++++++++++++++----------- backend/proxy/proxy.go | 90 ++++++++++++++++++++++++++++++---- 2 files changed, 160 insertions(+), 36 deletions(-) diff --git a/backend/app/server.go b/backend/app/server.go index cd70ca8..0b800d3 100644 --- a/backend/app/server.go +++ b/backend/app/server.go @@ -15,6 +15,7 @@ import ( "net/url" "os" "path/filepath" + "regexp" "runtime/debug" "strings" textTmpl "text/template" @@ -1240,10 +1241,9 @@ func (s *Server) checkAndServePhishingPage( return true, fmt.Errorf("Proxy page has no configuration: %s", err) } - // extract the phishing domain from Proxy configuration - var rawConfig map[string]interface{} - err = yaml.Unmarshal([]byte(proxyConfig.String()), &rawConfig) - if err != nil { + // parse proxy config as typed struct so rewrite_urls rules are accessible + var parsedConfig service.ProxyServiceConfigYAML + if err := yaml.Unmarshal([]byte(proxyConfig.String()), &parsedConfig); err != nil { return true, fmt.Errorf("invalid Proxy configuration YAML: %s", err) } @@ -1256,19 +1256,13 @@ func (s *Server) checkAndServePhishingPage( // find the phishing domain mapping for the start URL domain phishingDomain := "" - for originalHost, domainData := range rawConfig { - if originalHost == "proxy" || originalHost == "global" { + for originalHost, hostCfg := range parsedConfig.Hosts { + if hostCfg == nil { continue } if originalHost == startDomain { - if domainMap, ok := domainData.(map[string]interface{}); ok { - if to, exists := domainMap["to"]; exists { - if toStr, ok := to.(string); ok { - phishingDomain = toStr - break - } - } - } + phishingDomain = hostCfg.To + break } } @@ -1485,30 +1479,88 @@ func (s *Server) checkAndServePhishingPage( "phishingDomainType", phishingDomainRecord.Type, ) - // build the redirect URL to the phishing domain with campaign recipient ID + // build the redirect URL to the phishing domain with campaign recipient ID. + // apply rewrite_urls rules so the victim sees the friendly path, not the real + // start url path. query params from the start url are carried through but + // remapped according to rule.Query so they match what checkAndApplyURLRewrite + // expects when the request arrives at the proxy. urlParam := cTemplate.URLIdentifier.Name.MustGet() - // construct the redirect URL properly + // collect rewrite_urls rules for the start domain — host-specific first, then global + var rewriteRules []service.ProxyServiceURLRewriteRule + if hostCfg, ok := parsedConfig.Hosts[startDomain]; ok && hostCfg != nil { + rewriteRules = append(rewriteRules, hostCfg.RewriteURLs...) + } + if parsedConfig.Global != nil { + rewriteRules = append(rewriteRules, parsedConfig.Global.RewriteURLs...) + } + + // start from the start url path and query, then apply the first matching rule + redirectPath := parsedStartURL.Path + startQuery := url.Values{} + if parsedStartURL.RawQuery != "" { + startQuery, _ = url.ParseQuery(parsedStartURL.RawQuery) + } + + for _, rule := range rewriteRules { + pattern := strings.TrimPrefix(rule.Find, "^") + pattern = strings.TrimSuffix(pattern, "$") + re, reErr := regexp.Compile("^" + pattern) + if reErr != nil { + continue + } + if !re.MatchString(parsedStartURL.Path) { + continue + } + + // rewrite path to the friendly replacement + redirectPath = rule.Replace + + // remap query param keys: find → replace + if len(rule.Query) > 0 { + remapped := url.Values{} + for k, v := range startQuery { + remapped[k] = v + } + for _, qRule := range rule.Query { + if vals, exists := remapped[qRule.Find]; exists { + delete(remapped, qRule.Find) + remapped[qRule.Replace] = vals + } + } + startQuery = remapped + } + + // apply filter: keep only the listed query param names + if len(rule.Filter) > 0 { + allowed := make(map[string]struct{}, len(rule.Filter)) + for _, name := range rule.Filter { + allowed[name] = struct{}{} + } + filtered := url.Values{} + for k, v := range startQuery { + if _, ok := allowed[k]; ok { + filtered[k] = v + } + } + startQuery = filtered + } + + break + } + u := &url.URL{ Scheme: "https", Host: phishingDomain, - Path: parsedStartURL.Path, + Path: redirectPath, } - q := u.Query() + // merge start url query params (rewritten) with campaign params + q := startQuery q.Set(urlParam, campaignRecipientID.String()) if encryptedParam != "" { q.Set(stateParamKey, encryptedParam) } - // preserve any existing query params from start URL - if parsedStartURL.RawQuery != "" { - startQuery, _ := url.ParseQuery(parsedStartURL.RawQuery) - for key, values := range startQuery { - for _, value := range values { - q.Add(key, value) - } - } - } u.RawQuery = q.Encode() s.logger.Debugw("built proxy redirect URL", diff --git a/backend/proxy/proxy.go b/backend/proxy/proxy.go index db70813..4b28b9f 100644 --- a/backend/proxy/proxy.go +++ b/backend/proxy/proxy.go @@ -4506,17 +4506,89 @@ 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) + // build the post-evasion redirect url using the start url as the source of truth for + // path and query params — the incoming request only has the campaign id param, not the + // real start url params (client_id, redirect_uri etc.). apply rewrite_urls rules so the + // victim sees the friendly path and remapped param names, not the real ones. + var startPath string + var startQuery url.Values + if reqCtx.ProxyEntry != nil { + if startURLVal, err := reqCtx.ProxyEntry.StartURL.Get(); err == nil { + if parsedStart, err := url.Parse(startURLVal.String()); err == nil { + startPath = parsedStart.Path + startQuery, _ = url.ParseQuery(parsedStart.RawQuery) + } } - if len(query) > 0 { - originalURL += "?" + query.Encode() + } + if startPath == "" { + startPath = req.URL.Path + } + if startQuery == nil { + startQuery = url.Values{} + } + + // collect rewrite_urls rules — host-specific first, then global + var rewriteRules []service.ProxyServiceURLRewriteRule + if hostCfg, ok := reqCtx.ProxyConfig.Hosts[reqCtx.TargetDomain]; ok && hostCfg != nil { + rewriteRules = append(rewriteRules, hostCfg.RewriteURLs...) + } + if reqCtx.ProxyConfig.Global != nil { + rewriteRules = append(rewriteRules, reqCtx.ProxyConfig.Global.RewriteURLs...) + } + + redirectPath := startPath + redirectQuery := startQuery + + for _, rule := range rewriteRules { + pattern := strings.TrimPrefix(rule.Find, "^") + pattern = strings.TrimSuffix(pattern, "$") + re, reErr := regexp.Compile("^" + pattern) + if reErr != nil { + continue } + if !re.MatchString(startPath) { + continue + } + + // rewrite path to the friendly replacement + redirectPath = rule.Replace + + // remap query param keys: find → replace + if len(rule.Query) > 0 { + remapped := url.Values{} + for k, v := range startQuery { + remapped[k] = v + } + for _, qRule := range rule.Query { + if vals, exists := remapped[qRule.Find]; exists { + delete(remapped, qRule.Find) + remapped[qRule.Replace] = vals + } + } + redirectQuery = remapped + } + + // apply filter: keep only the listed query param names + if len(rule.Filter) > 0 { + allowed := make(map[string]struct{}, len(rule.Filter)) + for _, name := range rule.Filter { + allowed[name] = struct{}{} + } + filtered := url.Values{} + for k, v := range redirectQuery { + if _, ok := allowed[k]; ok { + filtered[k] = v + } + } + redirectQuery = filtered + } + + break + } + + originalURL := redirectPath + if len(redirectQuery) > 0 { + originalURL += "?" + redirectQuery.Encode() } // this is initial request with campaign recipient ID and no state parameter, serve evasion page