Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS
Mirrored from https://github.com/Slazee/caddy.git
Synced Nov 10, 22:30 UTC
| 1 | // Copyright 2015 Matthew Holt and The Caddy Authors |
| 2 | // |
| 3 | // Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 | // you may not use this file except in compliance with the License. |
| 5 | // You may obtain a copy of the License at |
| 6 | // |
| 7 | // http://www.apache.org/licenses/LICENSE-2.0 |
| 8 | // |
| 9 | // Unless required by applicable law or agreed to in writing, software |
| 10 | // distributed under the License is distributed on an "AS IS" BASIS, |
| 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 12 | // See the License for the specific language governing permissions and |
| 13 | // limitations under the License. |
| 14 | |
| 15 | package caddy |
| 16 | |
| 17 | import ( |
| 18 | "bytes" |
| 19 | "context" |
| 20 | "crypto" |
| 21 | "crypto/tls" |
| 22 | "crypto/x509" |
| 23 | "encoding/json" |
| 24 | "errors" |
| 25 | "fmt" |
| 26 | "maps" |
| 27 | "net/http" |
| 28 | "net/http/httptest" |
| 29 | "os" |
| 30 | "reflect" |
| 31 | "sync" |
| 32 | "testing" |
| 33 | "time" |
| 34 | |
| 35 | "github.com/caddyserver/certmagic" |
| 36 | "github.com/prometheus/client_golang/prometheus" |
| 37 | dto "github.com/prometheus/client_model/go" |
| 38 | "go.uber.org/zap" |
| 39 | "go.uber.org/zap/zaptest/observer" |
| 40 | ) |
| 41 | |
| 42 | var testCfg = []byte(`{ |
| 43 | "apps": { |
| 44 | "http": { |
| 45 | "servers": { |
| 46 | "myserver": { |
| 47 | "listen": ["tcp/localhost:8080-8084"], |
| 48 | "read_timeout": "30s" |
| 49 | }, |
| 50 | "yourserver": { |
| 51 | "listen": ["127.0.0.1:5000"], |
| 52 | "read_header_timeout": "15s" |
| 53 | } |
| 54 | } |
| 55 | } |
| 56 | } |
| 57 | } |
| 58 | `) |
| 59 | |
| 60 | type testAdminPublicKey string |
| 61 | |
| 62 | func (k testAdminPublicKey) Equal(x crypto.PublicKey) bool { |
| 63 | other, ok := x.(testAdminPublicKey) |
| 64 | return ok && k == other |
| 65 | } |
| 66 | |
| 67 | func TestUnsyncedConfigAccess(t *testing.T) { |
| 68 | // each test is performed in sequence, so |
| 69 | // each change builds on the previous ones; |
| 70 | // the config is not reset between tests |
| 71 | for i, tc := range []struct { |
| 72 | method string |
| 73 | path string // rawConfigKey will be prepended |
| 74 | payload string |
| 75 | expect string // JSON representation of what the whole config is expected to be after the request |
| 76 | shouldErr bool |
| 77 | }{ |
| 78 | { |
| 79 | method: "POST", |
| 80 | path: "", |
| 81 | payload: `{"foo": "bar", "list": ["a", "b", "c"]}`, // starting value |
| 82 | expect: `{"foo": "bar", "list": ["a", "b", "c"]}`, |
| 83 | }, |
| 84 | { |
| 85 | method: "POST", |
| 86 | path: "/foo", |
| 87 | payload: `"jet"`, |
| 88 | expect: `{"foo": "jet", "list": ["a", "b", "c"]}`, |
| 89 | }, |
| 90 | { |
| 91 | method: "POST", |
| 92 | path: "/bar", |
| 93 | payload: `{"aa": "bb", "qq": "zz"}`, |
| 94 | expect: `{"foo": "jet", "bar": {"aa": "bb", "qq": "zz"}, "list": ["a", "b", "c"]}`, |
| 95 | }, |
| 96 | { |
| 97 | method: "DELETE", |
| 98 | path: "/bar/qq", |
| 99 | expect: `{"foo": "jet", "bar": {"aa": "bb"}, "list": ["a", "b", "c"]}`, |
| 100 | }, |
| 101 | { |
| 102 | method: "DELETE", |
| 103 | path: "/bar/qq", |
| 104 | expect: `{"foo": "jet", "bar": {"aa": "bb"}, "list": ["a", "b", "c"]}`, |
| 105 | shouldErr: true, |
| 106 | }, |
| 107 | { |
| 108 | method: "POST", |
| 109 | path: "/list", |
| 110 | payload: `"e"`, |
| 111 | expect: `{"foo": "jet", "bar": {"aa": "bb"}, "list": ["a", "b", "c", "e"]}`, |
| 112 | }, |
| 113 | { |
| 114 | method: "PUT", |
| 115 | path: "/list/3", |
| 116 | payload: `"d"`, |
| 117 | expect: `{"foo": "jet", "bar": {"aa": "bb"}, "list": ["a", "b", "c", "d", "e"]}`, |
| 118 | }, |
| 119 | { |
| 120 | method: "DELETE", |
| 121 | path: "/list/3", |
| 122 | expect: `{"foo": "jet", "bar": {"aa": "bb"}, "list": ["a", "b", "c", "e"]}`, |
| 123 | }, |
| 124 | { |
| 125 | method: "PATCH", |
| 126 | path: "/list/3", |
| 127 | payload: `"d"`, |
| 128 | expect: `{"foo": "jet", "bar": {"aa": "bb"}, "list": ["a", "b", "c", "d"]}`, |
| 129 | }, |
| 130 | { |
| 131 | method: "POST", |
| 132 | path: "/list/...", |
| 133 | payload: `["e", "f", "g"]`, |
| 134 | expect: `{"foo": "jet", "bar": {"aa": "bb"}, "list": ["a", "b", "c", "d", "e", "f", "g"]}`, |
| 135 | }, |
| 136 | } { |
| 137 | err := unsyncedConfigAccess(tc.method, rawConfigKey+tc.path, []byte(tc.payload), nil) |
| 138 | |
| 139 | if tc.shouldErr && err == nil { |
| 140 | t.Fatalf("Test %d: Expected error return value, but got: %v", i, err) |
| 141 | } |
| 142 | if !tc.shouldErr && err != nil { |
| 143 | t.Fatalf("Test %d: Should not have had error return value, but got: %v", i, err) |
| 144 | } |
| 145 | |
| 146 | // decode the expected config so we can do a convenient DeepEqual |
| 147 | var expectedDecoded any |
| 148 | err = json.Unmarshal([]byte(tc.expect), &expectedDecoded) |
| 149 | if err != nil { |
| 150 | t.Fatalf("Test %d: Unmarshaling expected config: %v", i, err) |
| 151 | } |
| 152 | |
| 153 | // make sure the resulting config is as we expect it |
| 154 | if !reflect.DeepEqual(rawCfg[rawConfigKey], expectedDecoded) { |
| 155 | t.Fatalf("Test %d:\nExpected:\n\t%#v\nActual:\n\t%#v", |
| 156 | i, expectedDecoded, rawCfg[rawConfigKey]) |
| 157 | } |
| 158 | } |
| 159 | } |
| 160 | |
| 161 | // TestLoadConcurrent exercises Load under concurrent conditions |
| 162 | // and is most useful under test with `-race` enabled. |
| 163 | func TestLoadConcurrent(t *testing.T) { |
| 164 | var wg sync.WaitGroup |
| 165 | |
| 166 | for i := 0; i < 100; i++ { |
| 167 | wg.Go(func() { |
| 168 | _ = Load(testCfg, true) |
| 169 | }) |
| 170 | } |
| 171 | wg.Wait() |
| 172 | } |
| 173 | |
| 174 | type fooModule struct { |
| 175 | IntField int |
| 176 | StrField string |
| 177 | } |
| 178 | |
| 179 | func (fooModule) CaddyModule() ModuleInfo { |
| 180 | return ModuleInfo{ |
| 181 | ID: "foo", |
| 182 | New: func() Module { return new(fooModule) }, |
| 183 | } |
| 184 | } |
| 185 | func (fooModule) Start() error { return nil } |
| 186 | func (fooModule) Stop() error { return nil } |
| 187 | |
| 188 | func TestETags(t *testing.T) { |
| 189 | RegisterModule(fooModule{}) |
| 190 | |
| 191 | if err := Load([]byte(`{"admin": {"listen": "localhost:2999"}, "apps": {"foo": {"strField": "abc", "intField": 0}}}`), true); err != nil { |
| 192 | t.Fatalf("loading: %s", err) |
| 193 | } |
| 194 | |
| 195 | const key = "/" + rawConfigKey + "/apps/foo" |
| 196 | |
| 197 | // try update the config with the wrong etag |
| 198 | err := changeConfig(http.MethodPost, key, []byte(`{"strField": "abc", "intField": 1}}`), fmt.Sprintf(`"/%s not_an_etag"`, rawConfigKey), false) |
| 199 | if apiErr, ok := err.(APIError); !ok || apiErr.HTTPStatus != http.StatusPreconditionFailed { |
| 200 | t.Fatalf("expected precondition failed; got %v", err) |
| 201 | } |
| 202 | |
| 203 | // get the etag |
| 204 | hash := etagHasher() |
| 205 | if err := readConfig(key, hash); err != nil { |
| 206 | t.Fatalf("reading: %s", err) |
| 207 | } |
| 208 | |
| 209 | // do the same update with the correct key |
| 210 | err = changeConfig(http.MethodPost, key, []byte(`{"strField": "abc", "intField": 1}`), makeEtag(key, hash), false) |
| 211 | if err != nil { |
| 212 | t.Fatalf("expected update to work; got %v", err) |
| 213 | } |
| 214 | |
| 215 | // now try another update. The hash should no longer match and we should get precondition failed |
| 216 | err = changeConfig(http.MethodPost, key, []byte(`{"strField": "abc", "intField": 2}`), makeEtag(key, hash), false) |
| 217 | if apiErr, ok := err.(APIError); !ok || apiErr.HTTPStatus != http.StatusPreconditionFailed { |
| 218 | t.Fatalf("expected precondition failed; got %v", err) |
| 219 | } |
| 220 | } |
| 221 | |
| 222 | func BenchmarkLoad(b *testing.B) { |
| 223 | for b.Loop() { |
| 224 | Load(testCfg, true) |
| 225 | } |
| 226 | } |
| 227 | |
| 228 | func TestAdminHandlerErrorHandling(t *testing.T) { |
| 229 | initAdminMetrics() |
| 230 | |
| 231 | handler := adminHandler{ |
| 232 | mux: http.NewServeMux(), |
| 233 | } |
| 234 | |
| 235 | handler.mux.Handle("/error", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 236 | err := fmt.Errorf("test error") |
| 237 | handler.handleError(w, r, err) |
| 238 | })) |
| 239 | |
| 240 | req := httptest.NewRequest(http.MethodGet, "/error", nil) |
| 241 | rr := httptest.NewRecorder() |
| 242 | |
| 243 | handler.ServeHTTP(rr, req) |
| 244 | |
| 245 | if rr.Code == http.StatusOK { |
| 246 | t.Error("expected error response, got success") |
| 247 | } |
| 248 | |
| 249 | var apiErr APIError |
| 250 | if err := json.NewDecoder(rr.Body).Decode(&apiErr); err != nil { |
| 251 | t.Fatalf("decoding response: %v", err) |
| 252 | } |
| 253 | if apiErr.Message != "test error" { |
| 254 | t.Errorf("expected error message 'test error', got '%s'", apiErr.Message) |
| 255 | } |
| 256 | } |
| 257 | |
| 258 | func TestAdminHandlerServeHTTPRedactsSensitiveHeadersInLogs(t *testing.T) { |
| 259 | core, logs := observer.New(zap.InfoLevel) |
| 260 | |
| 261 | defaultLoggerMu.Lock() |
| 262 | origLogger := defaultLogger.logger |
| 263 | defaultLogger.logger = zap.New(core) |
| 264 | defaultLoggerMu.Unlock() |
| 265 | t.Cleanup(func() { |
| 266 | defaultLoggerMu.Lock() |
| 267 | defaultLogger.logger = origLogger |
| 268 | defaultLoggerMu.Unlock() |
| 269 | }) |
| 270 | |
| 271 | handler := adminHandler{ |
| 272 | mux: http.NewServeMux(), |
| 273 | } |
| 274 | req := httptest.NewRequest(http.MethodGet, "/redaction-test", nil) |
| 275 | req.Header.Set("Authorization", "Bearer secret") |
| 276 | req.Header.Set("Cookie", "session=secret") |
| 277 | req.Header.Set("X-Test", "ok") |
| 278 | rr := httptest.NewRecorder() |
| 279 | |
| 280 | Log().Info("unrelated test log") |
| 281 | handler.ServeHTTP(rr, req) |
| 282 | |
| 283 | entries := logs.FilterMessage("received request").FilterField(zap.String("uri", req.RequestURI)).All() |
| 284 | if len(entries) != 1 { |
| 285 | t.Fatalf("request log entries = %d, want 1", len(entries)) |
| 286 | } |
| 287 | |
| 288 | ctx := entries[0].ContextMap() |
| 289 | headers, ok := ctx["headers"].(map[string]any) |
| 290 | if !ok { |
| 291 | t.Fatalf("expected headers field in log context, got %T", ctx["headers"]) |
| 292 | } |
| 293 | |
| 294 | if got := headers["Authorization"]; !reflect.DeepEqual(got, []any{"REDACTED"}) { |
| 295 | t.Fatalf("expected redacted Authorization header, got %#v", got) |
| 296 | } |
| 297 | if got := headers["Cookie"]; !reflect.DeepEqual(got, []any{"REDACTED"}) { |
| 298 | t.Fatalf("expected redacted Cookie header, got %#v", got) |
| 299 | } |
| 300 | if got := headers["X-Test"]; !reflect.DeepEqual(got, []any{"ok"}) { |
| 301 | t.Fatalf("expected X-Test header to remain visible, got %#v", got) |
| 302 | } |
| 303 | } |
| 304 | |
| 305 | func initAdminMetrics() { |
| 306 | if adminMetrics.requestErrors != nil { |
| 307 | prometheus.Unregister(adminMetrics.requestErrors) |
| 308 | } |
| 309 | if adminMetrics.requestCount != nil { |
| 310 | prometheus.Unregister(adminMetrics.requestCount) |
| 311 | } |
| 312 | |
| 313 | adminMetrics.requestErrors = prometheus.NewCounterVec(prometheus.CounterOpts{ |
| 314 | Namespace: "caddy", |
| 315 | Subsystem: "admin_http", |
| 316 | Name: "request_errors_total", |
| 317 | Help: "Number of errors that occurred handling admin endpoint requests", |
| 318 | }, []string{"handler", "path", "method"}) |
| 319 | |
| 320 | adminMetrics.requestCount = prometheus.NewCounterVec(prometheus.CounterOpts{ |
| 321 | Namespace: "caddy", |
| 322 | Subsystem: "admin_http", |
| 323 | Name: "requests_total", |
| 324 | Help: "Count of requests to the admin endpoint", |
| 325 | }, []string{"handler", "path", "code", "method"}) // Added code and method labels |
| 326 | |
| 327 | prometheus.MustRegister(adminMetrics.requestErrors) |
| 328 | prometheus.MustRegister(adminMetrics.requestCount) |
| 329 | } |
| 330 | |
| 331 | func TestAdminHandlerBuiltinRouteErrors(t *testing.T) { |
| 332 | initAdminMetrics() |
| 333 | |
| 334 | cfg := &Config{ |
| 335 | Admin: &AdminConfig{ |
| 336 | Listen: "localhost:2019", |
| 337 | }, |
| 338 | } |
| 339 | |
| 340 | // Build the admin handler directly (no listener active) |
| 341 | addr, err := ParseNetworkAddress("localhost:2019") |
| 342 | if err != nil { |
| 343 | t.Fatalf("Failed to parse address: %v", err) |
| 344 | } |
| 345 | handler, err := cfg.Admin.newAdminHandler(addr, false, Context{}) |
| 346 | if err != nil { |
| 347 | t.Fatalf("Failed to create admin handler: %v", err) |
| 348 | } |
| 349 | |
| 350 | tests := []struct { |
| 351 | name string |
| 352 | path string |
| 353 | method string |
| 354 | expectedStatus int |
| 355 | }{ |
| 356 | { |
| 357 | name: "stop endpoint wrong method", |
| 358 | path: "/stop", |
| 359 | method: http.MethodGet, |
| 360 | expectedStatus: http.StatusMethodNotAllowed, |
| 361 | }, |
| 362 | { |
| 363 | name: "config endpoint wrong content-type", |
| 364 | path: "/config/", |
| 365 | method: http.MethodPost, |
| 366 | expectedStatus: http.StatusBadRequest, |
| 367 | }, |
| 368 | { |
| 369 | name: "config ID missing ID", |
| 370 | path: "/id/", |
| 371 | method: http.MethodGet, |
| 372 | expectedStatus: http.StatusBadRequest, |
| 373 | }, |
| 374 | } |
| 375 | |
| 376 | for _, test := range tests { |
| 377 | t.Run(test.name, func(t *testing.T) { |
| 378 | req := httptest.NewRequest(test.method, fmt.Sprintf("http://localhost:2019%s", test.path), nil) |
| 379 | rr := httptest.NewRecorder() |
| 380 | |
| 381 | handler.ServeHTTP(rr, req) |
| 382 | |
| 383 | if rr.Code != test.expectedStatus { |
| 384 | t.Errorf("expected status %d but got %d", test.expectedStatus, rr.Code) |
| 385 | } |
| 386 | |
| 387 | metricValue := testGetMetricValue(map[string]string{ |
| 388 | "path": test.path, |
| 389 | "handler": "admin", |
| 390 | "method": test.method, |
| 391 | }) |
| 392 | if metricValue != 1 { |
| 393 | t.Errorf("expected error metric to be incremented once, got %v", metricValue) |
| 394 | } |
| 395 | }) |
| 396 | } |
| 397 | } |
| 398 | |
| 399 | func testGetMetricValue(labels map[string]string) float64 { |
| 400 | promLabels := prometheus.Labels{} |
| 401 | maps.Copy(promLabels, labels) |
| 402 | |
| 403 | metric, err := adminMetrics.requestErrors.GetMetricWith(promLabels) |
| 404 | if err != nil { |
| 405 | return 0 |
| 406 | } |
| 407 | |
| 408 | pb := &dto.Metric{} |
| 409 | metric.Write(pb) |
| 410 | return pb.GetCounter().GetValue() |
| 411 | } |
| 412 | |
| 413 | type mockRouter struct { |
| 414 | routes []AdminRoute |
| 415 | } |
| 416 | |
| 417 | func (m mockRouter) Routes() []AdminRoute { |
| 418 | return m.routes |
| 419 | } |
| 420 | |
| 421 | type mockModule struct { |
| 422 | mockRouter |
| 423 | } |
| 424 | |
| 425 | func (m *mockModule) CaddyModule() ModuleInfo { |
| 426 | return ModuleInfo{ |
| 427 | ID: "admin.api.mock", |
| 428 | New: func() Module { |
| 429 | mm := &mockModule{ |
| 430 | mockRouter: mockRouter{ |
| 431 | routes: m.routes, |
| 432 | }, |
| 433 | } |
| 434 | return mm |
| 435 | }, |
| 436 | } |
| 437 | } |
| 438 | |
| 439 | func TestNewAdminHandlerRouterRegistration(t *testing.T) { |
| 440 | originalModules := make(map[string]ModuleInfo) |
| 441 | maps.Copy(originalModules, modules) |
| 442 | defer func() { |
| 443 | modules = originalModules |
| 444 | }() |
| 445 | |
| 446 | mockRoute := AdminRoute{ |
| 447 | Pattern: "/mock", |
| 448 | Handler: AdminHandlerFunc(func(w http.ResponseWriter, r *http.Request) error { |
| 449 | w.WriteHeader(http.StatusOK) |
| 450 | return nil |
| 451 | }), |
| 452 | } |
| 453 | |
| 454 | mock := &mockModule{ |
| 455 | mockRouter: mockRouter{ |
| 456 | routes: []AdminRoute{mockRoute}, |
| 457 | }, |
| 458 | } |
| 459 | RegisterModule(mock) |
| 460 | |
| 461 | addr, err := ParseNetworkAddress("localhost:2019") |
| 462 | if err != nil { |
| 463 | t.Fatalf("Failed to parse address: %v", err) |
| 464 | } |
| 465 | |
| 466 | admin := &AdminConfig{ |
| 467 | EnforceOrigin: false, |
| 468 | } |
| 469 | handler, err := admin.newAdminHandler(addr, false, Context{}) |
| 470 | if err != nil { |
| 471 | t.Fatalf("Failed to create admin handler: %v", err) |
| 472 | } |
| 473 | |
| 474 | req := httptest.NewRequest("GET", "/mock", nil) |
| 475 | req.Host = "localhost:2019" |
| 476 | rr := httptest.NewRecorder() |
| 477 | |
| 478 | handler.ServeHTTP(rr, req) |
| 479 | |
| 480 | if rr.Code != http.StatusOK { |
| 481 | t.Errorf("Expected status code %d but got %d", http.StatusOK, rr.Code) |
| 482 | t.Logf("Response body: %s", rr.Body.String()) |
| 483 | } |
| 484 | } |
| 485 | |
| 486 | type mockProvisionableRouter struct { |
| 487 | mockRouter |
| 488 | provisionErr error |
| 489 | provisioned bool |
| 490 | } |
| 491 | |
| 492 | func (m *mockProvisionableRouter) Provision(Context) error { |
| 493 | m.provisioned = true |
| 494 | return m.provisionErr |
| 495 | } |
| 496 | |
| 497 | type mockProvisionableModule struct { |
| 498 | *mockProvisionableRouter |
| 499 | } |
| 500 | |
| 501 | func (m *mockProvisionableModule) CaddyModule() ModuleInfo { |
| 502 | return ModuleInfo{ |
| 503 | ID: "admin.api.mock_provision", |
| 504 | New: func() Module { |
| 505 | mm := &mockProvisionableModule{ |
| 506 | mockProvisionableRouter: &mockProvisionableRouter{ |
| 507 | mockRouter: m.mockRouter, |
| 508 | provisionErr: m.provisionErr, |
| 509 | }, |
| 510 | } |
| 511 | return mm |
| 512 | }, |
| 513 | } |
| 514 | } |
| 515 | |
| 516 | func TestAdminRouterProvisioning(t *testing.T) { |
| 517 | tests := []struct { |
| 518 | name string |
| 519 | provisionErr error |
| 520 | wantErr bool |
| 521 | }{ |
| 522 | { |
| 523 | name: "successful provisioning", |
| 524 | provisionErr: nil, |
| 525 | wantErr: false, |
| 526 | }, |
| 527 | { |
| 528 | name: "provisioning error", |
| 529 | provisionErr: fmt.Errorf("provision failed"), |
| 530 | wantErr: true, |
| 531 | }, |
| 532 | } |
| 533 | |
| 534 | for _, test := range tests { |
| 535 | t.Run(test.name, func(t *testing.T) { |
| 536 | originalModules := make(map[string]ModuleInfo) |
| 537 | maps.Copy(originalModules, modules) |
| 538 | defer func() { |
| 539 | modules = originalModules |
| 540 | }() |
| 541 | |
| 542 | mockRoute := AdminRoute{ |
| 543 | Pattern: "/mock", |
| 544 | Handler: AdminHandlerFunc(func(w http.ResponseWriter, r *http.Request) error { |
| 545 | return nil |
| 546 | }), |
| 547 | } |
| 548 | |
| 549 | // Create provisionable module |
| 550 | mock := &mockProvisionableModule{ |
| 551 | mockProvisionableRouter: &mockProvisionableRouter{ |
| 552 | mockRouter: mockRouter{ |
| 553 | routes: []AdminRoute{mockRoute}, |
| 554 | }, |
| 555 | provisionErr: test.provisionErr, |
| 556 | }, |
| 557 | } |
| 558 | RegisterModule(mock) |
| 559 | |
| 560 | admin := &AdminConfig{} |
| 561 | addr, err := ParseNetworkAddress("localhost:2019") |
| 562 | if err != nil { |
| 563 | t.Fatalf("Failed to parse address: %v", err) |
| 564 | } |
| 565 | |
| 566 | _, err = admin.newAdminHandler(addr, false, Context{}) |
| 567 | |
| 568 | if test.wantErr { |
| 569 | if err == nil { |
| 570 | t.Error("Expected error but got nil") |
| 571 | } |
| 572 | } else { |
| 573 | if err != nil { |
| 574 | t.Errorf("Expected no error but got: %v", err) |
| 575 | } |
| 576 | } |
| 577 | }) |
| 578 | } |
| 579 | } |
| 580 | |
| 581 | func TestAllowedOriginsUnixSocket(t *testing.T) { |
| 582 | // see comment in allowedOrigins() as to why we do not fill out allowed origins for UDS |
| 583 | tests := []struct { |
| 584 | name string |
| 585 | addr NetworkAddress |
| 586 | origins []string |
| 587 | expectOrigins []string |
| 588 | }{ |
| 589 | { |
| 590 | name: "unix socket with default origins", |
| 591 | addr: NetworkAddress{ |
| 592 | Network: "unix", |
| 593 | Host: "/tmp/caddy.sock", |
| 594 | }, |
| 595 | origins: nil, // default origins |
| 596 | expectOrigins: []string{}, |
| 597 | }, |
| 598 | { |
| 599 | name: "unix socket with custom origins", |
| 600 | addr: NetworkAddress{ |
| 601 | Network: "unix", |
| 602 | Host: "/tmp/caddy.sock", |
| 603 | }, |
| 604 | origins: []string{"example.com"}, |
| 605 | expectOrigins: []string{ |
| 606 | "example.com", |
| 607 | }, |
| 608 | }, |
| 609 | { |
| 610 | name: "tcp socket on localhost gets all loopback addresses", |
| 611 | addr: NetworkAddress{ |
| 612 | Network: "tcp", |
| 613 | Host: "localhost", |
| 614 | StartPort: 2019, |
| 615 | EndPort: 2019, |
| 616 | }, |
| 617 | origins: nil, |
| 618 | expectOrigins: []string{ |
| 619 | "localhost:2019", |
| 620 | "[::1]:2019", |
| 621 | "127.0.0.1:2019", |
| 622 | }, |
| 623 | }, |
| 624 | } |
| 625 | |
| 626 | for i, test := range tests { |
| 627 | t.Run(test.name, func(t *testing.T) { |
| 628 | admin := AdminConfig{ |
| 629 | Origins: test.origins, |
| 630 | } |
| 631 | |
| 632 | got := admin.allowedOrigins(test.addr) |
| 633 | |
| 634 | var gotOrigins []string |
| 635 | for _, u := range got { |
| 636 | gotOrigins = append(gotOrigins, u.Host) |
| 637 | } |
| 638 | |
| 639 | if len(gotOrigins) != len(test.expectOrigins) { |
| 640 | t.Errorf("%d: Expected %d origins but got %d", i, len(test.expectOrigins), len(gotOrigins)) |
| 641 | return |
| 642 | } |
| 643 | |
| 644 | expectMap := make(map[string]struct{}) |
| 645 | for _, origin := range test.expectOrigins { |
| 646 | expectMap[origin] = struct{}{} |
| 647 | } |
| 648 | |
| 649 | gotMap := make(map[string]struct{}) |
| 650 | for _, origin := range gotOrigins { |
| 651 | gotMap[origin] = struct{}{} |
| 652 | } |
| 653 | |
| 654 | if !reflect.DeepEqual(expectMap, gotMap) { |
| 655 | t.Errorf("%d: Origins mismatch.\nExpected: %v\nGot: %v", i, test.expectOrigins, gotOrigins) |
| 656 | } |
| 657 | }) |
| 658 | } |
| 659 | } |
| 660 | |
| 661 | func TestRemoteAdminAccessControlPathSegmentMatching(t *testing.T) { |
| 662 | const authorizedKey testAdminPublicKey = "authorized" |
| 663 | peerCert := &x509.Certificate{PublicKey: authorizedKey} |
| 664 | |
| 665 | tests := []struct { |
| 666 | name string |
| 667 | allowedPath string |
| 668 | requestPath string |
| 669 | wantErr bool |
| 670 | }{ |
| 671 | { |
| 672 | name: "exact path", |
| 673 | allowedPath: "/pki/ca/prod", |
| 674 | requestPath: "/pki/ca/prod", |
| 675 | wantErr: false, |
| 676 | }, |
| 677 | { |
| 678 | name: "subpath", |
| 679 | allowedPath: "/pki/ca/prod", |
| 680 | requestPath: "/pki/ca/prod/certificates", |
| 681 | wantErr: false, |
| 682 | }, |
| 683 | { |
| 684 | name: "trailing slash subpath", |
| 685 | allowedPath: "/pki/ca/prod/", |
| 686 | requestPath: "/pki/ca/prod/certificates", |
| 687 | wantErr: false, |
| 688 | }, |
| 689 | { |
| 690 | name: "trailing slash scope excludes exact path without slash", |
| 691 | allowedPath: "/pki/ca/prod/", |
| 692 | requestPath: "/pki/ca/prod", |
| 693 | wantErr: true, |
| 694 | }, |
| 695 | { |
| 696 | name: "trailing slash scope includes exact path with slash", |
| 697 | allowedPath: "/pki/ca/prod/", |
| 698 | requestPath: "/pki/ca/prod/", |
| 699 | wantErr: false, |
| 700 | }, |
| 701 | { |
| 702 | name: "sibling with shared prefix", |
| 703 | allowedPath: "/pki/ca/prod", |
| 704 | requestPath: "/pki/ca/prod-backup", |
| 705 | wantErr: true, |
| 706 | }, |
| 707 | { |
| 708 | name: "same segment plus digit", |
| 709 | allowedPath: "/pki/ca/prod", |
| 710 | requestPath: "/pki/ca/prod1", |
| 711 | wantErr: true, |
| 712 | }, |
| 713 | { |
| 714 | name: "root path", |
| 715 | allowedPath: "/", |
| 716 | requestPath: "/pki/ca/prod", |
| 717 | wantErr: false, |
| 718 | }, |
| 719 | { |
| 720 | name: "empty path preserves allow all", |
| 721 | allowedPath: "", |
| 722 | requestPath: "/pki/ca/prod", |
| 723 | wantErr: false, |
| 724 | }, |
| 725 | { |
| 726 | name: "dot-dot traversal out of scope", |
| 727 | allowedPath: "/pki/ca/prod", |
| 728 | requestPath: "/pki/ca/prod/../../../../load", |
| 729 | wantErr: true, |
| 730 | }, |
| 731 | { |
| 732 | name: "dot-dot traversal out of trailing slash scope", |
| 733 | allowedPath: "/pki/ca/prod/", |
| 734 | requestPath: "/pki/ca/prod/../../../../load", |
| 735 | wantErr: true, |
| 736 | }, |
| 737 | { |
| 738 | name: "encoded traversal (net/url decodes %2e to . before path.Clean resolves ..)", |
| 739 | allowedPath: "/pki/ca/prod", |
| 740 | requestPath: "/pki/ca/prod/%2e%2e/load", |
| 741 | wantErr: true, |
| 742 | }, |
| 743 | { |
| 744 | name: "sibling traversal", |
| 745 | allowedPath: "/pki/ca/prod", |
| 746 | requestPath: "/pki/ca/prod/../staging", |
| 747 | wantErr: true, |
| 748 | }, |
| 749 | { |
| 750 | name: "collapsed slashes", |
| 751 | allowedPath: "/pki/ca/prod", |
| 752 | requestPath: "/pki//ca/prod", |
| 753 | wantErr: false, |
| 754 | }, |
| 755 | { |
| 756 | name: "exact request with trailing slash", |
| 757 | allowedPath: "/pki/ca/prod", |
| 758 | requestPath: "/pki/ca/prod/", |
| 759 | wantErr: false, |
| 760 | }, |
| 761 | } |
| 762 | |
| 763 | for i, test := range tests { |
| 764 | t.Run(test.name, func(t *testing.T) { |
| 765 | remote := RemoteAdmin{ |
| 766 | AccessControl: []*AdminAccess{ |
| 767 | { |
| 768 | Permissions: []AdminPermissions{ |
| 769 | { |
| 770 | Methods: []string{http.MethodGet}, |
| 771 | Paths: []string{test.allowedPath}, |
| 772 | }, |
| 773 | }, |
| 774 | publicKeys: []crypto.PublicKey{authorizedKey}, |
| 775 | }, |
| 776 | }, |
| 777 | } |
| 778 | |
| 779 | req := httptest.NewRequest(http.MethodGet, "https://localhost:2021"+test.requestPath, nil) |
| 780 | req.TLS = &tls.ConnectionState{ |
| 781 | VerifiedChains: [][]*x509.Certificate{{peerCert}}, |
| 782 | } |
| 783 | |
| 784 | err := remote.enforceAccessControls(req) |
| 785 | if test.wantErr { |
| 786 | if err == nil { |
| 787 | t.Errorf("test %d (%s): allowed path %q, request path %q: expected forbidden error, got nil", i, test.name, test.allowedPath, test.requestPath) |
| 788 | return |
| 789 | } |
| 790 | var apiErr APIError |
| 791 | if !errors.As(err, &apiErr) { |
| 792 | t.Errorf("test %d (%s): allowed path %q, request path %q: expected APIError with HTTP status %d, got %T: %v", i, test.name, test.allowedPath, test.requestPath, http.StatusForbidden, err, err) |
| 793 | return |
| 794 | } |
| 795 | if apiErr.HTTPStatus != http.StatusForbidden { |
| 796 | t.Errorf("test %d (%s): allowed path %q, request path %q: expected HTTP status %d, got %d", i, test.name, test.allowedPath, test.requestPath, http.StatusForbidden, apiErr.HTTPStatus) |
| 797 | } |
| 798 | return |
| 799 | } |
| 800 | |
| 801 | if err != nil { |
| 802 | t.Errorf("test %d (%s): allowed path %q, request path %q: expected no error, got %v", i, test.name, test.allowedPath, test.requestPath, err) |
| 803 | } |
| 804 | }) |
| 805 | } |
| 806 | } |
| 807 | |
| 808 | func TestRemoteAdminAccessControlMultiplePermissions(t *testing.T) { |
| 809 | const authorizedKey testAdminPublicKey = "authorized" |
| 810 | peerCert := &x509.Certificate{PublicKey: authorizedKey} |
| 811 | |
| 812 | tests := []struct { |
| 813 | name string |
| 814 | permissions []AdminPermissions |
| 815 | // extraAccess adds further access control entries holding the same key |
| 816 | extraAccess [][]AdminPermissions |
| 817 | method string |
| 818 | requestPath string |
| 819 | wantErr string |
| 820 | }{ |
| 821 | { |
| 822 | name: "no permissions grants all", |
| 823 | permissions: nil, |
| 824 | method: http.MethodDelete, |
| 825 | requestPath: "/config/", |
| 826 | }, |
| 827 | { |
| 828 | name: "first entry matches", |
| 829 | permissions: []AdminPermissions{ |
| 830 | {Methods: []string{http.MethodGet}, Paths: []string{"/config/"}}, |
| 831 | {Methods: []string{http.MethodPost}, Paths: []string{"/load"}}, |
| 832 | }, |
| 833 | method: http.MethodGet, |
| 834 | requestPath: "/config/", |
| 835 | }, |
| 836 | { |
| 837 | name: "second entry matches", |
| 838 | permissions: []AdminPermissions{ |
| 839 | {Methods: []string{http.MethodGet}, Paths: []string{"/config/"}}, |
| 840 | {Methods: []string{http.MethodPost}, Paths: []string{"/load"}}, |
| 841 | }, |
| 842 | method: http.MethodPost, |
| 843 | requestPath: "/load", |
| 844 | }, |
| 845 | { |
| 846 | name: "same path different methods across entries", |
| 847 | permissions: []AdminPermissions{ |
| 848 | {Methods: []string{http.MethodGet}, Paths: []string{"/config/"}}, |
| 849 | {Methods: []string{http.MethodPatch}, Paths: []string{"/config/"}}, |
| 850 | }, |
| 851 | method: http.MethodPatch, |
| 852 | requestPath: "/config/", |
| 853 | }, |
| 854 | { |
| 855 | name: "method and path from different entries", |
| 856 | permissions: []AdminPermissions{ |
| 857 | {Methods: []string{http.MethodGet}, Paths: []string{"/config/"}}, |
| 858 | {Methods: []string{http.MethodPost}, Paths: []string{"/load"}}, |
| 859 | }, |
| 860 | method: http.MethodPost, |
| 861 | requestPath: "/config/", |
| 862 | wantErr: "not authorized to use this method", |
| 863 | }, |
| 864 | { |
| 865 | name: "path allowed by no entry", |
| 866 | permissions: []AdminPermissions{ |
| 867 | {Methods: []string{http.MethodGet}, Paths: []string{"/config/"}}, |
| 868 | {Methods: []string{http.MethodPost}, Paths: []string{"/load"}}, |
| 869 | }, |
| 870 | method: http.MethodGet, |
| 871 | requestPath: "/stop", |
| 872 | wantErr: "not authorized to access this path", |
| 873 | }, |
| 874 | { |
| 875 | name: "later access entry with the same key matches", |
| 876 | permissions: []AdminPermissions{ |
| 877 | {Methods: []string{http.MethodGet}, Paths: []string{"/config/"}}, |
| 878 | }, |
| 879 | extraAccess: [][]AdminPermissions{ |
| 880 | {{Methods: []string{http.MethodPost}, Paths: []string{"/load"}}}, |
| 881 | }, |
| 882 | method: http.MethodPost, |
| 883 | requestPath: "/load", |
| 884 | }, |
| 885 | { |
| 886 | name: "no access entry with the same key matches", |
| 887 | permissions: []AdminPermissions{ |
| 888 | {Methods: []string{http.MethodGet}, Paths: []string{"/config/"}}, |
| 889 | }, |
| 890 | extraAccess: [][]AdminPermissions{ |
| 891 | {{Methods: []string{http.MethodGet}, Paths: []string{"/load"}}}, |
| 892 | }, |
| 893 | method: http.MethodPost, |
| 894 | requestPath: "/load", |
| 895 | wantErr: "not authorized to use this method", |
| 896 | }, |
| 897 | } |
| 898 | |
| 899 | for _, test := range tests { |
| 900 | t.Run(test.name, func(t *testing.T) { |
| 901 | remote := RemoteAdmin{ |
| 902 | AccessControl: []*AdminAccess{ |
| 903 | { |
| 904 | Permissions: test.permissions, |
| 905 | publicKeys: []crypto.PublicKey{authorizedKey}, |
| 906 | }, |
| 907 | }, |
| 908 | } |
| 909 | for _, permissions := range test.extraAccess { |
| 910 | remote.AccessControl = append(remote.AccessControl, &AdminAccess{ |
| 911 | Permissions: permissions, |
| 912 | publicKeys: []crypto.PublicKey{authorizedKey}, |
| 913 | }) |
| 914 | } |
| 915 | |
| 916 | req := httptest.NewRequest(test.method, "https://localhost:2021"+test.requestPath, nil) |
| 917 | req.TLS = &tls.ConnectionState{ |
| 918 | VerifiedChains: [][]*x509.Certificate{{peerCert}}, |
| 919 | } |
| 920 | |
| 921 | err := remote.enforceAccessControls(req) |
| 922 | if test.wantErr == "" { |
| 923 | if err != nil { |
| 924 | t.Errorf("%s %s: expected no error, got %v", test.method, test.requestPath, err) |
| 925 | } |
| 926 | return |
| 927 | } |
| 928 | |
| 929 | var apiErr APIError |
| 930 | if !errors.As(err, &apiErr) { |
| 931 | t.Fatalf("%s %s: expected APIError, got %T: %v", test.method, test.requestPath, err, err) |
| 932 | } |
| 933 | if apiErr.HTTPStatus != http.StatusForbidden { |
| 934 | t.Errorf("%s %s: expected HTTP status %d, got %d", test.method, test.requestPath, http.StatusForbidden, apiErr.HTTPStatus) |
| 935 | } |
| 936 | if apiErr.Message != test.wantErr { |
| 937 | t.Errorf("%s %s: expected message %q, got %q", test.method, test.requestPath, test.wantErr, apiErr.Message) |
| 938 | } |
| 939 | }) |
| 940 | } |
| 941 | } |
| 942 | |
| 943 | func TestReplaceRemoteAdminServer(t *testing.T) { |
| 944 | const testCert = `MIIDCTCCAfGgAwIBAgIUXsqJ1mY8pKlHQtI3HJ23x2eZPqwwDQYJKoZIhvcNAQEL |
| 945 | BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTIzMDEwMTAwMDAwMFoXDTI0MDEw |
| 946 | MTAwMDAwMFowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF |
| 947 | AAOCAQ8AMIIBCgKCAQEA4O4S6BSoYcoxvRqI+h7yPOjF6KjntjzVVm9M+uHK4lzX |
| 948 | F1L3pSxJ2nDD4wZEV3FJ5yFOHVFqkG2vXG3BIczOlYG7UeNmKbQnKc5kZj3HGUrS |
| 949 | VGEktA4OJbeZhhWP15gcXN5eDM2eH3g9BFXVX6AURxLiUXzhNBUEZuj/OEyH9yEF |
| 950 | /qPCE+EjzVvWxvBXwgz/io4r4yok/Vq/bxJ6FlV6R7DX5oJSXyO0VEHZPi9DIyNU |
| 951 | kK3F/r4U1sWiJGWOs8i3YQWZ2ejh1C0aLFZpPcCGGgMNpoF31gyYP6ZuPDUyCXsE |
| 952 | g36UUw1JHNtIXYcLhnXuqj4A8TybTDpgXLqvwA9DBQIDAQABo1MwUTAdBgNVHQ4E |
| 953 | FgQUc13z30pFC63rr/HGKOE7E82vjXwwHwYDVR0jBBgwFoAUc13z30pFC63rr/HG |
| 954 | KOE7E82vjXwwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAHO3j |
| 955 | oeiUXXJ7xD4P8Wj5t9d+E8lE1Xv1Dk3Z+EdG5+dan+RcToE42JJp9zB7FIh5Qz8g |
| 956 | W77LAjqh5oyqz3A2VJcyVgfE3uJP1R1mJM7JfGHf84QH4TZF2Q1RZY4SZs0VQ6+q |
| 957 | 5wSlIZ4NXDy4Q4XkIJBGS61wT8IzYFXYBpx4PCP1Qj0PIE4sevEGwjsBIgxK307o |
| 958 | BxF8AWe6N6e4YZmQLGjQ+SeH0iwZb6vpkHyAY8Kj2hvK+cq2P7vU3VGi0t3r1F8L |
| 959 | IvrXHCvO2BMNJ/1UK1M4YNX8LYJqQhg9hEsIROe1OE/m3VhxIYMJI+qZXk9yHfgJ |
| 960 | vq+SH04xKhtFudVBAQ==` |
| 961 | |
| 962 | tests := []struct { |
| 963 | name string |
| 964 | cfg *Config |
| 965 | wantErr bool |
| 966 | }{ |
| 967 | { |
| 968 | name: "nil config", |
| 969 | cfg: nil, |
| 970 | wantErr: false, |
| 971 | }, |
| 972 | { |
| 973 | name: "nil admin config", |
| 974 | cfg: &Config{ |
| 975 | Admin: nil, |
| 976 | }, |
| 977 | wantErr: false, |
| 978 | }, |
| 979 | { |
| 980 | name: "nil remote config", |
| 981 | cfg: &Config{ |
| 982 | Admin: &AdminConfig{}, |
| 983 | }, |
| 984 | wantErr: false, |
| 985 | }, |
| 986 | { |
| 987 | name: "invalid listen address", |
| 988 | cfg: &Config{ |
| 989 | Admin: &AdminConfig{ |
| 990 | Remote: &RemoteAdmin{ |
| 991 | Listen: "invalid:address", |
| 992 | }, |
| 993 | }, |
| 994 | }, |
| 995 | wantErr: true, |
| 996 | }, |
| 997 | { |
| 998 | name: "valid config", |
| 999 | cfg: &Config{ |
| 1000 | Admin: &AdminConfig{ |
| 1001 | Identity: &IdentityConfig{}, |
| 1002 | Remote: &RemoteAdmin{ |
| 1003 | Listen: "localhost:2021", |
| 1004 | AccessControl: []*AdminAccess{ |
| 1005 | { |
| 1006 | PublicKeys: []string{testCert}, |
| 1007 | Permissions: []AdminPermissions{{Methods: []string{"GET"}, Paths: []string{"/test"}}}, |
| 1008 | }, |
| 1009 | }, |
| 1010 | }, |
| 1011 | }, |
| 1012 | }, |
| 1013 | wantErr: false, |
| 1014 | }, |
| 1015 | { |
| 1016 | name: "invalid certificate", |
| 1017 | cfg: &Config{ |
| 1018 | Admin: &AdminConfig{ |
| 1019 | Identity: &IdentityConfig{}, |
| 1020 | Remote: &RemoteAdmin{ |
| 1021 | Listen: "localhost:2021", |
| 1022 | AccessControl: []*AdminAccess{ |
| 1023 | { |
| 1024 | PublicKeys: []string{"invalid-cert-data"}, |
| 1025 | Permissions: []AdminPermissions{{Methods: []string{"GET"}, Paths: []string{"/test"}}}, |
| 1026 | }, |
| 1027 | }, |
| 1028 | }, |
| 1029 | }, |
| 1030 | }, |
| 1031 | wantErr: true, |
| 1032 | }, |
| 1033 | { |
| 1034 | name: "non-canonical root path with trailing slash", |
| 1035 | cfg: &Config{ |
| 1036 | Admin: &AdminConfig{ |
| 1037 | Identity: &IdentityConfig{}, |
| 1038 | Remote: &RemoteAdmin{ |
| 1039 | Listen: "localhost:2021", |
| 1040 | AccessControl: []*AdminAccess{ |
| 1041 | { |
| 1042 | PublicKeys: []string{testCert}, |
| 1043 | Permissions: []AdminPermissions{{Methods: []string{"GET"}, Paths: []string{"//"}}}, |
| 1044 | }, |
| 1045 | }, |
| 1046 | }, |
| 1047 | }, |
| 1048 | }, |
| 1049 | wantErr: true, |
| 1050 | }, |
| 1051 | { |
| 1052 | name: "canonical subpath with trailing slash", |
| 1053 | cfg: &Config{ |
| 1054 | Admin: &AdminConfig{ |
| 1055 | Identity: &IdentityConfig{}, |
| 1056 | Remote: &RemoteAdmin{ |
| 1057 | Listen: "localhost:2021", |
| 1058 | AccessControl: []*AdminAccess{ |
| 1059 | { |
| 1060 | PublicKeys: []string{testCert}, |
| 1061 | Permissions: []AdminPermissions{{Methods: []string{"GET"}, Paths: []string{"/foo/"}}}, |
| 1062 | }, |
| 1063 | }, |
| 1064 | }, |
| 1065 | }, |
| 1066 | }, |
| 1067 | wantErr: false, |
| 1068 | }, |
| 1069 | { |
| 1070 | name: "non-canonical path /..", |
| 1071 | cfg: &Config{ |
| 1072 | Admin: &AdminConfig{ |
| 1073 | Identity: &IdentityConfig{}, |
| 1074 | Remote: &RemoteAdmin{ |
| 1075 | Listen: "localhost:2021", |
| 1076 | AccessControl: []*AdminAccess{ |
| 1077 | { |
| 1078 | PublicKeys: []string{testCert}, |
| 1079 | Permissions: []AdminPermissions{{Methods: []string{"GET"}, Paths: []string{"/.."}}}, |
| 1080 | }, |
| 1081 | }, |
| 1082 | }, |
| 1083 | }, |
| 1084 | }, |
| 1085 | wantErr: true, |
| 1086 | }, |
| 1087 | { |
| 1088 | name: "non-canonical path with double slashes", |
| 1089 | cfg: &Config{ |
| 1090 | Admin: &AdminConfig{ |
| 1091 | Identity: &IdentityConfig{}, |
| 1092 | Remote: &RemoteAdmin{ |
| 1093 | Listen: "localhost:2021", |
| 1094 | AccessControl: []*AdminAccess{ |
| 1095 | { |
| 1096 | PublicKeys: []string{testCert}, |
| 1097 | Permissions: []AdminPermissions{{Methods: []string{"GET"}, Paths: []string{"/foo//bar"}}}, |
| 1098 | }, |
| 1099 | }, |
| 1100 | }, |
| 1101 | }, |
| 1102 | }, |
| 1103 | wantErr: true, |
| 1104 | }, |
| 1105 | { |
| 1106 | name: "non-canonical path with double trailing slashes", |
| 1107 | cfg: &Config{ |
| 1108 | Admin: &AdminConfig{ |
| 1109 | Identity: &IdentityConfig{}, |
| 1110 | Remote: &RemoteAdmin{ |
| 1111 | Listen: "localhost:2021", |
| 1112 | AccessControl: []*AdminAccess{ |
| 1113 | { |
| 1114 | PublicKeys: []string{testCert}, |
| 1115 | Permissions: []AdminPermissions{{Methods: []string{"GET"}, Paths: []string{"/foo//"}}}, |
| 1116 | }, |
| 1117 | }, |
| 1118 | }, |
| 1119 | }, |
| 1120 | }, |
| 1121 | wantErr: true, |
| 1122 | }, |
| 1123 | } |
| 1124 | |
| 1125 | for _, test := range tests { |
| 1126 | t.Run(test.name, func(t *testing.T) { |
| 1127 | ctx := Context{ |
| 1128 | Context: context.Background(), |
| 1129 | cfg: test.cfg, |
| 1130 | } |
| 1131 | |
| 1132 | if test.cfg != nil { |
| 1133 | test.cfg.storage = &certmagic.FileStorage{Path: t.TempDir()} |
| 1134 | } |
| 1135 | |
| 1136 | if test.cfg != nil && test.cfg.Admin != nil && test.cfg.Admin.Identity != nil { |
| 1137 | identityCertCache = certmagic.NewCache(certmagic.CacheOptions{ |
| 1138 | GetConfigForCert: func(certmagic.Certificate) (*certmagic.Config, error) { |
| 1139 | return &certmagic.Config{}, nil |
| 1140 | }, |
| 1141 | }) |
| 1142 | } |
| 1143 | |
| 1144 | err := replaceRemoteAdminServer(ctx, test.cfg) |
| 1145 | |
| 1146 | if test.wantErr { |
| 1147 | if err == nil { |
| 1148 | t.Error("Expected error but got nil") |
| 1149 | } |
| 1150 | } else { |
| 1151 | if err != nil { |
| 1152 | t.Errorf("Expected no error but got: %v", err) |
| 1153 | } |
| 1154 | } |
| 1155 | |
| 1156 | // Clean up |
| 1157 | if remoteAdminServer != nil { |
| 1158 | _ = stopAdminServer(remoteAdminServer) |
| 1159 | } |
| 1160 | }) |
| 1161 | } |
| 1162 | } |
| 1163 | |
| 1164 | type mockIssuer struct { |
| 1165 | configSet *certmagic.Config |
| 1166 | } |
| 1167 | |
| 1168 | func (m *mockIssuer) Issue(ctx context.Context, csr *x509.CertificateRequest) (*certmagic.IssuedCertificate, error) { |
| 1169 | return &certmagic.IssuedCertificate{ |
| 1170 | Certificate: []byte(csr.Raw), |
| 1171 | }, nil |
| 1172 | } |
| 1173 | |
| 1174 | func (m *mockIssuer) SetConfig(cfg *certmagic.Config) { |
| 1175 | m.configSet = cfg |
| 1176 | } |
| 1177 | |
| 1178 | func (m *mockIssuer) IssuerKey() string { |
| 1179 | return "mock" |
| 1180 | } |
| 1181 | |
| 1182 | type mockIssuerModule struct { |
| 1183 | *mockIssuer |
| 1184 | } |
| 1185 | |
| 1186 | func (m *mockIssuerModule) CaddyModule() ModuleInfo { |
| 1187 | return ModuleInfo{ |
| 1188 | ID: "tls.issuance.acme", |
| 1189 | New: func() Module { |
| 1190 | return &mockIssuerModule{mockIssuer: new(mockIssuer)} |
| 1191 | }, |
| 1192 | } |
| 1193 | } |
| 1194 | |
| 1195 | func TestManageIdentity(t *testing.T) { |
| 1196 | originalModules := make(map[string]ModuleInfo) |
| 1197 | maps.Copy(originalModules, modules) |
| 1198 | defer func() { |
| 1199 | modules = originalModules |
| 1200 | }() |
| 1201 | |
| 1202 | RegisterModule(&mockIssuerModule{}) |
| 1203 | |
| 1204 | certPEM := []byte(`-----BEGIN CERTIFICATE----- |
| 1205 | MIIDujCCAqKgAwIBAgIIE31FZVaPXTUwDQYJKoZIhvcNAQEFBQAwSTELMAkGA1UE |
| 1206 | BhMCVVMxEzARBgNVBAoTCkdvb2dsZSBJbmMxJTAjBgNVBAMTHEdvb2dsZSBJbnRl |
| 1207 | cm5ldCBBdXRob3JpdHkgRzIwHhcNMTQwMTI5MTMyNzQzWhcNMTQwNTI5MDAwMDAw |
| 1208 | WjBpMQswCQYDVQQGEwJVUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwN |
| 1209 | TW91bnRhaW4gVmlldzETMBEGA1UECgwKR29vZ2xlIEluYzEYMBYGA1UEAwwPbWFp |
| 1210 | bC5nb29nbGUuY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE3lcub2pUwkjC |
| 1211 | 5GJQA2ZZfJJi6d1QHhEmkX9VxKYGp6gagZuRqJWy9TXP6++1ZzQQxqZLD0TkuxZ9 |
| 1212 | 8i9Nz00000CCBjCCAQQwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMGgG |
| 1213 | CCsGAQUFBwEBBFwwWjArBggrBgEFBQcwAoYfaHR0cDovL3BraS5nb29nbGUuY29t |
| 1214 | L0dJQUcyLmNydDArBggrBgEFBQcwAYYfaHR0cDovL2NsaWVudHMxLmdvb2dsZS5j |
| 1215 | b20vb2NzcDAdBgNVHQ4EFgQUiJxtimAuTfwb+aUtBn5UYKreKvMwDAYDVR0TAQH/ |
| 1216 | BAIwADAfBgNVHSMEGDAWgBRK3QYWG7z2aLV29YG2u2IaulqBLzAXBgNVHREEEDAO |
| 1217 | ggxtYWlsLmdvb2dsZTANBgkqhkiG9w0BAQUFAAOCAQEAMP6IWgNGZE8wP9TjFjSZ |
| 1218 | 3mmW3A1eIr0CuPwNZ2LJ5ZD1i70ojzcj4I9IdP5yPg9CAEV4hNASbM1LzfC7GmJE |
| 1219 | tPzW5tRmpKVWZGRgTgZI8Hp/xZXMwLh9ZmXV4kESFAGj5G5FNvJyUV7R5Eh+7OZX |
| 1220 | 7G4jJ4ZGJh+5jzN9HdJJHQHGYNIYOzC7+HH9UMwCjX9vhQ4RjwFZJThS2Yb+y7pb |
| 1221 | 9yxTJZoXC6J0H5JpnZb7kZEJ+Xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx |
| 1222 | -----END CERTIFICATE-----`) |
| 1223 | |
| 1224 | keyPEM := []byte(`-----BEGIN PRIVATE KEY----- |
| 1225 | MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDRS0LmTwUT0iwP |
| 1226 | ... |
| 1227 | -----END PRIVATE KEY-----`) |
| 1228 | |
| 1229 | tmpDir, err := os.MkdirTemp("", "TestManageIdentity-") |
| 1230 | if err != nil { |
| 1231 | t.Fatal(err) |
| 1232 | } |
| 1233 | testStorage := certmagic.FileStorage{Path: tmpDir} |
| 1234 | // Clean up the temp dir after the test finishes. Ensure any background |
| 1235 | // certificate maintenance is stopped first to avoid RemoveAll races. |
| 1236 | t.Cleanup(func() { |
| 1237 | if identityCertCache != nil { |
| 1238 | identityCertCache.Stop() |
| 1239 | identityCertCache = nil |
| 1240 | } |
| 1241 | // Give goroutines a moment to exit and release file handles. |
| 1242 | time.Sleep(50 * time.Millisecond) |
| 1243 | _ = os.RemoveAll(tmpDir) |
| 1244 | }) |
| 1245 | |
| 1246 | err = testStorage.Store(context.Background(), "localhost/localhost.crt", certPEM) |
| 1247 | if err != nil { |
| 1248 | t.Fatal(err) |
| 1249 | } |
| 1250 | err = testStorage.Store(context.Background(), "localhost/localhost.key", keyPEM) |
| 1251 | if err != nil { |
| 1252 | t.Fatal(err) |
| 1253 | } |
| 1254 | |
| 1255 | tests := []struct { |
| 1256 | name string |
| 1257 | cfg *Config |
| 1258 | wantErr bool |
| 1259 | checkState func(*testing.T, *Config) |
| 1260 | }{ |
| 1261 | { |
| 1262 | name: "nil config", |
| 1263 | cfg: nil, |
| 1264 | }, |
| 1265 | { |
| 1266 | name: "nil admin config", |
| 1267 | cfg: &Config{ |
| 1268 | Admin: nil, |
| 1269 | }, |
| 1270 | }, |
| 1271 | { |
| 1272 | name: "nil identity config", |
| 1273 | cfg: &Config{ |
| 1274 | Admin: &AdminConfig{}, |
| 1275 | }, |
| 1276 | }, |
| 1277 | { |
| 1278 | name: "default issuer when none specified", |
| 1279 | cfg: &Config{ |
| 1280 | Admin: &AdminConfig{ |
| 1281 | Identity: &IdentityConfig{ |
| 1282 | Identifiers: []string{"localhost"}, |
| 1283 | }, |
| 1284 | }, |
| 1285 | storage: &testStorage, |
| 1286 | }, |
| 1287 | checkState: func(t *testing.T, cfg *Config) { |
| 1288 | if len(cfg.Admin.Identity.issuers) == 0 { |
| 1289 | t.Error("Expected at least 1 issuer to be configured") |
| 1290 | return |
| 1291 | } |
| 1292 | if _, ok := cfg.Admin.Identity.issuers[0].(*mockIssuerModule); !ok { |
| 1293 | t.Error("Expected mock issuer to be configured") |
| 1294 | } |
| 1295 | }, |
| 1296 | }, |
| 1297 | { |
| 1298 | name: "custom issuer", |
| 1299 | cfg: &Config{ |
| 1300 | Admin: &AdminConfig{ |
| 1301 | Identity: &IdentityConfig{ |
| 1302 | Identifiers: []string{"localhost"}, |
| 1303 | IssuersRaw: []json.RawMessage{ |
| 1304 | json.RawMessage(`{"module": "acme"}`), |
| 1305 | }, |
| 1306 | }, |
| 1307 | }, |
| 1308 | storage: &testStorage, |
| 1309 | }, |
| 1310 | checkState: func(t *testing.T, cfg *Config) { |
| 1311 | if len(cfg.Admin.Identity.issuers) != 1 { |
| 1312 | t.Fatalf("Expected 1 issuer, got %d", len(cfg.Admin.Identity.issuers)) |
| 1313 | } |
| 1314 | mockIss, ok := cfg.Admin.Identity.issuers[0].(*mockIssuerModule) |
| 1315 | if !ok { |
| 1316 | t.Fatal("Expected mock issuer") |
| 1317 | } |
| 1318 | if mockIss.configSet == nil { |
| 1319 | t.Error("Issuer config was not set") |
| 1320 | } |
| 1321 | }, |
| 1322 | }, |
| 1323 | { |
| 1324 | name: "invalid issuer module", |
| 1325 | cfg: &Config{ |
| 1326 | Admin: &AdminConfig{ |
| 1327 | Identity: &IdentityConfig{ |
| 1328 | Identifiers: []string{"localhost"}, |
| 1329 | IssuersRaw: []json.RawMessage{ |
| 1330 | json.RawMessage(`{"module": "doesnt_exist"}`), |
| 1331 | }, |
| 1332 | }, |
| 1333 | }, |
| 1334 | }, |
| 1335 | wantErr: true, |
| 1336 | }, |
| 1337 | } |
| 1338 | |
| 1339 | for _, test := range tests { |
| 1340 | t.Run(test.name, func(t *testing.T) { |
| 1341 | if identityCertCache != nil { |
| 1342 | // Reset the cert cache before each test |
| 1343 | identityCertCache.Stop() |
| 1344 | identityCertCache = nil |
| 1345 | } |
| 1346 | // Ensure any cache started by manageIdentity is stopped at the end |
| 1347 | defer func() { |
| 1348 | if identityCertCache != nil { |
| 1349 | identityCertCache.Stop() |
| 1350 | identityCertCache = nil |
| 1351 | } |
| 1352 | }() |
| 1353 | |
| 1354 | ctx := Context{ |
| 1355 | Context: context.Background(), |
| 1356 | cfg: test.cfg, |
| 1357 | moduleInstances: make(map[string][]Module), |
| 1358 | } |
| 1359 | |
| 1360 | // If this test provided a FileStorage, set the package-level |
| 1361 | // testCertMagicStorageOverride so certmagicConfig will use it. |
| 1362 | if test.cfg != nil && test.cfg.storage != nil { |
| 1363 | testCertMagicStorageOverride = test.cfg.storage |
| 1364 | defer func() { testCertMagicStorageOverride = nil }() |
| 1365 | } |
| 1366 | |
| 1367 | err := manageIdentity(ctx, test.cfg) |
| 1368 | |
| 1369 | if test.wantErr { |
| 1370 | if err == nil { |
| 1371 | t.Error("Expected error but got nil") |
| 1372 | } |
| 1373 | return |
| 1374 | } |
| 1375 | if err != nil { |
| 1376 | t.Fatalf("Expected no error but got: %v", err) |
| 1377 | } |
| 1378 | |
| 1379 | if test.checkState != nil { |
| 1380 | test.checkState(t, test.cfg) |
| 1381 | } |
| 1382 | }) |
| 1383 | } |
| 1384 | } |
| 1385 | |
| 1386 | func TestUnsyncedConfigAccessCanonicalArrayIndices(t *testing.T) { |
| 1387 | rawCfg = map[string]any{ |
| 1388 | rawConfigKey: map[string]any{ |
| 1389 | "list": []any{"zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten"}, |
| 1390 | }, |
| 1391 | } |
| 1392 | |
| 1393 | tests := []struct { |
| 1394 | name string |
| 1395 | path string |
| 1396 | wantOutput string |
| 1397 | wantErr bool |
| 1398 | }{ |
| 1399 | {name: "allow zero", path: "/" + rawConfigKey + "/list/0", wantOutput: "\"zero\"\n"}, |
| 1400 | {name: "allow one", path: "/" + rawConfigKey + "/list/1", wantOutput: "\"one\"\n"}, |
| 1401 | {name: "allow ten", path: "/" + rawConfigKey + "/list/10", wantOutput: "\"ten\"\n"}, |
| 1402 | {name: "reject leading zero", path: "/" + rawConfigKey + "/list/01", wantErr: true}, |
| 1403 | {name: "reject multiple leading zeros", path: "/" + rawConfigKey + "/list/002", wantErr: true}, |
| 1404 | {name: "reject plus sign", path: "/" + rawConfigKey + "/list/+1", wantErr: true}, |
| 1405 | {name: "reject negative zero", path: "/" + rawConfigKey + "/list/-0", wantErr: true}, |
| 1406 | } |
| 1407 | |
| 1408 | for i, tc := range tests { |
| 1409 | t.Run(tc.name, func(t *testing.T) { |
| 1410 | var gotOutput bytes.Buffer |
| 1411 | err := unsyncedConfigAccess(http.MethodGet, tc.path, nil, &gotOutput) |
| 1412 | |
| 1413 | if tc.wantErr { |
| 1414 | if err == nil { |
| 1415 | t.Errorf("test %d (%s): input path %q: expected error, got nil with output %q", i, tc.name, tc.path, gotOutput.String()) |
| 1416 | } |
| 1417 | return |
| 1418 | } |
| 1419 | |
| 1420 | if err != nil { |
| 1421 | t.Errorf("test %d (%s): input path %q: expected no error with output %q, got error %v with output %q", i, tc.name, tc.path, tc.wantOutput, err, gotOutput.String()) |
| 1422 | } |
| 1423 | if gotOutput.String() != tc.wantOutput { |
| 1424 | t.Errorf("test %d (%s): input path %q: expected output %q, got %q", i, tc.name, tc.path, tc.wantOutput, gotOutput.String()) |
| 1425 | } |
| 1426 | }) |
| 1427 | } |
| 1428 | } |