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
+}