From fd078b453c28baf0c5caacf4ac5aadd15be0f8df Mon Sep 17 00:00:00 2001 From: Ronni Skansing Date: Mon, 30 Mar 2026 19:08:06 +0200 Subject: [PATCH] add support for rewrite header engine Signed-off-by: Ronni Skansing --- backend/proxy/proxy.go | 75 ++++- backend/service/proxy.go | 73 ++++- .../proxy/ProxyConfigBuilder.svelte | 282 ++++++++++++------ 3 files changed, 316 insertions(+), 114 deletions(-) diff --git a/backend/proxy/proxy.go b/backend/proxy/proxy.go index 36bc280..17ccc7f 100644 --- a/backend/proxy/proxy.go +++ b/backend/proxy/proxy.go @@ -953,7 +953,11 @@ func (m *ProxyHandler) applyCustomResponseHeaderReplacementsWithVariables(resp * for _, replacement := range hCfg.Rewrite { if replacement.From == "response_header" || replacement.From == "any" { if m.rewriteRuleMatchesRequest(replacement, resp.Request) { - headers = m.applyReplacementWithVariables(headers, replacement, session.ID, varCtx, "") + if replacement.Engine == "header" { + m.applyHeaderRewriteToHeaders(resp.Header, replacement, session.ID, varCtx) + } else { + headers = m.applyReplacementWithVariables(headers, replacement, session.ID, varCtx, "") + } } } } @@ -965,7 +969,11 @@ func (m *ProxyHandler) applyCustomResponseHeaderReplacementsWithVariables(resp * for _, replacement := range proxyConfig.Global.Rewrite { if replacement.From == "response_header" || replacement.From == "any" { if m.rewriteRuleMatchesRequest(replacement, resp.Request) { - headers = m.applyReplacementWithVariables(headers, replacement, session.ID, varCtx, "") + if replacement.Engine == "header" { + m.applyHeaderRewriteToHeaders(resp.Header, replacement, session.ID, varCtx) + } else { + headers = m.applyReplacementWithVariables(headers, replacement, session.ID, varCtx, "") + } } } } @@ -2427,7 +2435,11 @@ func (m *ProxyHandler) applyCustomResponseHeaderReplacementsWithoutSession(resp for _, replacement := range hostConfig.Rewrite { if replacement.From == "response_header" || replacement.From == "any" { if m.rewriteRuleMatchesRequest(replacement, resp.Request) { - headers = m.applyReplacement(headers, replacement, "no-session", "") + if replacement.Engine == "header" { + m.applyHeaderRewriteToHeaders(resp.Header, replacement, "no-session", nil) + } else { + headers = m.applyReplacement(headers, replacement, "no-session", "") + } } } } @@ -2439,7 +2451,11 @@ func (m *ProxyHandler) applyCustomResponseHeaderReplacementsWithoutSession(resp for _, replacement := range proxyConfig.Global.Rewrite { if replacement.From == "response_header" || replacement.From == "any" { if m.rewriteRuleMatchesRequest(replacement, resp.Request) { - headers = m.applyReplacement(headers, replacement, "no-session", "") + if replacement.Engine == "header" { + m.applyHeaderRewriteToHeaders(resp.Header, replacement, "no-session", nil) + } else { + headers = m.applyReplacement(headers, replacement, "no-session", "") + } } } } @@ -2519,6 +2535,46 @@ func (m *ProxyHandler) applyReplacement(body []byte, replacement service.ProxySe return m.applyReplacementWithVariables(body, replacement, sessionID, nil, contentType) } +// applyHeaderRewriteToHeaders applies a header-engine rewrite rule directly to an http.Header map. +// find = header name (canonical form), action = "set" | "add" | "remove", replace = new value. +// This operates directly on the header map so no serialize/parse round-trip is needed. +func (m *ProxyHandler) applyHeaderRewriteToHeaders(headers http.Header, rule service.ProxyServiceReplaceRule, sessionID string, varCtx *VariablesContext) { + if rule.Find == "" { + m.logger.Errorw("header rewrite rule has empty find (header name)", "sessionID", sessionID, "rule", rule.Name) + return + } + + // canonicalize the header name the same way Go's net/http does + canonicalName := http.CanonicalHeaderKey(rule.Find) + + action := rule.Action + if action == "" { + action = "set" + } + + switch action { + case "remove": + headers.Del(canonicalName) + + case "add": + value := rule.Replace + if varCtx != nil && varCtx.Enabled && varCtx.Data != nil { + value = m.interpolateVariables(value, varCtx) + } + headers.Add(canonicalName, value) + + case "set": + value := rule.Replace + if varCtx != nil && varCtx.Enabled && varCtx.Data != nil { + value = m.interpolateVariables(value, varCtx) + } + headers.Set(canonicalName, value) + + default: + m.logger.Errorw("unsupported header rewrite action", "action", action, "sessionID", sessionID, "rule", rule.Name) + } +} + // applyReplacementWithVariables applies replacement with optional template variable interpolation func (m *ProxyHandler) applyReplacementWithVariables(body []byte, replacement service.ProxyServiceReplaceRule, sessionID string, varCtx *VariablesContext, contentType string) []byte { // interpolate variables in the replacement value if enabled @@ -2542,6 +2598,13 @@ func (m *ProxyHandler) applyReplacementWithVariables(body []byte, replacement se return m.applyRegexReplacement(body, interpolatedReplacement, sessionID) case "dom": return m.applyDomReplacement(body, interpolatedReplacement, sessionID, contentType) + case "header": + // header engine operates directly on http.Header — it cannot be used in a body rewrite context. + // callers that handle headers (applyCustomResponseHeaderReplacementsWithVariables, + // applyCustomResponseHeaderReplacementsWithoutSession, applyEarlyRequestHeaderReplacements) + // intercept this engine before reaching here. + m.logger.Errorw("header engine used in a non-header rewrite context — use from: request_header or response_header", "sessionID", sessionID, "rule", replacement.Name) + return body default: m.logger.Errorw("unsupported replacement engine", "engine", engine, "sessionID", sessionID) return body @@ -2950,6 +3013,10 @@ func (m *ProxyHandler) applyEarlyRequestHeaderReplacements(req *http.Request, re if engine == "" { engine = "regex" } + if engine == "header" { + m.applyHeaderRewriteToHeaders(req.Header, replacement, "early-request", nil) + continue + } if engine == "regex" { re, err := regexp.Compile(replacement.Find) if err != nil { diff --git a/backend/service/proxy.go b/backend/service/proxy.go index 2886a0d..3ca751b 100644 --- a/backend/service/proxy.go +++ b/backend/service/proxy.go @@ -309,15 +309,15 @@ func (c *ProxyServiceCaptureRule) GetFindAsString() string { // ProxyServiceReplaceRule represents a replacement rule type ProxyServiceReplaceRule struct { Name string `yaml:"name,omitempty"` - Engine string `yaml:"engine,omitempty"` // "regex" (default) or "dom" - Find string `yaml:"find,omitempty"` // regex pattern (regex engine) or css selector (dom engine) - Replace string `yaml:"replace,omitempty"` // replacement value for both engines - Action string `yaml:"action,omitempty"` // dom action: setText, setHtml, setAttr, removeAttr, addClass, removeClass, remove - Target string `yaml:"target,omitempty"` // target matching: "first", "last", "all" (default), "1,3,5", "2-4" - From string `yaml:"from,omitempty"` - Path string `yaml:"path,omitempty"` // regex pattern to restrict rule to matching request paths - Method string `yaml:"method,omitempty"` // restrict rule to this HTTP method (e.g. GET, POST) - PathRe *regexp.Regexp `yaml:"-"` // compiled regex for path matching + Engine string `yaml:"engine,omitempty"` // "regex" (default), "dom", or "header" + Find string `yaml:"find,omitempty"` // regex pattern (regex engine), css selector (dom engine), or header name (header engine) + Replace string `yaml:"replace,omitempty"` // replacement value (regex/dom), or new header value (header engine set/add actions) + Action string `yaml:"action,omitempty"` // dom: setText, setHtml, setAttr, removeAttr, addClass, removeClass, remove — header: set, add, remove + Target string `yaml:"target,omitempty"` // target matching for dom engine: "first", "last", "all" (default), "1,3,5", "2-4" + From string `yaml:"from,omitempty"` // request_header, request_body, response_header, response_body, any + Path string `yaml:"path,omitempty"` // regex pattern to restrict rule to matching request paths + Method string `yaml:"method,omitempty"` // restrict rule to this HTTP method (e.g. GET, POST) + PathRe *regexp.Regexp `yaml:"-"` // compiled regex for path matching } // ProxyServiceURLRewriteRule represents a URL rewrite rule for anti-detection @@ -1355,6 +1355,17 @@ func (m *Proxy) cleanupRewriteRule(rule *ProxyServiceReplaceRule) { } // dom engine always uses response_body, force it rule.From = "response_body" + } else if rule.Engine == "header" { + // for header engine, remove dom/regex-specific fields + rule.Target = "" + // set default action to 'set' if not specified + if rule.Action == "" { + rule.Action = "set" + } + // set default 'from' to 'response_header' if not specified + if rule.From == "" { + rule.From = "response_header" + } } } @@ -1368,9 +1379,9 @@ func (m *Proxy) validateReplaceRules(replaceRules []ProxyServiceReplaceRule) err } // validate engine type - if engine != "regex" && engine != "dom" { + if engine != "regex" && engine != "dom" && engine != "header" { return validate.WrapErrorWithField( - errors.New("invalid 'engine' value in replace rule, must be 'regex' or 'dom'"), + errors.New("invalid 'engine' value in replace rule, must be one of: regex, dom, header"), "proxyConfig", ) } @@ -1447,6 +1458,46 @@ func (m *Proxy) validateReplaceRules(replaceRules []ProxyServiceReplaceRule) err // dom engine doesn't use 'from' field - it always works on response_body // if 'from' is specified for dom engine, it's ignored (will be forced to response_body) + } else if engine == "header" { + if replace.Find == "" { + return validate.WrapErrorWithField(errors.New("replace rule 'find' (header name) is required for header engine"), "proxyConfig") + } + + // validate header action + validActions := []string{"set", "add", "remove"} + validAction := false + for _, action := range validActions { + if replace.Action == action || replace.Action == "" { + validAction = true + break + } + } + if !validAction { + return validate.WrapErrorWithField( + errors.New("invalid 'action' value for header engine, must be one of: "+strings.Join(validActions, ", ")), + "proxyConfig", + ) + } + + // 'replace' (new value) is required for set and add, not for remove + effectiveAction := replace.Action + if effectiveAction == "" { + effectiveAction = "set" + } + if (effectiveAction == "set" || effectiveAction == "add") && replace.Replace == "" { + return validate.WrapErrorWithField( + errors.New("replace rule 'replace' (new value) is required for header engine action '"+effectiveAction+"'"), + "proxyConfig", + ) + } + + // validate that 'from' is a header context when explicitly set + if replace.From != "" && replace.From != "request_header" && replace.From != "response_header" && replace.From != "any" { + return validate.WrapErrorWithField( + errors.New("header engine 'from' must be one of: request_header, response_header, any"), + "proxyConfig", + ) + } } if replace.From != "" { diff --git a/frontend/src/lib/components/proxy/ProxyConfigBuilder.svelte b/frontend/src/lib/components/proxy/ProxyConfigBuilder.svelte index ff5584e..a2a4840 100644 --- a/frontend/src/lib/components/proxy/ProxyConfigBuilder.svelte +++ b/frontend/src/lib/components/proxy/ProxyConfigBuilder.svelte @@ -525,6 +525,7 @@ find: '', replace: '', from: 'response_body', + action: '', path: '', method: '' } @@ -592,6 +593,7 @@ find: '', replace: '', from: 'response_body', + action: '', path: '', method: '' } @@ -727,8 +729,40 @@ ]; const engines = [ { value: 'regex', label: 'Regex' }, - { value: 'dom', label: 'DOM' } + { value: 'dom', label: 'DOM' }, + { value: 'header', label: 'Header' } ]; + + const headerActions = [ + { value: 'set', label: 'Set' }, + { value: 'add', label: 'Add' }, + { value: 'remove', label: 'Remove' } + ]; + + const rewriteHeaderFromOptions = [ + { value: 'request_header', label: 'Request Header' }, + { value: 'response_header', label: 'Response Header' }, + { value: 'any', label: 'Any' } + ]; + + function getFromOptionsForRewriteEngine(engine) { + if (engine === 'header') return rewriteHeaderFromOptions; + return fromOptions; + } + + function getDefaultFromForRewriteEngine(engine) { + if (engine === 'header') return 'response_header'; + return 'response_body'; + } + + function handleRewriteEngineChange(rule, newEngine) { + rule.engine = newEngine; + rule.from = getDefaultFromForRewriteEngine(newEngine); + if (newEngine !== 'dom') { + rule.action = newEngine === 'header' ? 'set' : ''; + } + configData = configData; + } const domActions = [ { value: 'setText', label: 'Set Text' }, { value: 'setHtml', label: 'Set HTML' }, @@ -1910,6 +1944,7 @@ bind:value={rule.engine} options={engines} size="normal" + on:change={() => handleRewriteEngineChange(rule, rule.engine)} > Engine @@ -1966,31 +2001,45 @@ >Also supports numeric list (1,3,5) or range (2-4) - {:else} + {:else if rule.engine === 'header'}
- From + Action
{/if} +
+ + From + +
{#if rule.engine === 'dom'} Selector (CSS) + {:else if rule.engine === 'header'} + Header Name {:else} Find (regex) {/if} @@ -2005,59 +2054,69 @@ {#if rule.engine === 'dom'} CSS selector to find HTML elements + {:else if rule.engine === 'header'} + Exact header name (e.g. Server, X-Powered-By) {:else} Regex pattern to search for in content {/if} {/if}
-
- - {#if rule.engine === 'dom' && rule.action === 'setAttr'} - Value (attr:value) - {:else if rule.engine === 'dom' && rule.action === 'remove'} - Value (not required) - {:else} - Replace - {/if} - - {#if hasError(`hosts.${expandedHostIndex}.rewrite.${ruleIndex}.replace`)} - {getError( - `hosts.${expandedHostIndex}.rewrite.${ruleIndex}.replace` - )} + - {:else} - - {#if rule.engine === 'dom'} - {#if rule.action === 'setAttr'} - Format: attribute:value (e.g. href:https://example.com) - {:else if rule.action === 'remove'} - Not required for remove action - {:else if rule.action === 'removeAttr'} - Attribute name to remove - {:else if rule.action === 'addClass' || rule.action === 'removeClass'} - CSS class name - {:else} - New content for matched elements - {/if} + {#if rule.engine === 'dom' && rule.action === 'setAttr'} + Value (attr:value) + {:else if rule.engine === 'dom' && rule.action === 'remove'} + Value (not required) + {:else if rule.engine === 'header'} + New Value {:else} - Replacement text (use $1, $2 for capture groups) + Replace {/if} - - {/if} -
+ + {#if hasError(`hosts.${expandedHostIndex}.rewrite.${ruleIndex}.replace`)} + {getError( + `hosts.${expandedHostIndex}.rewrite.${ruleIndex}.replace` + )} + {:else} + + {#if rule.engine === 'dom'} + {#if rule.action === 'setAttr'} + Format: attribute:value (e.g. href:https://example.com) + {:else if rule.action === 'remove'} + Not required for remove action + {:else if rule.action === 'removeAttr'} + Attribute name to remove + {:else if rule.action === 'addClass' || rule.action === 'removeClass'} + CSS class name + {:else} + New content for matched elements + {/if} + {:else if rule.engine === 'header'} + New value for the header + {:else} + Replacement text (use $1, $2 for capture groups) + {/if} + + {/if} + + {/if} {/each} @@ -2724,6 +2783,7 @@ bind:value={rule.engine} options={engines} size="normal" + on:change={() => handleRewriteEngineChange(rule, rule.engine)} > Engine @@ -2778,29 +2838,43 @@ >Also supports numeric list (1,3,5) or range (2-4) - {:else} + {:else if rule.engine === 'header'}
- From + Action
{/if} +
+ + From + +
{#if rule.engine === 'dom'} Selector (CSS) + {:else if rule.engine === 'header'} + Header Name {:else} Find (regex) {/if} @@ -2811,57 +2885,67 @@ {#if rule.engine === 'dom'} CSS selector to find HTML elements + {:else if rule.engine === 'header'} + Exact header name (e.g. Server, X-Powered-By) {:else} Regex pattern to search for in content {/if} {/if}
-
- - {#if rule.engine === 'dom' && rule.action === 'setAttr'} - Value (attr:value) - {:else if rule.engine === 'dom' && rule.action === 'remove'} - Value (not required) - {:else} - Replace - {/if} - - {#if hasError(`global.rewrite.${i}.replace`)} - {getError(`global.rewrite.${i}.replace`)} + - {:else} - - {#if rule.engine === 'dom'} - {#if rule.action === 'setAttr'} - Format: attribute:value (e.g. href:https://example.com) - {:else if rule.action === 'remove'} - Not required for remove action - {:else if rule.action === 'removeAttr'} - Attribute name to remove - {:else if rule.action === 'addClass' || rule.action === 'removeClass'} - CSS class name - {:else} - New content for matched elements - {/if} + {#if rule.engine === 'dom' && rule.action === 'setAttr'} + Value (attr:value) + {:else if rule.engine === 'dom' && rule.action === 'remove'} + Value (not required) + {:else if rule.engine === 'header'} + New Value {:else} - Replacement text (use $1, $2 for capture groups) + Replace {/if} - - {/if} -
+ + {#if hasError(`global.rewrite.${i}.replace`)} + {getError(`global.rewrite.${i}.replace`)} + {:else} + + {#if rule.engine === 'dom'} + {#if rule.action === 'setAttr'} + Format: attribute:value (e.g. href:https://example.com) + {:else if rule.action === 'remove'} + Not required for remove action + {:else if rule.action === 'removeAttr'} + Attribute name to remove + {:else if rule.action === 'addClass' || rule.action === 'removeClass'} + CSS class name + {:else} + New content for matched elements + {/if} + {:else if rule.engine === 'header'} + New value for the header + {:else} + Replacement text (use $1, $2 for capture groups) + {/if} + + {/if} + + {/if} {/each}