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 == "" {
|
if r.SearchRegexp == "" {
|
||||||
continue
|
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)
|
re, err := regexp.Compile(r.SearchRegexp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("replacement %d for header field '%s': %v", i, fieldName, err)
|
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
|
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 {
|
func (ops HeaderOps) validate() error {
|
||||||
for fieldName, replacements := range ops.Replace {
|
for fieldName, replacements := range ops.Replace {
|
||||||
for _, r := range replacements {
|
for _, r := range replacements {
|
||||||
@ -269,7 +291,15 @@ func (ops HeaderOps) ApplyTo(hdr http.Header, repl *caddy.Replacer) {
|
|||||||
for fieldName, vals := range hdr {
|
for fieldName, vals := range hdr {
|
||||||
for i := range vals {
|
for i := range vals {
|
||||||
if r.re != nil {
|
if r.re != nil {
|
||||||
|
// Use precompiled regular expressions
|
||||||
hdr[fieldName][i] = r.re.ReplaceAllString(hdr[fieldName][i], replace)
|
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 {
|
} else {
|
||||||
hdr[fieldName][i] = strings.ReplaceAll(hdr[fieldName][i], search, replace)
|
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 {
|
for i := range vals {
|
||||||
if r.re != nil {
|
if r.re != nil {
|
||||||
hdr[hdrFieldName][i] = r.re.ReplaceAllString(hdr[hdrFieldName][i], replace)
|
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 {
|
} else {
|
||||||
hdr[hdrFieldName][i] = strings.ReplaceAll(hdr[hdrFieldName][i], search, replace)
|
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 {
|
func (f nextHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) error {
|
||||||
return f(w, r)
|
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