add support for rewrite header engine

Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
Ronni Skansing
2026-03-30 19:08:06 +02:00
parent af2a3aaedc
commit fd078b453c
3 changed files with 316 additions and 114 deletions
+71 -4
View File
@@ -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 {
+62 -11
View File
@@ -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 != "" {
@@ -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
</TextFieldSelect>
@@ -1966,31 +2001,45 @@
>Also supports numeric list (1,3,5) or range (2-4)</span
>
</div>
{:else}
{:else if rule.engine === 'header'}
<div class="field-wrapper">
<TextFieldSelect
id={`host-${expandedHostIndex}-rewrite-${ruleIndex}-from`}
bind:value={rule.from}
options={fromOptions}
id={`host-${expandedHostIndex}-rewrite-${ruleIndex}-action`}
bind:value={rule.action}
options={headerActions}
size="normal"
>
From
Action
</TextFieldSelect>
</div>
{/if}
<div class="field-wrapper">
<TextFieldSelect
id={`host-${expandedHostIndex}-rewrite-${ruleIndex}-from`}
bind:value={rule.from}
options={getFromOptionsForRewriteEngine(rule.engine)}
size="normal"
>
From
</TextFieldSelect>
</div>
<div class="field-wrapper full">
<TextField
width="full"
bind:value={rule.find}
placeholder={rule.engine === 'dom'
? 'div.logo, #header img'
: 'target\\.com'}
: rule.engine === 'header'
? 'Server'
: 'target\\.com'}
error={hasError(
`hosts.${expandedHostIndex}.rewrite.${ruleIndex}.find`
)}
>
{#if rule.engine === 'dom'}
Selector (CSS)
{:else if rule.engine === 'header'}
Header Name
{:else}
Find (regex)
{/if}
@@ -2005,59 +2054,69 @@
<span class="form-hint">
{#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}
</span>
{/if}
</div>
<div class="field-wrapper full">
<TextareaField
fullWidth
bind:value={rule.replace}
placeholder={rule.engine === 'dom'
? rule.action === 'setAttr'
? 'href:https://example.com'
: rule.action === 'remove'
? ''
: 'New content'
: 'phishing.com'}
height="medium"
>
{#if rule.engine === 'dom' && rule.action === 'setAttr'}
Value (attr:value)
{:else if rule.engine === 'dom' && rule.action === 'remove'}
Value (not required)
{:else}
Replace
{/if}
</TextareaField>
{#if hasError(`hosts.${expandedHostIndex}.rewrite.${ruleIndex}.replace`)}
<span class="field-error"
>{getError(
`hosts.${expandedHostIndex}.rewrite.${ruleIndex}.replace`
)}</span
{#if rule.engine !== 'header' || rule.action !== 'remove'}
<div class="field-wrapper full">
<TextareaField
fullWidth
bind:value={rule.replace}
placeholder={rule.engine === 'dom'
? rule.action === 'setAttr'
? 'href:https://example.com'
: rule.action === 'remove'
? ''
: 'New content'
: rule.engine === 'header'
? 'new-value'
: 'phishing.com'}
height="medium"
>
{:else}
<span class="form-hint">
{#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}
</span>
{/if}
</div>
</TextareaField>
{#if hasError(`hosts.${expandedHostIndex}.rewrite.${ruleIndex}.replace`)}
<span class="field-error"
>{getError(
`hosts.${expandedHostIndex}.rewrite.${ruleIndex}.replace`
)}</span
>
{:else}
<span class="form-hint">
{#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}
</span>
{/if}
</div>
{/if}
</div>
</div>
{/each}
@@ -2724,6 +2783,7 @@
bind:value={rule.engine}
options={engines}
size="normal"
on:change={() => handleRewriteEngineChange(rule, rule.engine)}
>
Engine
</TextFieldSelect>
@@ -2778,29 +2838,43 @@
>Also supports numeric list (1,3,5) or range (2-4)</span
>
</div>
{:else}
{:else if rule.engine === 'header'}
<div class="field-wrapper">
<TextFieldSelect
id={`global-rewrite-${i}-from`}
bind:value={rule.from}
options={fromOptions}
id={`global-rewrite-${i}-action`}
bind:value={rule.action}
options={headerActions}
size="normal"
>
From
Action
</TextFieldSelect>
</div>
{/if}
<div class="field-wrapper">
<TextFieldSelect
id={`global-rewrite-${i}-from`}
bind:value={rule.from}
options={getFromOptionsForRewriteEngine(rule.engine)}
size="normal"
>
From
</TextFieldSelect>
</div>
<div class="field-wrapper full">
<TextField
width="full"
bind:value={rule.find}
placeholder={rule.engine === 'dom'
? 'div.logo, #header img'
: 'target\\.com'}
: rule.engine === 'header'
? 'Server'
: 'target\\.com'}
error={hasError(`global.rewrite.${i}.find`)}
>
{#if rule.engine === 'dom'}
Selector (CSS)
{:else if rule.engine === 'header'}
Header Name
{:else}
Find (regex)
{/if}
@@ -2811,57 +2885,67 @@
<span class="form-hint">
{#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}
</span>
{/if}
</div>
<div class="field-wrapper full">
<TextareaField
fullWidth
bind:value={rule.replace}
placeholder={rule.engine === 'dom'
? rule.action === 'setAttr'
? 'href:https://example.com'
: rule.action === 'remove'
? ''
: 'New content'
: 'phishing.com'}
height="medium"
>
{#if rule.engine === 'dom' && rule.action === 'setAttr'}
Value (attr:value)
{:else if rule.engine === 'dom' && rule.action === 'remove'}
Value (not required)
{:else}
Replace
{/if}
</TextareaField>
{#if hasError(`global.rewrite.${i}.replace`)}
<span class="field-error"
>{getError(`global.rewrite.${i}.replace`)}</span
{#if rule.engine !== 'header' || rule.action !== 'remove'}
<div class="field-wrapper full">
<TextareaField
fullWidth
bind:value={rule.replace}
placeholder={rule.engine === 'dom'
? rule.action === 'setAttr'
? 'href:https://example.com'
: rule.action === 'remove'
? ''
: 'New content'
: rule.engine === 'header'
? 'new-value'
: 'phishing.com'}
height="medium"
>
{:else}
<span class="form-hint">
{#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}
</span>
{/if}
</div>
</TextareaField>
{#if hasError(`global.rewrite.${i}.replace`)}
<span class="field-error"
>{getError(`global.rewrite.${i}.replace`)}</span
>
{:else}
<span class="form-hint">
{#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}
</span>
{/if}
</div>
{/if}
</div>
</div>
{/each}