add proxy response directive

Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
Ronni Skansing
2025-10-16 00:26:48 +02:00
parent ee406aeecf
commit 2cd428c416
4 changed files with 529 additions and 10 deletions
+170
View File
@@ -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
+247 -9
View File
@@ -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
+93 -1
View File
@@ -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;
+19
View File
@@ -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"