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