From 256df3c8ad4ed93c58a7e83470a7f15ef8cb1b94 Mon Sep 17 00:00:00 2001 From: gelsomino Date: Mon, 28 Sep 2026 20:19:51 +0800 Subject: [PATCH] map: reject malformed destination placeholders (#8074) Provision previously only checked for a single opening brace at the start of a destination, so values like {result}suffix, {result, and {result}} passed validation. strings.Trim(dest, "{}") then silently stored a wrong placeholder name (e.g. result}suffix), making the intended value unavailable. Require the destination to be exactly one placeholder: a single opening brace at the start, a single closing brace at the end, and a non-empty name. Fixes #8073 --- caddytest/integration/map_test.go | 45 ++++++++++++++++++++++++++++++ modules/caddyhttp/map/map.go | 8 ++++-- modules/caddyhttp/map/map_test.go | 46 +++++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 3 deletions(-) diff --git a/caddytest/integration/map_test.go b/caddytest/integration/map_test.go index eb3386564..3a10cb3a2 100644 --- a/caddytest/integration/map_test.go +++ b/caddytest/integration/map_test.go @@ -65,6 +65,51 @@ func TestMapRespondWithDefault(t *testing.T) { tester.AssertPostResponseBody("http://localhost:9080/version", []string{}, bytes.NewBuffer([]byte{}), 200, "hello from localhost unknown") } +func TestMapInvalidDestination(t *testing.T) { + // see https://github.com/caddyserver/caddy/issues/8073 + failureCases := []struct { + name string + destination string + expectedError string + }{ + { + name: "trailing text", + destination: "{dest-name}suffix", + expectedError: "destination 0 must be a placeholder and only a placeholder, but got '{dest-name}suffix'", + }, + { + name: "missing closing brace", + destination: "{dest-name", + expectedError: "destination 0 must be a placeholder and only a placeholder, but got '{dest-name'", + }, + { + name: "double closing brace", + destination: "{dest-name}}", + expectedError: "destination 0 must be a placeholder and only a placeholder, but got '{dest-name}}'", + }, + } + + for _, tc := range failureCases { + t.Run(tc.name, func(t *testing.T) { + caddytest.AssertLoadError(t, ` + { + skip_install_trust + admin localhost:2999 + http_port 9080 + https_port 9443 + } + + localhost:9080 { + map {http.request.uri.path} `+tc.destination+` { + /foo mapped + } + respond "{result}" + } + `, "caddyfile", tc.expectedError) + }) + } +} + func TestMapAsJSON(t *testing.T) { // arrange tester := caddytest.NewTester(t) diff --git a/modules/caddyhttp/map/map.go b/modules/caddyhttp/map/map.go index 0c03a1ad5..da68e35cf 100644 --- a/modules/caddyhttp/map/map.go +++ b/modules/caddyhttp/map/map.go @@ -64,10 +64,12 @@ func (Handler) CaddyModule() caddy.ModuleInfo { // Provision sets up h. func (h *Handler) Provision(_ caddy.Context) error { for j, dest := range h.Destinations { - if strings.Count(dest, "{") != 1 || !strings.HasPrefix(dest, "{") { - return fmt.Errorf("destination must be a placeholder and only a placeholder") + if !strings.HasPrefix(dest, "{") || !strings.HasSuffix(dest, "}") || + strings.Count(dest, "{") != 1 || strings.Count(dest, "}") != 1 || + len(dest) < 3 { + return fmt.Errorf("destination %d must be a placeholder and only a placeholder, but got '%s'", j, dest) } - h.Destinations[j] = strings.Trim(dest, "{}") + h.Destinations[j] = dest[1 : len(dest)-1] } for i, m := range h.Mappings { diff --git a/modules/caddyhttp/map/map_test.go b/modules/caddyhttp/map/map_test.go index a9dec4fb5..3839ed39d 100644 --- a/modules/caddyhttp/map/map_test.go +++ b/modules/caddyhttp/map/map_test.go @@ -43,6 +43,52 @@ func TestValidateDuplicateInputs(t *testing.T) { } } +func TestProvisionDestinations(t *testing.T) { + for _, tc := range []struct { + name string + destination string + want string + wantErr bool + }{ + {name: "valid placeholder", destination: "{output}", want: "output"}, + {name: "trailing text", destination: "{output}suffix", wantErr: true}, + {name: "leading text", destination: "prefix{output}", wantErr: true}, + {name: "missing opening brace", destination: "output}", wantErr: true}, + {name: "missing closing brace", destination: "{output", wantErr: true}, + {name: "double closing brace", destination: "{output}}", wantErr: true}, + {name: "double opening brace", destination: "{{output}", wantErr: true}, + {name: "nested braces", destination: "{out{put}}", wantErr: true}, + {name: "empty braces", destination: "{}", wantErr: true}, + {name: "no braces", destination: "output", wantErr: true}, + } { + t.Run(tc.name, func(t *testing.T) { + h := Handler{ + Source: "{http.request.uri.path}", + Destinations: []string{tc.destination}, + Mappings: []Mapping{ + { + Input: "/foo", + Outputs: []any{"FOO"}, + }, + }, + } + err := h.Provision(caddy.Context{}) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error for destination %q, but got none (stored %q)", tc.destination, h.Destinations[0]) + } + return + } + if err != nil { + t.Fatalf("unexpected error for destination %q: %v", tc.destination, err) + } + if h.Destinations[0] != tc.want { + t.Fatalf("expected destination %q, got %q", tc.want, h.Destinations[0]) + } + }) + } +} + func TestHandler(t *testing.T) { for i, tc := range []struct { handler Handler