diff --git a/backend/proxy/proxy.go b/backend/proxy/proxy.go index 2d30cca..987dbce 100644 --- a/backend/proxy/proxy.go +++ b/backend/proxy/proxy.go @@ -116,6 +116,7 @@ type ProxyHandler struct { CampaignService *service.Campaign TemplateService *service.Template cookieName string + ipAllowList sync.Map // map[string]int64 (ip+domain -> expiry timestamp) } func NewProxyHandler( @@ -258,16 +259,14 @@ func (m *ProxyHandler) initializeRequestContext(ctx context.Context, req *http.R // check for campaign recipient id campaignRecipientID, paramName := m.getCampaignRecipientIDFromURLParams(req) - reqCtx := &RequestContext{ + return &RequestContext{ PhishDomain: req.Host, TargetDomain: targetDomain, Domain: domain, ProxyConfig: proxyConfig, CampaignRecipientID: campaignRecipientID, ParamName: paramName, - } - - return reqCtx, nil + }, nil } func (m *ProxyHandler) processRequestWithContext(req *http.Request, reqCtx *RequestContext) (*http.Request, *http.Response) { @@ -314,7 +313,7 @@ func (m *ProxyHandler) processRequestWithContext(req *http.Request, reqCtx *Requ // check access control before proceeding hasSession := reqCtx.SessionID != "" - if allowed, denyAction := m.evaluatePathAccess(req.URL.Path, reqCtx, hasSession); !allowed { + if allowed, denyAction := m.evaluatePathAccess(req.URL.Path, reqCtx, hasSession, req); !allowed { return req, m.createDenyResponse(req, reqCtx, denyAction, hasSession) } @@ -363,6 +362,9 @@ func (m *ProxyHandler) resolveSessionContext(req *http.Request, reqCtx *RequestC // register page visit event for MITM landing m.registerPageVisitEvent(req, newSession) + + // allow list IP for tunnel mode access + m.allowListIP(req, reqCtx.Domain.Name) } else { // load existing session sessionVal, exists := m.sessions.Load(reqCtx.SessionID) @@ -2394,6 +2396,32 @@ func (m *ProxyHandler) CleanupExpiredSessions() { return true }) + if cleanedCount > 0 { + m.logger.Debugw("cleaned up expired sessions", "count", cleanedCount) + } + + // cleanup expired IP allow listed entries + ipCleanedCount := 0 + currentTime := now.Unix() + + m.ipAllowList.Range(func(key, value interface{}) bool { + expiry, ok := value.(int64) + if !ok { + m.ipAllowList.Delete(key) + ipCleanedCount++ + return true + } + + if currentTime >= expiry { + m.ipAllowList.Delete(key) + ipCleanedCount++ + } + return true + }) + + if ipCleanedCount > 0 { + m.logger.Debugw("cleaned up expired IP allow listed entries", "count", ipCleanedCount) + } } func (m *ProxyHandler) getTargetDomainForPhishingDomain(phishingDomain string) (string, error) { @@ -2482,94 +2510,169 @@ func (m *ProxyHandler) writeResponse(w http.ResponseWriter, resp *http.Response) } // evaluatePathAccess checks if a path is allowed based on access control rules -func (m *ProxyHandler) evaluatePathAccess(path string, reqCtx *RequestContext, hasSession bool) (bool, string) { - // check for nil request context - if reqCtx == nil { - m.logger.Errorw("request context is nil in evaluatePathAccess") - return true, "" // default allow to prevent panic - } - +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 && domainConfig.Access != nil { - allowed, action := m.checkAccessRules(path, domainConfig.Access, hasSession) - // domain rule found - return its decision (allow or deny) - return allowed, action + 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 } } } - // no domain rule found - check global rules + // 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 { - - if allowed, action := m.checkAccessRules(path, reqCtx.ProxyConfig.Global.Access, hasSession); !allowed { - - return false, action - } + allowed, action := m.checkAccessRules(path, reqCtx.ProxyConfig.Global.Access, hasSession, reqCtx, req) + return allowed, action } - // default allow if no rules match - return true, "" + // 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) (bool, string) { +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 } - matches := m.matchesAnyAccessPath(path, accessControl.Paths) - - var action string - if hasSession { - action = accessControl.OnDeny.WithSession - } else { - action = accessControl.OnDeny.WithoutSession - } - - // default actions if not specified + action := accessControl.OnDeny if action == "" { - action = "404" + action = "404" // default action } switch accessControl.Mode { - case "allow": - if matches { - return true, "" // path matches allow list + 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, "" } - // path doesn't match allow list - check if we should allow anyway - if action == "allow" { - return true, "" // override deny with allow + + // check if IP is allowlisted for this domain (from previous lure access) + if reqCtx != nil && reqCtx.Domain != nil && req != nil && m.isIPAllowlistedForRequest(req, reqCtx.Domain.Name) { + return true, "" } - return false, action // deny with specified action - case "deny": - if !matches { - return true, "" // path doesn't match deny list - } - // path matches deny list - check if we should allow anyway - if action == "allow" { - return true, "" // override deny with allow - } - return false, action // deny with specified action + + // no lure request and IP not allow listed - deny access + return false, action default: return true, "" // safe default } } -// matchesAnyAccessPath checks if a path matches any of the provided regex patterns -func (m *ProxyHandler) matchesAnyAccessPath(path string, patterns []string) bool { - for _, pattern := range patterns { - if matched, err := regexp.MatchString(pattern, path); err == nil && matched { +// 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 allow listed for this domain (from previous lure access) + if reqCtx != nil && reqCtx.Domain != nil && req != nil && m.isIPAllowlistedForRequest(req, reqCtx.Domain.Name) { + return true, "" + } + + // no lure request and IP not allow listed - deny with default action + return false, "404" +} + +// allowListIP adds an IP address to the allow list for private mode access +func (m *ProxyHandler) allowListIP(req *http.Request, domain string) { + clientIP := m.getClientIP(req) + if clientIP == "" { + return + } + + key := clientIP + "-" + domain + // allowlisted for 10 minutes + expiry := time.Now().Add(10 * time.Minute).Unix() + + m.ipAllowList.Store(key, expiry) + + m.logger.Debugw("IP allow listed for private mode", + "ip", clientIP, + "domain", domain, + "expires_at", time.Unix(expiry, 0).Format(time.RFC3339), + ) +} + +// isIPAllowlistedForRequest checks if an IP is allowlisted for a specific domain +func (m *ProxyHandler) isIPAllowlistedForRequest(req *http.Request, domain string) bool { + clientIP := m.getClientIP(req) + if clientIP == "" { + return false + } + + key := clientIP + "-" + domain + + if expiryVal, exists := m.ipAllowList.Load(key); exists { + expiry := expiryVal.(int64) + if time.Now().Unix() < expiry { + m.logger.Debugw("IP found in allow list for private mode", + "ip", clientIP, + "domain", domain, + "expires_at", time.Unix(expiry, 0).Format(time.RFC3339), + ) return true } + // expired, remove it + m.ipAllowList.Delete(key) + m.logger.Debugw("IP allow listed entry expired and removed", + "ip", clientIP, + "domain", domain, + ) } + return false } +// 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 @@ -2586,6 +2689,12 @@ func (m *ProxyHandler) createDenyResponse(req *http.Request, reqCtx *RequestCont "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) diff --git a/backend/service/proxy.go b/backend/service/proxy.go index 42cd4c8..1f43e5d 100644 --- a/backend/service/proxy.go +++ b/backend/service/proxy.go @@ -58,16 +58,13 @@ type ProxyServiceRules struct { // ProxyServiceAccessControl represents access control configuration type ProxyServiceAccessControl struct { - Mode string `yaml:"mode"` // "allow" | "deny" - Paths []string `yaml:"paths"` - OnDeny ProxyServiceDenyResponse `yaml:"on_deny"` + Mode string `yaml:"mode"` // "public" | "private" + OnDeny string `yaml:"on_deny,omitempty"` // "404" | "redirect:URL" | status code (only used for private mode) } -// ProxyServiceDenyResponse represents response configuration when access is denied -type ProxyServiceDenyResponse struct { - WithSession string `yaml:"with_session"` // "allow" | "redirect:URL" | status code - WithoutSession string `yaml:"without_session"` // "allow" | "redirect:URL" | status code -} +// Access control modes: +// - "public": Allow all traffic (traditional proxy mode) - on_deny is ignored +// - "private": Strict IP-based mode like evilginx2 - whitelist IP after lure access, deny all others (DEFAULT) // CompilePathPatterns compiles regex patterns for all capture and response rules func CompilePathPatterns(config *ProxyServiceConfigYAML) error { @@ -254,15 +251,7 @@ type ProxyServiceResponseRule struct { // version: "0.0" // global: // -// access: -// mode: "deny" -// paths: -// - "^/admin/" -// - "^/wp-admin/" -// - "^/\\.git/" -// on_deny: -// with_session: 403 -// without_session: 404 +// # No access section = private mode by default (secure by default) // capture: // - name: "global_navigation" // path: "/important" @@ -276,15 +265,14 @@ type ProxyServiceResponseRule struct { // example.com: // // to: "phishing-example.com" -// access: -// mode: "allow" -// paths: -// - "^/login" -// - "^/api/public/" -// - "^/assets/" -// on_deny: -// with_session: "redirect:https://phishing-example.com/" -// without_session: 503 +// # No access section = private mode by default (secure by default) +// # To override with public mode or custom deny action: +// # access: +// # mode: "public" # Traditional proxy mode +// # Or: +// # access: +// # mode: "private" +// # on_deny: "https://example.com" # Clean redirect syntax // response: // - path: "^/robots\\.txt$" // headers: @@ -1112,46 +1100,38 @@ func (m *Proxy) validateReplaceRules(replaceRules []ProxyServiceReplaceRule) err // validateAccessControl validates access control configuration func (m *Proxy) validateAccessControl(accessControl *ProxyServiceAccessControl) error { if accessControl == nil { - return nil // access control is optional + return nil // access control will be set to defaults + } + + // set default mode if empty + if accessControl.Mode == "" { + accessControl.Mode = "private" } // validate mode - if accessControl.Mode != "allow" && accessControl.Mode != "deny" { + if accessControl.Mode != "public" && accessControl.Mode != "private" { return validate.WrapErrorWithField( - errors.New("access control mode must be either 'allow' or 'deny'"), + errors.New("access control mode must be either 'public' or 'private' - private mode uses IP whitelisting like evilginx2"), "proxyConfig", ) } - // validate paths are valid regex patterns - for i, path := range accessControl.Paths { - if path == "" { - return validate.WrapErrorWithField( - errors.New(fmt.Sprintf("access control path %d cannot be empty", i)), - "proxyConfig", - ) + // validate deny action (only required for private mode) + if accessControl.Mode == "private" { + // set default deny action if empty + if accessControl.OnDeny == "" { + accessControl.OnDeny = "404" } - if _, err := regexp.Compile(path); err != nil { - return validate.WrapErrorWithField( - errors.New(fmt.Sprintf("invalid regex pattern in access control path '%s': %s", path, err.Error())), - "proxyConfig", - ) + if err := m.validateDenyAction(accessControl.OnDeny); err != nil { + return err } } - // validate deny response actions - if err := m.validateDenyAction(accessControl.OnDeny.WithSession, true); err != nil { - return err - } - if err := m.validateDenyAction(accessControl.OnDeny.WithoutSession, false); err != nil { - return err - } - return nil } // validateDenyAction validates a deny action string -func (m *Proxy) validateDenyAction(action string, withSession bool) error { +func (m *Proxy) validateDenyAction(action string) error { if action == "" { return nil // action is optional, will use default } @@ -1161,16 +1141,27 @@ func (m *Proxy) validateDenyAction(action string, withSession bool) error { return nil } - // check for redirect action + // check for redirect action (auto-detect URLs or old redirect: syntax) + if strings.HasPrefix(action, "http://") || strings.HasPrefix(action, "https://") { + if len(action) < 10 { // minimum valid URL length + return validate.WrapErrorWithField( + errors.New("redirect URL is too short, must be a valid URL like 'https://example.com'"), + "proxyConfig", + ) + } + return nil + } + + // check for old redirect: syntax (backwards compatibility) if strings.HasPrefix(action, "redirect:") { url := strings.TrimPrefix(action, "redirect:") if url == "" { return validate.WrapErrorWithField( - errors.New("redirect action must include URL: 'redirect:https://example.com'"), + errors.New("redirect action must include URL: 'redirect:https://example.com' or just 'https://example.com'"), "proxyConfig", ) } - // basic URL validation + // basic URL validation for old syntax if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { return validate.WrapErrorWithField( errors.New("redirect URL must start with http:// or https://"), @@ -1192,7 +1183,7 @@ func (m *Proxy) validateDenyAction(action string, withSession bool) error { } return validate.WrapErrorWithField( - errors.New("invalid deny action: must be 'allow', 'redirect:URL', or a status code"), + errors.New("deny action must be a valid HTTP status code (e.g., '404') or redirect URL (e.g., 'https://example.com')"), "proxyConfig", ) } diff --git a/frontend/src/lib/utils/proxyYamlCompletion.js b/frontend/src/lib/utils/proxyYamlCompletion.js index 65e063b..4d7962f 100644 --- a/frontend/src/lib/utils/proxyYamlCompletion.js +++ b/frontend/src/lib/utils/proxyYamlCompletion.js @@ -96,16 +96,13 @@ export class ProxyYamlCompletionProvider { if (linePrefix.match(/\s*method:\s*$/)) { return this.getMethodSuggestions(range); } - if (linePrefix.match(/\s*(with_session|without_session):\s*$/)) { - return this.getActionSuggestions(range); + if (linePrefix.match(/\s*on_deny:\s*$/)) { + return this.getOnDenySuggestions(range); } // Handle array items if (linePrefix.match(/^\s*-\s*$/)) { const context = this.findParentSection(linesAbove, currentIndent); - if (context === 'paths') { - return this.getPathPatternSuggestions(range); - } if (context === 'capture') { return this.getNewCaptureSuggestions(range); } @@ -131,8 +128,6 @@ export class ProxyYamlCompletionProvider { return this.getDomainSuggestions(range); case 'access': return this.getAccessSuggestions(range); - case 'on_deny': - return this.getOnDenySuggestions(range); case 'capture': return this.getCaptureSuggestions(range); case 'rewrite': @@ -253,7 +248,7 @@ export class ProxyYamlCompletionProvider { label: 'access', kind: this.monaco.languages.CompletionItemKind.Module, insertText: 'access:', - documentation: 'Domain access control', + documentation: 'Domain access control (optional - defaults to secure private mode)', range }, { @@ -285,22 +280,17 @@ export class ProxyYamlCompletionProvider { { label: 'mode', kind: this.monaco.languages.CompletionItemKind.Property, - insertText: 'mode: "allow"', - documentation: 'Access control mode: allow or deny', - range - }, - { - label: 'paths', - kind: this.monaco.languages.CompletionItemKind.Property, - insertText: 'paths:', - documentation: 'Array of path patterns', + insertText: 'mode: "private"', + documentation: + 'Access control mode: public (allow all) or private (IP whitelist after lure). Default: private', range }, { label: 'on_deny', - kind: this.monaco.languages.CompletionItemKind.Module, - insertText: 'on_deny:', - documentation: 'Response when access denied', + kind: this.monaco.languages.CompletionItemKind.Property, + insertText: 'on_deny: "404"', + documentation: + 'Response for blocked requests in private mode (e.g., "404", "https://example.com")', range } ]; @@ -309,17 +299,24 @@ export class ProxyYamlCompletionProvider { getOnDenySuggestions(range) { return [ { - label: 'with_session', - kind: this.monaco.languages.CompletionItemKind.Property, - insertText: 'with_session: 403', - documentation: 'Response for users with sessions', + label: '"404"', + kind: this.monaco.languages.CompletionItemKind.Value, + insertText: '"404"', + documentation: 'Return 404 Not Found status', range }, { - label: 'without_session', - kind: this.monaco.languages.CompletionItemKind.Property, - insertText: 'without_session: 404', - documentation: 'Response for users without sessions', + label: '"403"', + kind: this.monaco.languages.CompletionItemKind.Value, + insertText: '"403"', + documentation: 'Return 403 Forbidden status', + range + }, + { + label: '"https://example.com"', + kind: this.monaco.languages.CompletionItemKind.Value, + insertText: '"https://example.com"', + documentation: 'Redirect to specified URL (auto-detected)', range } ]; @@ -573,17 +570,17 @@ export class ProxyYamlCompletionProvider { getModeSuggestions(range) { return [ { - label: '"allow"', + label: '"private"', kind: this.monaco.languages.CompletionItemKind.Value, - insertText: '"allow"', - documentation: 'Allowlist mode - only specified paths allowed', + insertText: '"private"', + documentation: 'Private mode - IP-based whitelist after lure access (DEFAULT, secure)', range }, { - label: '"deny"', + label: '"public"', kind: this.monaco.languages.CompletionItemKind.Value, - insertText: '"deny"', - documentation: 'Denylist mode - specified paths blocked', + insertText: '"public"', + documentation: 'Public mode - allow all traffic (traditional proxy behavior)', range } ]; @@ -782,86 +779,6 @@ export class ProxyYamlCompletionProvider { ]; } - getActionSuggestions(range) { - return [ - { - label: '"allow"', - kind: this.monaco.languages.CompletionItemKind.Value, - insertText: '"allow"', - documentation: 'Allow access (override deny)', - range - }, - { - label: '"redirect:https://example.com"', - kind: this.monaco.languages.CompletionItemKind.Value, - insertText: '"redirect:https://example.com"', - documentation: 'Redirect to URL', - range - }, - { - label: '404', - kind: this.monaco.languages.CompletionItemKind.Value, - insertText: '404', - documentation: 'Return 404 Not Found', - range - }, - { - label: '403', - kind: this.monaco.languages.CompletionItemKind.Value, - insertText: '403', - documentation: 'Return 403 Forbidden', - range - }, - { - label: '503', - kind: this.monaco.languages.CompletionItemKind.Value, - insertText: '503', - documentation: 'Return 503 Service Unavailable', - range - } - ]; - } - - getPathPatternSuggestions(range) { - return [ - { - label: '"^/admin/"', - kind: this.monaco.languages.CompletionItemKind.Value, - insertText: '"^/admin/"', - documentation: 'Admin panel paths', - range - }, - { - label: '"^/login"', - kind: this.monaco.languages.CompletionItemKind.Value, - insertText: '"^/login"', - documentation: 'Login page', - range - }, - { - label: '"^/api/"', - kind: this.monaco.languages.CompletionItemKind.Value, - insertText: '"^/api/"', - documentation: 'API endpoints', - range - }, - { - label: '"^/assets/"', - kind: this.monaco.languages.CompletionItemKind.Value, - insertText: '"^/assets/"', - documentation: 'Static assets', - range - }, - { - label: '"^/\\.git/"', - kind: this.monaco.languages.CompletionItemKind.Value, - insertText: '"^/\\.git/"', - documentation: 'Git repository', - range - } - ]; - } - provideHover(model, position) { const word = model.getWordAtPosition(position); if (!word) return null; @@ -884,12 +801,10 @@ export class ProxyYamlCompletionProvider { const hoverData = { version: 'Configuration version. Currently supports "0.0"', global: 'Rules that apply to all domain mappings', - access: 'Access control configuration - restricts which paths are accessible', - mode: 'Access control mode: "allow" (allowlist) or "deny" (denylist)', - paths: 'Array of regex patterns for path matching', - on_deny: 'Response configuration when access is denied', - with_session: 'Response for users with active proxy sessions (request with mitm cookie)', - without_session: 'Response for requests without sessions', + access: 'Access control configuration (optional - defaults to private mode for security)', + mode: 'Access control mode: "public" (allow all traffic) or "private" (IP whitelist after lure access, DEFAULT)', + on_deny: + 'Response when access is denied in private mode (e.g., "404", "https://example.com")', capture: 'Rules for capturing data from requests/responses', name: 'Unique identifier for the rule', method: 'HTTP method to match (GET, POST, PUT, DELETE, etc.)',