@marcel91200 / caddy Mirror · read-only / commits / bbf1dfce
Fork 0

Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS

Mirrored from https://github.com/Slazee/caddy.git

Synced Nov 11, 23:30 UTC

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

Zongze Wu committed Jul 14, 2025 at 13:55 UTC bbf1dfcea283c4b002f2e4c970d2def38fa2046f
3 files changed +203
caddytest/integration/caddyfile_adapt/header_placeholder_search.caddyfiletest new
+64
@@ -0,0 +1,64 @@
1 +:80 {
2 + header Test-Static ":443" "STATIC-WORKS"
3 + header Test-Dynamic ":{http.request.local.port}" "DYNAMIC-WORKS"
4 + header Test-Complex "port-{http.request.local.port}-end" "COMPLEX-{http.request.method}"
5 +}
6 +----------
7 +{
8 + "apps": {
9 + "http": {
10 + "servers": {
11 + "srv0": {
12 + "listen": [
13 + ":80"
14 + ],
15 + "routes": [
16 + {
17 + "handle": [
18 + {
19 + "handler": "headers",
20 + "response": {
21 + "replace": {
22 + "Test-Static": [
23 + {
24 + "replace": "STATIC-WORKS",
25 + "search_regexp": ":443"
26 + }
27 + ]
28 + }
29 + }
30 + },
31 + {
32 + "handler": "headers",
33 + "response": {
34 + "replace": {
35 + "Test-Dynamic": [
36 + {
37 + "replace": "DYNAMIC-WORKS",
38 + "search_regexp": ":{http.request.local.port}"
39 + }
40 + ]
41 + }
42 + }
43 + },
44 + {
45 + "handler": "headers",
46 + "response": {
47 + "replace": {
48 + "Test-Complex": [
49 + {
50 + "replace": "COMPLEX-{http.request.method}",
51 + "search_regexp": "port-{http.request.local.port}-end"
52 + }
53 + ]
54 + }
55 + }
56 + }
57 + ]
58 + }
59 + ]
60 + }
61 + }
62 + }
63 + }
64 +}
\ No newline at end of file
modules/caddyhttp/headers/headers.go
+35
@@ -141,6 +141,14 @@ func (ops *HeaderOps) Provision(_ caddy.Context) error {
141 if r.SearchRegexp == "" {
142 continue
143 }
144 +
145 + // Check if it contains placeholders
146 + if containsPlaceholders(r.SearchRegexp) {
147 + // Contains placeholders, skips precompilation, and recompiles at runtime
148 + continue
149 + }
150 +
151 + // Does not contain placeholders, safe to precompile
152 re, err := regexp.Compile(r.SearchRegexp)
153 if err != nil {
154 return fmt.Errorf("replacement %d for header field '%s': %v", i, fieldName, err)
@@ -151,6 +159,20 @@ func (ops *HeaderOps) Provision(_ caddy.Context) error {
159 return nil
160 }
161
162 +// containsCaddyPlaceholders checks if the string contains Caddy placeholder syntax {key}
163 +func containsPlaceholders(s string) bool {
164 + openIdx := strings.Index(s, "{")
165 + if openIdx == -1 {
166 + return false
167 + }
168 + closeIdx := strings.Index(s[openIdx+1:], "}")
169 + if closeIdx == -1 {
170 + return false
171 + }
172 + // Make sure there is content between the brackets
173 + return closeIdx > 0
174 +}
175 +
176 func (ops HeaderOps) validate() error {
177 for fieldName, replacements := range ops.Replace {
178 for _, r := range replacements {
@@ -269,7 +291,15 @@ func (ops HeaderOps) ApplyTo(hdr http.Header, repl *caddy.Replacer) {
291 for fieldName, vals := range hdr {
292 for i := range vals {
293 if r.re != nil {
294 + // Use precompiled regular expressions
295 hdr[fieldName][i] = r.re.ReplaceAllString(hdr[fieldName][i], replace)
296 + } else if r.SearchRegexp != "" {
297 + // Runtime compilation of regular expressions
298 + searchRegexp := repl.ReplaceKnown(r.SearchRegexp, "")
299 + if re, err := regexp.Compile(searchRegexp); err == nil {
300 + hdr[fieldName][i] = re.ReplaceAllString(hdr[fieldName][i], replace)
301 + }
302 + // If compilation fails, skip this replacement
303 } else {
304 hdr[fieldName][i] = strings.ReplaceAll(hdr[fieldName][i], search, replace)
305 }
@@ -291,6 +321,11 @@ func (ops HeaderOps) ApplyTo(hdr http.Header, repl *caddy.Replacer) {
321 for i := range vals {
322 if r.re != nil {
323 hdr[hdrFieldName][i] = r.re.ReplaceAllString(hdr[hdrFieldName][i], replace)
324 + } else if r.SearchRegexp != "" {
325 + searchRegexp := repl.ReplaceKnown(r.SearchRegexp, "")
326 + if re, err := regexp.Compile(searchRegexp); err == nil {
327 + hdr[hdrFieldName][i] = re.ReplaceAllString(hdr[hdrFieldName][i], replace)
328 + }
329 } else {
330 hdr[hdrFieldName][i] = strings.ReplaceAll(hdr[hdrFieldName][i], search, replace)
331 }
modules/caddyhttp/headers/headers_test.go
+104
@@ -272,3 +272,107 @@ type nextHandler func(http.ResponseWriter, *http.Request) error
272 func (f nextHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) error {
273 return f(w, r)
274 }
275 +
276 +func TestContainsPlaceholders(t *testing.T) {
277 + for i, tc := range []struct {
278 + input string
279 + expected bool
280 + }{
281 + {"static", false},
282 + {"{placeholder}", true},
283 + {"prefix-{placeholder}-suffix", true},
284 + {"{}", false},
285 + {"no-braces", false},
286 + {"{unclosed", false},
287 + {"unopened}", false},
288 + } {
289 + actual := containsPlaceholders(tc.input)
290 + if actual != tc.expected {
291 + t.Errorf("Test %d: containsPlaceholders(%q) = %v, expected %v", i, tc.input, actual, tc.expected)
292 + }
293 + }
294 +}
295 +
296 +func TestHeaderProvisionSkipsPlaceholders(t *testing.T) {
297 + ops := &HeaderOps{
298 + Replace: map[string][]Replacement{
299 + "Static": {
300 + Replacement{SearchRegexp: ":443", Replace: "STATIC"},
301 + },
302 + "Dynamic": {
303 + Replacement{SearchRegexp: ":{http.request.local.port}", Replace: "DYNAMIC"},
304 + },
305 + },
306 + }
307 +
308 + err := ops.Provision(caddy.Context{})
309 + if err != nil {
310 + t.Fatalf("Provision failed: %v", err)
311 + }
312 +
313 + // Static regex should be precompiled
314 + if ops.Replace["Static"][0].re == nil {
315 + t.Error("Expected static regex to be precompiled")
316 + }
317 +
318 + // Dynamic regex with placeholder should not be precompiled
319 + if ops.Replace["Dynamic"][0].re != nil {
320 + t.Error("Expected dynamic regex with placeholder to not be precompiled")
321 + }
322 +}
323 +
324 +func TestPlaceholderInSearchRegexp(t *testing.T) {
325 + handler := Handler{
326 + Response: &RespHeaderOps{
327 + HeaderOps: &HeaderOps{
328 + Replace: map[string][]Replacement{
329 + "Test-Header": {
330 + Replacement{
331 + SearchRegexp: ":{http.request.local.port}",
332 + Replace: "PLACEHOLDER-WORKS",
333 + },
334 + },
335 + },
336 + },
337 + },
338 + }
339 +
340 + // Provision the handler
341 + err := handler.Provision(caddy.Context{})
342 + if err != nil {
343 + t.Fatalf("Provision failed: %v", err)
344 + }
345 +
346 + replacement := handler.Response.HeaderOps.Replace["Test-Header"][0]
347 + t.Logf("After provision - SearchRegexp: %q, re: %v", replacement.SearchRegexp, replacement.re)
348 +
349 + rr := httptest.NewRecorder()
350 +
351 + req := httptest.NewRequest("GET", "http://localhost:443/", nil)
352 + repl := caddy.NewReplacer()
353 + repl.Set("http.request.local.port", "443")
354 +
355 + ctx := context.WithValue(req.Context(), caddy.ReplacerCtxKey, repl)
356 + req = req.WithContext(ctx)
357 +
358 + rr.Header().Set("Test-Header", "prefix:443suffix")
359 + t.Logf("Initial header: %v", rr.Header())
360 +
361 + next := nextHandler(func(w http.ResponseWriter, r *http.Request) error {
362 + w.WriteHeader(200)
363 + return nil
364 + })
365 +
366 + err = handler.ServeHTTP(rr, req, next)
367 + if err != nil {
368 + t.Fatalf("ServeHTTP failed: %v", err)
369 + }
370 +
371 + t.Logf("Final header: %v", rr.Header())
372 +
373 + result := rr.Header().Get("Test-Header")
374 + expected := "prefixPLACEHOLDER-WORKSsuffix"
375 + if result != expected {
376 + t.Errorf("Expected header value %q, got %q", expected, result)
377 + }
378 +}