@marcel91200 / caddy Mirror · read-only / commits / 7b34e310
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, 14:45 UTC

core: Check whether @id is unique (#7002)

* caddy.go: Check whether @id is unique(#6991) * Alternate implementation, using Gemini 3.1 --------- Co-authored-by: Francis Lavoie <lavofr@gmail.com>

Salent Olivick committed Mar 4, 2026 at 06:09 UTC 7b34e3107eb6071fea3a3f94a5c913c32a55afd0
2 files changed +136 -3
caddy.go
+20 -3
@@ -227,8 +227,18 @@ func changeConfig(method, path string, input []byte, ifMatchHeader string, force
227 idx := make(map[string]string)
228 err = indexConfigObjects(rawCfg[rawConfigKey], "/"+rawConfigKey, idx)
229 if err != nil {
230 + if len(rawCfgJSON) > 0 {
231 + var oldCfg any
232 + err2 := json.Unmarshal(rawCfgJSON, &oldCfg)
233 + if err2 != nil {
234 + err = fmt.Errorf("%v; additionally, restoring old config: %v", err, err2)
235 + }
236 + rawCfg[rawConfigKey] = oldCfg
237 + } else {
238 + rawCfg[rawConfigKey] = nil
239 + }
240 return APIError{
231 - HTTPStatus: http.StatusInternalServerError,
241 + HTTPStatus: http.StatusBadRequest,
242 Err: fmt.Errorf("indexing config: %v", err),
243 }
244 }
@@ -248,6 +258,8 @@ func changeConfig(method, path string, input []byte, ifMatchHeader string, force
258 err = fmt.Errorf("%v; additionally, restoring old config: %v", err, err2)
259 }
260 rawCfg[rawConfigKey] = oldCfg
261 + } else {
262 + rawCfg[rawConfigKey] = nil
263 }
264
265 return fmt.Errorf("loading new config: %v", err)
@@ -281,14 +293,19 @@ func indexConfigObjects(ptr any, configPath string, index map[string]string) err
293 case map[string]any:
294 for k, v := range val {
295 if k == idKey {
296 + var idStr string
297 switch idVal := v.(type) {
298 case string:
286 - index[idVal] = configPath
299 + idStr = idVal
300 case float64: // all JSON numbers decode as float64
288 - index[fmt.Sprintf("%v", idVal)] = configPath
301 + idStr = fmt.Sprintf("%v", idVal)
302 default:
303 return fmt.Errorf("%s: %s field must be a string or number", configPath, idKey)
304 }
305 + if existingPath, ok := index[idStr]; ok {
306 + return fmt.Errorf("duplicate ID '%s' found at %s and %s", idStr, existingPath, configPath)
307 + }
308 + index[idStr] = configPath
309 continue
310 }
311 // traverse this object property recursively
caddytest/caddytest_test.go
+116
@@ -1,6 +1,7 @@
1 package caddytest
2
3 import (
4 + "bytes"
5 "net/http"
6 "strings"
7 "testing"
@@ -126,3 +127,118 @@ func TestLoadUnorderedJSON(t *testing.T) {
127 }
128 tester.AssertResponseCode(req, 200)
129 }
130 +
131 +func TestCheckID(t *testing.T) {
132 + tester := NewTester(t)
133 + tester.InitServer(`{
134 + "admin": {
135 + "listen": "localhost:2999"
136 + },
137 + "apps": {
138 + "http": {
139 + "http_port": 9080,
140 + "servers": {
141 + "s_server": {
142 + "@id": "s_server",
143 + "listen": [
144 + ":9080"
145 + ],
146 + "routes": [
147 + {
148 + "handle": [
149 + {
150 + "handler": "static_response",
151 + "body": "Hello"
152 + }
153 + ]
154 + }
155 + ]
156 + }
157 + }
158 + }
159 + }
160 + }
161 + `, "json")
162 + headers := []string{"Content-Type:application/json"}
163 + sServer1 := []byte(`{"@id":"s_server","listen":[":9080"],"routes":[{"@id":"route1","handle":[{"handler":"static_response","body":"Hello 2"}]}]}`)
164 +
165 + // PUT to an existing ID should fail with a 409 conflict
166 + tester.AssertPutResponseBody(
167 + "http://localhost:2999/id/s_server",
168 + headers,
169 + bytes.NewBuffer(sServer1),
170 + 409,
171 + `{"error":"[/config/apps/http/servers/s_server] key already exists: s_server"}`+"\n")
172 +
173 + // POST replaces the object fully
174 + tester.AssertPostResponseBody(
175 + "http://localhost:2999/id/s_server",
176 + headers,
177 + bytes.NewBuffer(sServer1),
178 + 200,
179 + "")
180 +
181 + // Verify the server is running the new route
182 + tester.AssertGetResponse(
183 + "http://localhost:9080/",
184 + 200,
185 + "Hello 2")
186 +
187 + // Update the existing route to ensure IDs are handled correctly when replaced
188 + tester.AssertPostResponseBody(
189 + "http://localhost:2999/id/s_server",
190 + headers,
191 + bytes.NewBuffer([]byte(`{"@id":"s_server","listen":[":9080"],"routes":[{"@id":"route1","handle":[{"handler":"static_response","body":"Hello2"}],"match":[{"path":["/route_1/*"]}]}]}`)),
192 + 200,
193 + "")
194 +
195 + sServer2 := []byte(`{"@id":"s_server","listen":[":9080"],"routes":[{"@id":"route1","handle":[{"handler":"static_response","body":"Hello2"}],"match":[{"path":["/route_1/*"]}]}]}`)
196 +
197 + // Identical patch should succeed and return 200 (config is unchanged branch)
198 + tester.AssertPatchResponseBody(
199 + "http://localhost:2999/id/s_server",
200 + headers,
201 + bytes.NewBuffer(sServer2),
202 + 200,
203 + "")
204 +
205 + route2 := []byte(`{"@id":"route2","handle": [{"handler": "static_response","body": "route2"}],"match":[{"path":["/route_2/*"]}]}`)
206 +
207 + // Put a new route2 object before the route1 object due to the path of /id/route1
208 + // Being translated to: /config/apps/http/servers/s_server/routes/0
209 + tester.AssertPutResponseBody(
210 + "http://localhost:2999/id/route1",
211 + headers,
212 + bytes.NewBuffer(route2),
213 + 200,
214 + "")
215 +
216 + // Verify that the whole config looks correct, now containing both route1 and route2
217 + tester.AssertGetResponse(
218 + "http://localhost:2999/config/",
219 + 200,
220 + `{"admin":{"listen":"localhost:2999"},"apps":{"http":{"http_port":9080,"servers":{"s_server":{"@id":"s_server","listen":[":9080"],"routes":[{"@id":"route2","handle":[{"body":"route2","handler":"static_response"}],"match":[{"path":["/route_2/*"]}]},{"@id":"route1","handle":[{"body":"Hello2","handler":"static_response"}],"match":[{"path":["/route_1/*"]}]}]}}}}}`+"\n")
221 +
222 + // Try to add another copy of route2 using POST to test duplicate ID handling
223 + // Since the first route2 ended up at array index 0, and we are appending to the array, the index for the new element would be 2
224 + tester.AssertPostResponseBody(
225 + "http://localhost:2999/id/route2",
226 + headers,
227 + bytes.NewBuffer(route2),
228 + 400,
229 + `{"error":"indexing config: duplicate ID 'route2' found at /config/apps/http/servers/s_server/routes/0 and /config/apps/http/servers/s_server/routes/2"}`+"\n")
230 +
231 + // Use PATCH to modify an existing object successfully
232 + tester.AssertPatchResponseBody(
233 + "http://localhost:2999/id/route1",
234 + headers,
235 + bytes.NewBuffer([]byte(`{"@id":"route1","handle":[{"handler":"static_response","body":"route1"}],"match":[{"path":["/route_1/*"]}]}`)),
236 + 200,
237 + "")
238 +
239 + // Verify the PATCH updated the server state
240 + tester.AssertGetResponse(
241 + "http://localhost:9080/route_1/",
242 + 200,
243 + "route1")
244 +}