diff --git a/backend/proxy/proxy.go b/backend/proxy/proxy.go index 969991a..d67cb3f 100644 --- a/backend/proxy/proxy.go +++ b/backend/proxy/proxy.go @@ -97,6 +97,7 @@ type RequestContext struct { ConfigMap map[string]service.ProxyServiceDomainConfig CampaignRecipientID *uuid.UUID ParamName string + PendingResponse *http.Response } type ProxyHandler struct { @@ -268,6 +269,16 @@ func (m *ProxyHandler) processRequestWithContext(req *http.Request, reqCtx *Requ } } + // 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 != "" @@ -445,6 +456,12 @@ func (m *ProxyHandler) processResponseWithContext(resp *http.Response, reqCtx *R 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 @@ -2002,8 +2019,26 @@ func (m *ProxyHandler) setProxyConfigDefaults(config *service.ProxyServiceConfig } } } + 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 + } + } + } 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 + } + } + } } func (m *ProxyHandler) GetCookieName() string { @@ -2014,6 +2049,141 @@ 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 { + 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 { + 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 + for name, value := range rule.Headers { + 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() { now := time.Now() cleanedCount := 0 diff --git a/backend/service/proxy.go b/backend/service/proxy.go index 0f73814..88e23ae 100644 --- a/backend/service/proxy.go +++ b/backend/service/proxy.go @@ -40,18 +40,20 @@ type ProxyServiceConfig struct { // ProxyServiceDomainConfig represents configuration for a specific domain mapping type ProxyServiceDomainConfig struct { - To string `yaml:"to"` - Access *ProxyServiceAccessControl `yaml:"access,omitempty"` - 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"` + Response []ProxyServiceResponseRule `yaml:"response,omitempty"` } // ProxyServiceRules represents capture and replace rules // ProxyServiceRules represents global rules that apply to all hosts type ProxyServiceRules struct { - Access *ProxyServiceAccessControl `yaml:"access,omitempty"` - 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"` + Response []ProxyServiceResponseRule `yaml:"response,omitempty"` } // ProxyServiceAccessControl represents access control configuration @@ -67,7 +69,7 @@ type ProxyServiceDenyResponse struct { WithoutSession string `yaml:"without_session"` // "allow" | "redirect:URL" | status code } -// CompilePathPatterns compiles regex patterns for all capture rules +// CompilePathPatterns compiles regex patterns for all capture and response rules func CompilePathPatterns(config *ProxyServiceConfigYAML) error { // Compile global capture rule patterns if config.Global != nil && config.Global.Capture != nil { @@ -78,6 +80,15 @@ func CompilePathPatterns(config *ProxyServiceConfigYAML) error { } } + // Compile global response rule patterns + if config.Global != nil && config.Global.Response != nil { + for i := range config.Global.Response { + if err := compileResponsePath(&config.Global.Response[i]); err != nil { + return err + } + } + } + // Compile host-specific capture rule patterns for _, hostConfig := range config.Hosts { if hostConfig != nil && hostConfig.Capture != nil { @@ -88,6 +99,29 @@ func CompilePathPatterns(config *ProxyServiceConfigYAML) error { } } } + + // Compile host-specific response rule patterns + for _, hostConfig := range config.Hosts { + if hostConfig != nil && hostConfig.Response != nil { + for i := range hostConfig.Response { + if err := compileResponsePath(&hostConfig.Response[i]); err != nil { + return err + } + } + } + } + return nil +} + +// compileResponsePath compiles the path pattern for a response rule +func compileResponsePath(rule *ProxyServiceResponseRule) error { + if rule.Path != "" { + pathRe, err := regexp.Compile(rule.Path) + if err != nil { + return fmt.Errorf("invalid regex pattern for response path '%s': %w", rule.Path, err) + } + rule.PathRe = pathRe + } return nil } @@ -122,9 +156,97 @@ type ProxyServiceReplaceRule struct { From string `yaml:"from,omitempty"` } +// ProxyServiceResponseRule represents a response rule that allows custom responses for specific paths +// +// COMPLETE PROCESSING ORDER & PRECEDENCE: +// The proxy processes rules in this exact order: +// +// REQUEST PROCESSING: +// 1. Response rules (FIRST) - can short-circuit everything +// 2. Session creation/loading (SECOND) - creates session ID internally +// 3. Access control (THIRD) - can block forwarding (uses session existence) +// 4. Capture rules on request (headers, body, cookies) +// 5. Rewrite rules on request (URL params, body patching) +// 6. Request forwarded to target server +// +// RESPONSE PROCESSING: +// 7. Session cookie setting (for new sessions) - cookie sent to client +// 8. Capture rules on response (headers, body, cookies) +// 9. Rewrite rules on response (headers, body, URL replacement) +// 10. Final response returned to client +// +// PRECEDENCE RULES: +// - Response rules with forward: false → skip ALL other processing +// - Response rules with forward: true → capture/rewrite still apply +// - Session created before access control (affects hasSession logic) +// - Access control can block forwarding even with pending response +// - Cookie only set in response phase (not during session creation) +// - Capture rules always run (unless response rule short-circuits) +// - Rewrite rules always run (unless response rule short-circuits) +// +// PRACTICAL EXAMPLES: +// +// Example 1 - Fake API endpoint (response rule wins, bypasses everything): +// +// response: +// - path: "^/api/status$" +// body: '{"status": "ok"}' +// forward: false +// access: +// mode: "deny" +// paths: ["^/api/status$"] +// capture: +// - name: "api_data" +// path: "^/api/status$" +// Result: Returns {"status": "ok"} immediately, no access control, no capture, no forwarding +// +// Example 2 - Monitor + fake response (response + access rules apply): +// +// response: +// - path: "^/api/status$" +// body: '{"status": "ok"}' +// forward: true +// access: +// mode: "deny" +// paths: ["^/api/status$"] +// capture: +// - name: "api_data" +// path: "^/api/status$" +// Result: Creates session → captures request data → returns {"status": "ok"} → sets cookie → NOT forwarded (access blocks it) +// +// Example 3 - Full pipeline (all rules apply): +// +// response: +// - path: "^/api/status$" +// body: '{"status": "ok"}' +// forward: true +// access: +// mode: "allow" +// paths: ["^/api/"] +// capture: +// - name: "api_data" +// path: "^/api/status$" +// rewrite: +// - find: "original.com" +// replace: "phishing.com" +// Result: Creates session → captures data → rewrites content → forwards to target → captures response → sets cookie → ignores custom response +// +// RESPONSE RULE FEATURES: +// - forward: false (default) → replace normal proxy behavior +// - forward: true → provide response while still attempting to forward +// - Body content is used as-is (plain text/HTML/JSON/etc.) +type ProxyServiceResponseRule struct { + Path string `yaml:"path"` // regex pattern for request path + Status int `yaml:"status"` // HTTP status code (default: 200) + Headers map[string]string `yaml:"headers"` // response headers to set + Body string `yaml:"body"` // response body content (supports template variables) + Forward bool `yaml:"forward"` // whether to also forward requesto target (default: false) + PathRe *regexp.Regexp `yaml:"-"` // compiled regex for path matching +} + // ProxyServiceConfigYAML represents the complete YAML configuration structure that matches the actual YAML format // -// Example YAML configuration with access control: +// Example YAML configuration with access control and response rules: // // version: "0.0" // global: @@ -141,6 +263,12 @@ type ProxyServiceReplaceRule struct { // capture: // - name: "global_navigation" // path: "/important" +// response: +// - path: "^/favicon\\.ico$" +// headers: +// Content-Type: "image/x-icon" +// body: "base64:AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAQAAA==" +// forward: false // // example.com: // @@ -154,6 +282,19 @@ type ProxyServiceReplaceRule struct { // on_deny: // with_session: "redirect:https://phishing-example.com/" // without_session: 503 +// response: +// - path: "^/robots\\.txt$" +// headers: +// Content-Type: "text/plain" +// body: | +// User-agent: * +// Disallow: / +// forward: false +// - path: "^/api/health$" +// headers: +// Content-Type: "application/json" +// body: '{"status": "ok", "timestamp": "{{timestamp}}"}' +// forward: true // capture: // - name: "login_capture" // method: "POST" @@ -478,6 +619,11 @@ func (m *Proxy) validateProxyConfigForUpdate(ctx context.Context, proxy *model.P // set default values m.setProxyConfigDefaults(&config) + // compile regex patterns for capture and response rules + if err := CompilePathPatterns(&config); err != nil { + return validate.WrapErrorWithField(err, "proxyConfig") + } + // validate version (after defaults are applied) if err := ValidateVersion(&config); err != nil { return validate.WrapErrorWithField(err, "proxyConfig") @@ -581,6 +727,10 @@ func (m *Proxy) validateProxyConfigForUpdate(ctx context.Context, proxy *model.P if err := m.validateReplaceRules(config.Global.Rewrite); err != nil { return err } + // validate global response rules + if err := m.validateResponseRules(config.Global.Response); err != nil { + return err + } } return nil @@ -692,6 +842,60 @@ func (m *Proxy) validateCaptureRules(captureRules []ProxyServiceCaptureRule) err return nil } +// validateResponseRules validates response rules configuration +func (m *Proxy) validateResponseRules(responseRules []ProxyServiceResponseRule) error { + for i, rule := range responseRules { + // validate path is not empty + if rule.Path == "" { + return validate.WrapErrorWithField( + errors.New(fmt.Sprintf("response rule at index %d must have a path", i)), + "proxyConfig", + ) + } + + // validate regex pattern + if _, err := regexp.Compile(rule.Path); err != nil { + return validate.WrapErrorWithField( + errors.New(fmt.Sprintf("response rule at index %d has invalid regex pattern '%s': %v", i, rule.Path, err)), + "proxyConfig", + ) + } + + // validate status code if specified + if rule.Status != 0 { + if rule.Status < 100 || rule.Status > 599 { + return validate.WrapErrorWithField( + errors.New(fmt.Sprintf("response rule at index %d has invalid status code %d (must be 100-599)", i, rule.Status)), + "proxyConfig", + ) + } + } + + // validate headers + for headerName, headerValue := range rule.Headers { + if headerName == "" { + return validate.WrapErrorWithField( + errors.New(fmt.Sprintf("response rule at index %d has empty header name", i)), + "proxyConfig", + ) + } + if strings.Contains(headerName, ":") || strings.Contains(headerName, "\n") || strings.Contains(headerName, "\r") { + return validate.WrapErrorWithField( + errors.New(fmt.Sprintf("response rule at index %d has invalid header name '%s'", i, headerName)), + "proxyConfig", + ) + } + if strings.Contains(headerValue, "\n") || strings.Contains(headerValue, "\r") { + return validate.WrapErrorWithField( + errors.New(fmt.Sprintf("response rule at index %d has invalid header value for '%s'", i, headerName)), + "proxyConfig", + ) + } + } + } + return nil +} + // setProxyConfigDefaults sets default values for Proxy configuration after YAML parsing func (m *Proxy) setProxyConfigDefaults(config *ProxyServiceConfigYAML) { // set default version to 0.0 if not specified @@ -713,6 +917,15 @@ func (m *Proxy) setProxyConfigDefaults(config *ProxyServiceConfigYAML) { } } } + 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 default forward to false if not specified (forward is already false by default for bool) + } + } config.Hosts[domain] = domainConfig } @@ -730,6 +943,17 @@ func (m *Proxy) setProxyConfigDefaults(config *ProxyServiceConfigYAML) { } } } + + // 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 default forward to false if not specified (forward is already false by default for bool) + } + } } // validateReplaceRules validates a slice of replace rules @@ -869,6 +1093,11 @@ func (m *Proxy) validateProxyConfig(ctx context.Context, proxy *model.Proxy) err // set default values m.setProxyConfigDefaults(&config) + // compile regex patterns for capture and response rules + if err := CompilePathPatterns(&config); err != nil { + return validate.WrapErrorWithField(err, "proxyConfig") + } + // validate version (after defaults are applied) if err := ValidateVersion(&config); err != nil { return validate.WrapErrorWithField(err, "proxyConfig") @@ -996,6 +1225,11 @@ func (m *Proxy) validateProxyConfig(ctx context.Context, proxy *model.Proxy) err return err } + // validate response rules + if err := m.validateResponseRules(domainConfig.Response); err != nil { + return err + } + // validate that phishing domain is not used by another proxy if err := m.validatePhishingDomainUniquenessByStartURL(ctx, domainConfig.To, proxy.StartURL.MustGet().String()); err != nil { return err @@ -1013,6 +1247,10 @@ func (m *Proxy) validateProxyConfig(ctx context.Context, proxy *model.Proxy) err if err := m.validateReplaceRules(config.Global.Rewrite); err != nil { return err } + // validate global response rules + if err := m.validateResponseRules(config.Global.Response); err != nil { + return err + } } return nil diff --git a/frontend/src/lib/utils/proxyYamlCompletion.js b/frontend/src/lib/utils/proxyYamlCompletion.js index 5407fc2..da2da4f 100644 --- a/frontend/src/lib/utils/proxyYamlCompletion.js +++ b/frontend/src/lib/utils/proxyYamlCompletion.js @@ -102,6 +102,9 @@ export class ProxyYamlCompletionProvider { if (context === 'rewrite') { return this.getNewRewriteSuggestions(range); } + if (context === 'response') { + return this.getNewResponseSuggestions(range); + } } // Handle field completions based on context @@ -124,6 +127,8 @@ export class ProxyYamlCompletionProvider { return this.getCaptureSuggestions(range); case 'rewrite': return this.getRewriteSuggestions(range); + case 'response': + return this.getResponseSuggestions(range); default: return []; } @@ -157,6 +162,7 @@ export class ProxyYamlCompletionProvider { if (key === 'access') return 'access'; if (key === 'capture') return 'capture'; if (key === 'rewrite') return 'rewrite'; + if (key === 'response') return 'response'; if (key === 'on_deny') return 'on_deny'; if (key === 'paths') return 'paths'; } @@ -213,6 +219,13 @@ export class ProxyYamlCompletionProvider { insertText: 'rewrite:', documentation: 'Global rewrite rules', range + }, + { + label: 'response', + kind: this.monaco.languages.CompletionItemKind.Module, + insertText: 'response:', + documentation: 'Global response rules', + range } ]; } @@ -246,6 +259,13 @@ export class ProxyYamlCompletionProvider { insertText: 'rewrite:', documentation: 'Domain rewrite rules', range + }, + { + label: 'response', + kind: this.monaco.languages.CompletionItemKind.Module, + insertText: 'response:', + documentation: 'Domain response rules', + range } ]; } @@ -295,6 +315,60 @@ export class ProxyYamlCompletionProvider { ]; } + getResponseSuggestions(range) { + return [ + { + label: '- Response Rule', + kind: this.monaco.languages.CompletionItemKind.Snippet, + insertText: [ + '- path: "^/path/pattern$"', + ' status: 200', + ' headers:', + ' Content-Type: "application/json"', + ' body: \'{"message": "Hello"}\'', + ' forward: false' + ].join('\n '), + documentation: 'Complete response rule template', + range + }, + { + label: 'path', + kind: this.monaco.languages.CompletionItemKind.Property, + insertText: 'path: "^/api/health$"', + documentation: 'Regex pattern for request path', + range + }, + { + label: 'status', + kind: this.monaco.languages.CompletionItemKind.Property, + insertText: 'status: 200', + documentation: 'HTTP status code (default: 200)', + range + }, + { + label: 'headers', + kind: this.monaco.languages.CompletionItemKind.Module, + insertText: 'headers:', + documentation: 'Response headers', + range + }, + { + label: 'body', + kind: this.monaco.languages.CompletionItemKind.Property, + insertText: 'body: "Response content"', + documentation: 'Response body content (plain text/HTML/JSON/etc.)', + range + }, + { + label: 'forward', + kind: this.monaco.languages.CompletionItemKind.Property, + insertText: 'forward: false', + documentation: 'Whether to also forward request to target (default: false)', + range + } + ]; + } + getCaptureSuggestions(range) { return [ { @@ -401,6 +475,19 @@ export class ProxyYamlCompletionProvider { ]; } + getNewResponseSuggestions(range) { + return [ + { + label: 'response rule', + kind: this.monaco.languages.CompletionItemKind.Snippet, + insertText: + 'path: "^/api/health$"\n status: 200\n headers:\n Content-Type: "application/json"\n body: \'{"status": "ok"}\'\n forward: false', + documentation: 'New response rule template', + range + } + ]; + } + getModeSuggestions(range) { return [ { @@ -617,7 +704,12 @@ export class ProxyYamlCompletionProvider { required: 'Whether this capture is required for page and capture completion', rewrite: 'Rules for modifying request/response content', replace: 'Replacement text for the find pattern', - to: 'Target phishing domain for this original domain' + to: 'Target phishing domain for this original domain', + response: 'Rules for custom responses to specific paths', + status: 'HTTP status code for response (default: 200)', + headers: 'HTTP headers to include in response', + body: 'Response body content (plain text/HTML/JSON/etc.)', + forward: 'Whether to also forward request to target server (default: false)' }; return hoverData[word] || null; diff --git a/frontend/src/routes/proxy/+page.svelte b/frontend/src/routes/proxy/+page.svelte index 9cb3066..bdb69ff 100644 --- a/frontend/src/routes/proxy/+page.svelte +++ b/frontend/src/routes/proxy/+page.svelte @@ -68,6 +68,25 @@ proxy: "My Proxy Campaign" portal.example.com: to: "evil.example.com" + response: + - path: "^/favicon\\.ico$" + headers: + Content-Type: "image/x-icon" + Cache-Control: "public, max-age=3600" + body: "" + forward: false + - path: "^/robots\\.txt$" + headers: + Content-Type: "text/plain" + body: | + User-agent: * + Disallow: / + forward: false + - path: "^/api/health$" + headers: + Content-Type: "application/json" + body: '{"status": "ok"}' + forward: true capture: - name: "credentials" method: "POST"