mirror of
https://github.com/caddyserver/caddy.git
synced 2025-07-31 15:08:42 -04:00
headers: Support placeholders in replacement search patterns (#7117)
* fix: resolve http.request placeholders in header directive find operation - Skip regex compilation during provision when placeholders are detected - Compile regex at runtime after placeholder replacement - Preserves performance for static regexes while enabling dynamic placeholders - Fixes #7109 * test: add tests for placeholder detection in header replacements - Test containsPlaceholders function edge cases - Test provision skips compilation for dynamic regexes - Test end-to-end placeholder replacement functionality
This commit is contained in:
parent
aff88d4b26
commit
bbf1dfcea2
@ -0,0 +1,64 @@
|
||||
:80 {
|
||||
header Test-Static ":443" "STATIC-WORKS"
|
||||
header Test-Dynamic ":{http.request.local.port}" "DYNAMIC-WORKS"
|
||||
header Test-Complex "port-{http.request.local.port}-end" "COMPLEX-{http.request.method}"
|
||||
}
|
||||
----------
|
||||
{
|
||||
"apps": {
|
||||
"http": {
|
||||
"servers": {
|
||||
"srv0": {
|
||||
"listen": [
|
||||
":80"
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"handler": "headers",
|
||||
"response": {
|
||||
"replace": {
|
||||
"Test-Static": [
|
||||
{
|
||||
"replace": "STATIC-WORKS",
|
||||
"search_regexp": ":443"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"handler": "headers",
|
||||
"response": {
|
||||
"replace": {
|
||||
"Test-Dynamic": [
|
||||
{
|
||||
"replace": "DYNAMIC-WORKS",
|
||||
"search_regexp": ":{http.request.local.port}"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"handler": "headers",
|
||||
"response": {
|
||||
"replace": {
|
||||
"Test-Complex": [
|
||||
{
|
||||
"replace": "COMPLEX-{http.request.method}",
|
||||
"search_regexp": "port-{http.request.local.port}-end"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -141,6 +141,14 @@ func (ops *HeaderOps) Provision(_ caddy.Context) error {
|
||||
if r.SearchRegexp == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if it contains placeholders
|
||||
if containsPlaceholders(r.SearchRegexp) {
|
||||
// Contains placeholders, skips precompilation, and recompiles at runtime
|
||||
continue
|
||||
}
|
||||
|
||||
// Does not contain placeholders, safe to precompile
|
||||
re, err := regexp.Compile(r.SearchRegexp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("replacement %d for header field '%s': %v", i, fieldName, err)
|
||||
@ -151,6 +159,20 @@ func (ops *HeaderOps) Provision(_ caddy.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// containsCaddyPlaceholders checks if the string contains Caddy placeholder syntax {key}
|
||||
func containsPlaceholders(s string) bool {
|
||||
openIdx := strings.Index(s, "{")
|
||||
if openIdx == -1 {
|
||||
return false
|
||||
}
|
||||
closeIdx := strings.Index(s[openIdx+1:], "}")
|
||||
if closeIdx == -1 {
|
||||
return false
|
||||
}
|
||||
// Make sure there is content between the brackets
|
||||
return closeIdx > 0
|
||||
}
|
||||
|
||||
func (ops HeaderOps) validate() error {
|
||||
for fieldName, replacements := range ops.Replace {
|
||||
for _, r := range replacements {
|
||||
@ -269,7 +291,15 @@ func (ops HeaderOps) ApplyTo(hdr http.Header, repl *caddy.Replacer) {
|
||||
for fieldName, vals := range hdr {
|
||||
for i := range vals {
|
||||
if r.re != nil {
|
||||
// Use precompiled regular expressions
|
||||
hdr[fieldName][i] = r.re.ReplaceAllString(hdr[fieldName][i], replace)
|
||||
} else if r.SearchRegexp != "" {
|
||||
// Runtime compilation of regular expressions
|
||||
searchRegexp := repl.ReplaceKnown(r.SearchRegexp, "")
|
||||
if re, err := regexp.Compile(searchRegexp); err == nil {
|
||||
hdr[fieldName][i] = re.ReplaceAllString(hdr[fieldName][i], replace)
|
||||
}
|
||||
// If compilation fails, skip this replacement
|
||||
} else {
|
||||
hdr[fieldName][i] = strings.ReplaceAll(hdr[fieldName][i], search, replace)
|
||||
}
|
||||
@ -291,6 +321,11 @@ func (ops HeaderOps) ApplyTo(hdr http.Header, repl *caddy.Replacer) {
|
||||
for i := range vals {
|
||||
if r.re != nil {
|
||||
hdr[hdrFieldName][i] = r.re.ReplaceAllString(hdr[hdrFieldName][i], replace)
|
||||
} else if r.SearchRegexp != "" {
|
||||
searchRegexp := repl.ReplaceKnown(r.SearchRegexp, "")
|
||||
if re, err := regexp.Compile(searchRegexp); err == nil {
|
||||
hdr[hdrFieldName][i] = re.ReplaceAllString(hdr[hdrFieldName][i], replace)
|
||||
}
|
||||
} else {
|
||||
hdr[hdrFieldName][i] = strings.ReplaceAll(hdr[hdrFieldName][i], search, replace)
|
||||
}
|
||||
|
@ -272,3 +272,107 @@ type nextHandler func(http.ResponseWriter, *http.Request) error
|
||||
func (f nextHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) error {
|
||||
return f(w, r)
|
||||
}
|
||||
|
||||
func TestContainsPlaceholders(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
input string
|
||||
expected bool
|
||||
}{
|
||||
{"static", false},
|
||||
{"{placeholder}", true},
|
||||
{"prefix-{placeholder}-suffix", true},
|
||||
{"{}", false},
|
||||
{"no-braces", false},
|
||||
{"{unclosed", false},
|
||||
{"unopened}", false},
|
||||
} {
|
||||
actual := containsPlaceholders(tc.input)
|
||||
if actual != tc.expected {
|
||||
t.Errorf("Test %d: containsPlaceholders(%q) = %v, expected %v", i, tc.input, actual, tc.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeaderProvisionSkipsPlaceholders(t *testing.T) {
|
||||
ops := &HeaderOps{
|
||||
Replace: map[string][]Replacement{
|
||||
"Static": {
|
||||
Replacement{SearchRegexp: ":443", Replace: "STATIC"},
|
||||
},
|
||||
"Dynamic": {
|
||||
Replacement{SearchRegexp: ":{http.request.local.port}", Replace: "DYNAMIC"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := ops.Provision(caddy.Context{})
|
||||
if err != nil {
|
||||
t.Fatalf("Provision failed: %v", err)
|
||||
}
|
||||
|
||||
// Static regex should be precompiled
|
||||
if ops.Replace["Static"][0].re == nil {
|
||||
t.Error("Expected static regex to be precompiled")
|
||||
}
|
||||
|
||||
// Dynamic regex with placeholder should not be precompiled
|
||||
if ops.Replace["Dynamic"][0].re != nil {
|
||||
t.Error("Expected dynamic regex with placeholder to not be precompiled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaceholderInSearchRegexp(t *testing.T) {
|
||||
handler := Handler{
|
||||
Response: &RespHeaderOps{
|
||||
HeaderOps: &HeaderOps{
|
||||
Replace: map[string][]Replacement{
|
||||
"Test-Header": {
|
||||
Replacement{
|
||||
SearchRegexp: ":{http.request.local.port}",
|
||||
Replace: "PLACEHOLDER-WORKS",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Provision the handler
|
||||
err := handler.Provision(caddy.Context{})
|
||||
if err != nil {
|
||||
t.Fatalf("Provision failed: %v", err)
|
||||
}
|
||||
|
||||
replacement := handler.Response.HeaderOps.Replace["Test-Header"][0]
|
||||
t.Logf("After provision - SearchRegexp: %q, re: %v", replacement.SearchRegexp, replacement.re)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
req := httptest.NewRequest("GET", "http://localhost:443/", nil)
|
||||
repl := caddy.NewReplacer()
|
||||
repl.Set("http.request.local.port", "443")
|
||||
|
||||
ctx := context.WithValue(req.Context(), caddy.ReplacerCtxKey, repl)
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
rr.Header().Set("Test-Header", "prefix:443suffix")
|
||||
t.Logf("Initial header: %v", rr.Header())
|
||||
|
||||
next := nextHandler(func(w http.ResponseWriter, r *http.Request) error {
|
||||
w.WriteHeader(200)
|
||||
return nil
|
||||
})
|
||||
|
||||
err = handler.ServeHTTP(rr, req, next)
|
||||
if err != nil {
|
||||
t.Fatalf("ServeHTTP failed: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Final header: %v", rr.Header())
|
||||
|
||||
result := rr.Header().Get("Test-Header")
|
||||
expected := "prefixPLACEHOLDER-WORKSsuffix"
|
||||
if result != expected {
|
||||
t.Errorf("Expected header value %q, got %q", expected, result)
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user