change access directive and default proxy acccess handling

Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
Ronni Skansing
2025-10-20 21:38:52 +02:00
parent d6a1060009
commit 847a3552b1
3 changed files with 245 additions and 230 deletions
+165 -56
View File
@@ -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)
+45 -54
View File
@@ -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",
)
}
+35 -120
View File
@@ -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.)',