mirror of
https://github.com/caddyserver/caddy.git
synced 2026-11-11 19:37:55 +00:00
* admin: authorize remote requests matching any permission entry
enforceAccessControls rejected a request as soon as one permission entry
did not match its method or path, so only the first entry was usable.
Allow the request if any entry permits both, and deny only after all fail.
* admin: check every access entry that holds the client key
A key listed in several access control entries was only checked
against the first one; deny only after all of them are tried.
(cherry picked from commit c168dbc4ca)
1429 lines
37 KiB
Go
1429 lines
37 KiB
Go
// Copyright 2015 Matthew Holt and The Caddy Authors
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package caddy
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"maps"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"reflect"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/caddyserver/certmagic"
|
|
"github.com/prometheus/client_golang/prometheus"
|
|
dto "github.com/prometheus/client_model/go"
|
|
"go.uber.org/zap"
|
|
"go.uber.org/zap/zaptest/observer"
|
|
)
|
|
|
|
var testCfg = []byte(`{
|
|
"apps": {
|
|
"http": {
|
|
"servers": {
|
|
"myserver": {
|
|
"listen": ["tcp/localhost:8080-8084"],
|
|
"read_timeout": "30s"
|
|
},
|
|
"yourserver": {
|
|
"listen": ["127.0.0.1:5000"],
|
|
"read_header_timeout": "15s"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`)
|
|
|
|
type testAdminPublicKey string
|
|
|
|
func (k testAdminPublicKey) Equal(x crypto.PublicKey) bool {
|
|
other, ok := x.(testAdminPublicKey)
|
|
return ok && k == other
|
|
}
|
|
|
|
func TestUnsyncedConfigAccess(t *testing.T) {
|
|
// each test is performed in sequence, so
|
|
// each change builds on the previous ones;
|
|
// the config is not reset between tests
|
|
for i, tc := range []struct {
|
|
method string
|
|
path string // rawConfigKey will be prepended
|
|
payload string
|
|
expect string // JSON representation of what the whole config is expected to be after the request
|
|
shouldErr bool
|
|
}{
|
|
{
|
|
method: "POST",
|
|
path: "",
|
|
payload: `{"foo": "bar", "list": ["a", "b", "c"]}`, // starting value
|
|
expect: `{"foo": "bar", "list": ["a", "b", "c"]}`,
|
|
},
|
|
{
|
|
method: "POST",
|
|
path: "/foo",
|
|
payload: `"jet"`,
|
|
expect: `{"foo": "jet", "list": ["a", "b", "c"]}`,
|
|
},
|
|
{
|
|
method: "POST",
|
|
path: "/bar",
|
|
payload: `{"aa": "bb", "qq": "zz"}`,
|
|
expect: `{"foo": "jet", "bar": {"aa": "bb", "qq": "zz"}, "list": ["a", "b", "c"]}`,
|
|
},
|
|
{
|
|
method: "DELETE",
|
|
path: "/bar/qq",
|
|
expect: `{"foo": "jet", "bar": {"aa": "bb"}, "list": ["a", "b", "c"]}`,
|
|
},
|
|
{
|
|
method: "DELETE",
|
|
path: "/bar/qq",
|
|
expect: `{"foo": "jet", "bar": {"aa": "bb"}, "list": ["a", "b", "c"]}`,
|
|
shouldErr: true,
|
|
},
|
|
{
|
|
method: "POST",
|
|
path: "/list",
|
|
payload: `"e"`,
|
|
expect: `{"foo": "jet", "bar": {"aa": "bb"}, "list": ["a", "b", "c", "e"]}`,
|
|
},
|
|
{
|
|
method: "PUT",
|
|
path: "/list/3",
|
|
payload: `"d"`,
|
|
expect: `{"foo": "jet", "bar": {"aa": "bb"}, "list": ["a", "b", "c", "d", "e"]}`,
|
|
},
|
|
{
|
|
method: "DELETE",
|
|
path: "/list/3",
|
|
expect: `{"foo": "jet", "bar": {"aa": "bb"}, "list": ["a", "b", "c", "e"]}`,
|
|
},
|
|
{
|
|
method: "PATCH",
|
|
path: "/list/3",
|
|
payload: `"d"`,
|
|
expect: `{"foo": "jet", "bar": {"aa": "bb"}, "list": ["a", "b", "c", "d"]}`,
|
|
},
|
|
{
|
|
method: "POST",
|
|
path: "/list/...",
|
|
payload: `["e", "f", "g"]`,
|
|
expect: `{"foo": "jet", "bar": {"aa": "bb"}, "list": ["a", "b", "c", "d", "e", "f", "g"]}`,
|
|
},
|
|
} {
|
|
err := unsyncedConfigAccess(tc.method, rawConfigKey+tc.path, []byte(tc.payload), nil)
|
|
|
|
if tc.shouldErr && err == nil {
|
|
t.Fatalf("Test %d: Expected error return value, but got: %v", i, err)
|
|
}
|
|
if !tc.shouldErr && err != nil {
|
|
t.Fatalf("Test %d: Should not have had error return value, but got: %v", i, err)
|
|
}
|
|
|
|
// decode the expected config so we can do a convenient DeepEqual
|
|
var expectedDecoded any
|
|
err = json.Unmarshal([]byte(tc.expect), &expectedDecoded)
|
|
if err != nil {
|
|
t.Fatalf("Test %d: Unmarshaling expected config: %v", i, err)
|
|
}
|
|
|
|
// make sure the resulting config is as we expect it
|
|
if !reflect.DeepEqual(rawCfg[rawConfigKey], expectedDecoded) {
|
|
t.Fatalf("Test %d:\nExpected:\n\t%#v\nActual:\n\t%#v",
|
|
i, expectedDecoded, rawCfg[rawConfigKey])
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestLoadConcurrent exercises Load under concurrent conditions
|
|
// and is most useful under test with `-race` enabled.
|
|
func TestLoadConcurrent(t *testing.T) {
|
|
var wg sync.WaitGroup
|
|
|
|
for i := 0; i < 100; i++ {
|
|
wg.Go(func() {
|
|
_ = Load(testCfg, true)
|
|
})
|
|
}
|
|
wg.Wait()
|
|
}
|
|
|
|
type fooModule struct {
|
|
IntField int
|
|
StrField string
|
|
}
|
|
|
|
func (fooModule) CaddyModule() ModuleInfo {
|
|
return ModuleInfo{
|
|
ID: "foo",
|
|
New: func() Module { return new(fooModule) },
|
|
}
|
|
}
|
|
func (fooModule) Start() error { return nil }
|
|
func (fooModule) Stop() error { return nil }
|
|
|
|
func TestETags(t *testing.T) {
|
|
RegisterModule(fooModule{})
|
|
|
|
if err := Load([]byte(`{"admin": {"listen": "localhost:2999"}, "apps": {"foo": {"strField": "abc", "intField": 0}}}`), true); err != nil {
|
|
t.Fatalf("loading: %s", err)
|
|
}
|
|
|
|
const key = "/" + rawConfigKey + "/apps/foo"
|
|
|
|
// try update the config with the wrong etag
|
|
err := changeConfig(http.MethodPost, key, []byte(`{"strField": "abc", "intField": 1}}`), fmt.Sprintf(`"/%s not_an_etag"`, rawConfigKey), false)
|
|
if apiErr, ok := err.(APIError); !ok || apiErr.HTTPStatus != http.StatusPreconditionFailed {
|
|
t.Fatalf("expected precondition failed; got %v", err)
|
|
}
|
|
|
|
// get the etag
|
|
hash := etagHasher()
|
|
if err := readConfig(key, hash); err != nil {
|
|
t.Fatalf("reading: %s", err)
|
|
}
|
|
|
|
// do the same update with the correct key
|
|
err = changeConfig(http.MethodPost, key, []byte(`{"strField": "abc", "intField": 1}`), makeEtag(key, hash), false)
|
|
if err != nil {
|
|
t.Fatalf("expected update to work; got %v", err)
|
|
}
|
|
|
|
// now try another update. The hash should no longer match and we should get precondition failed
|
|
err = changeConfig(http.MethodPost, key, []byte(`{"strField": "abc", "intField": 2}`), makeEtag(key, hash), false)
|
|
if apiErr, ok := err.(APIError); !ok || apiErr.HTTPStatus != http.StatusPreconditionFailed {
|
|
t.Fatalf("expected precondition failed; got %v", err)
|
|
}
|
|
}
|
|
|
|
func BenchmarkLoad(b *testing.B) {
|
|
for b.Loop() {
|
|
Load(testCfg, true)
|
|
}
|
|
}
|
|
|
|
func TestAdminHandlerErrorHandling(t *testing.T) {
|
|
initAdminMetrics()
|
|
|
|
handler := adminHandler{
|
|
mux: http.NewServeMux(),
|
|
}
|
|
|
|
handler.mux.Handle("/error", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
err := fmt.Errorf("test error")
|
|
handler.handleError(w, r, err)
|
|
}))
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/error", nil)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
if rr.Code == http.StatusOK {
|
|
t.Error("expected error response, got success")
|
|
}
|
|
|
|
var apiErr APIError
|
|
if err := json.NewDecoder(rr.Body).Decode(&apiErr); err != nil {
|
|
t.Fatalf("decoding response: %v", err)
|
|
}
|
|
if apiErr.Message != "test error" {
|
|
t.Errorf("expected error message 'test error', got '%s'", apiErr.Message)
|
|
}
|
|
}
|
|
|
|
func TestAdminHandlerServeHTTPRedactsSensitiveHeadersInLogs(t *testing.T) {
|
|
core, logs := observer.New(zap.InfoLevel)
|
|
|
|
defaultLoggerMu.Lock()
|
|
origLogger := defaultLogger.logger
|
|
defaultLogger.logger = zap.New(core)
|
|
defaultLoggerMu.Unlock()
|
|
t.Cleanup(func() {
|
|
defaultLoggerMu.Lock()
|
|
defaultLogger.logger = origLogger
|
|
defaultLoggerMu.Unlock()
|
|
})
|
|
|
|
handler := adminHandler{
|
|
mux: http.NewServeMux(),
|
|
}
|
|
req := httptest.NewRequest(http.MethodGet, "/redaction-test", nil)
|
|
req.Header.Set("Authorization", "Bearer secret")
|
|
req.Header.Set("Cookie", "session=secret")
|
|
req.Header.Set("X-Test", "ok")
|
|
rr := httptest.NewRecorder()
|
|
|
|
Log().Info("unrelated test log")
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
entries := logs.FilterMessage("received request").FilterField(zap.String("uri", req.RequestURI)).All()
|
|
if len(entries) != 1 {
|
|
t.Fatalf("request log entries = %d, want 1", len(entries))
|
|
}
|
|
|
|
ctx := entries[0].ContextMap()
|
|
headers, ok := ctx["headers"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected headers field in log context, got %T", ctx["headers"])
|
|
}
|
|
|
|
if got := headers["Authorization"]; !reflect.DeepEqual(got, []any{"REDACTED"}) {
|
|
t.Fatalf("expected redacted Authorization header, got %#v", got)
|
|
}
|
|
if got := headers["Cookie"]; !reflect.DeepEqual(got, []any{"REDACTED"}) {
|
|
t.Fatalf("expected redacted Cookie header, got %#v", got)
|
|
}
|
|
if got := headers["X-Test"]; !reflect.DeepEqual(got, []any{"ok"}) {
|
|
t.Fatalf("expected X-Test header to remain visible, got %#v", got)
|
|
}
|
|
}
|
|
|
|
func initAdminMetrics() {
|
|
if adminMetrics.requestErrors != nil {
|
|
prometheus.Unregister(adminMetrics.requestErrors)
|
|
}
|
|
if adminMetrics.requestCount != nil {
|
|
prometheus.Unregister(adminMetrics.requestCount)
|
|
}
|
|
|
|
adminMetrics.requestErrors = prometheus.NewCounterVec(prometheus.CounterOpts{
|
|
Namespace: "caddy",
|
|
Subsystem: "admin_http",
|
|
Name: "request_errors_total",
|
|
Help: "Number of errors that occurred handling admin endpoint requests",
|
|
}, []string{"handler", "path", "method"})
|
|
|
|
adminMetrics.requestCount = prometheus.NewCounterVec(prometheus.CounterOpts{
|
|
Namespace: "caddy",
|
|
Subsystem: "admin_http",
|
|
Name: "requests_total",
|
|
Help: "Count of requests to the admin endpoint",
|
|
}, []string{"handler", "path", "code", "method"}) // Added code and method labels
|
|
|
|
prometheus.MustRegister(adminMetrics.requestErrors)
|
|
prometheus.MustRegister(adminMetrics.requestCount)
|
|
}
|
|
|
|
func TestAdminHandlerBuiltinRouteErrors(t *testing.T) {
|
|
initAdminMetrics()
|
|
|
|
cfg := &Config{
|
|
Admin: &AdminConfig{
|
|
Listen: "localhost:2019",
|
|
},
|
|
}
|
|
|
|
// Build the admin handler directly (no listener active)
|
|
addr, err := ParseNetworkAddress("localhost:2019")
|
|
if err != nil {
|
|
t.Fatalf("Failed to parse address: %v", err)
|
|
}
|
|
handler, err := cfg.Admin.newAdminHandler(addr, false, Context{})
|
|
if err != nil {
|
|
t.Fatalf("Failed to create admin handler: %v", err)
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
path string
|
|
method string
|
|
expectedStatus int
|
|
}{
|
|
{
|
|
name: "stop endpoint wrong method",
|
|
path: "/stop",
|
|
method: http.MethodGet,
|
|
expectedStatus: http.StatusMethodNotAllowed,
|
|
},
|
|
{
|
|
name: "config endpoint wrong content-type",
|
|
path: "/config/",
|
|
method: http.MethodPost,
|
|
expectedStatus: http.StatusBadRequest,
|
|
},
|
|
{
|
|
name: "config ID missing ID",
|
|
path: "/id/",
|
|
method: http.MethodGet,
|
|
expectedStatus: http.StatusBadRequest,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
req := httptest.NewRequest(test.method, fmt.Sprintf("http://localhost:2019%s", test.path), nil)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != test.expectedStatus {
|
|
t.Errorf("expected status %d but got %d", test.expectedStatus, rr.Code)
|
|
}
|
|
|
|
metricValue := testGetMetricValue(map[string]string{
|
|
"path": test.path,
|
|
"handler": "admin",
|
|
"method": test.method,
|
|
})
|
|
if metricValue != 1 {
|
|
t.Errorf("expected error metric to be incremented once, got %v", metricValue)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func testGetMetricValue(labels map[string]string) float64 {
|
|
promLabels := prometheus.Labels{}
|
|
maps.Copy(promLabels, labels)
|
|
|
|
metric, err := adminMetrics.requestErrors.GetMetricWith(promLabels)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
|
|
pb := &dto.Metric{}
|
|
metric.Write(pb)
|
|
return pb.GetCounter().GetValue()
|
|
}
|
|
|
|
type mockRouter struct {
|
|
routes []AdminRoute
|
|
}
|
|
|
|
func (m mockRouter) Routes() []AdminRoute {
|
|
return m.routes
|
|
}
|
|
|
|
type mockModule struct {
|
|
mockRouter
|
|
}
|
|
|
|
func (m *mockModule) CaddyModule() ModuleInfo {
|
|
return ModuleInfo{
|
|
ID: "admin.api.mock",
|
|
New: func() Module {
|
|
mm := &mockModule{
|
|
mockRouter: mockRouter{
|
|
routes: m.routes,
|
|
},
|
|
}
|
|
return mm
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestNewAdminHandlerRouterRegistration(t *testing.T) {
|
|
originalModules := make(map[string]ModuleInfo)
|
|
maps.Copy(originalModules, modules)
|
|
defer func() {
|
|
modules = originalModules
|
|
}()
|
|
|
|
mockRoute := AdminRoute{
|
|
Pattern: "/mock",
|
|
Handler: AdminHandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
|
w.WriteHeader(http.StatusOK)
|
|
return nil
|
|
}),
|
|
}
|
|
|
|
mock := &mockModule{
|
|
mockRouter: mockRouter{
|
|
routes: []AdminRoute{mockRoute},
|
|
},
|
|
}
|
|
RegisterModule(mock)
|
|
|
|
addr, err := ParseNetworkAddress("localhost:2019")
|
|
if err != nil {
|
|
t.Fatalf("Failed to parse address: %v", err)
|
|
}
|
|
|
|
admin := &AdminConfig{
|
|
EnforceOrigin: false,
|
|
}
|
|
handler, err := admin.newAdminHandler(addr, false, Context{})
|
|
if err != nil {
|
|
t.Fatalf("Failed to create admin handler: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/mock", nil)
|
|
req.Host = "localhost:2019"
|
|
rr := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Errorf("Expected status code %d but got %d", http.StatusOK, rr.Code)
|
|
t.Logf("Response body: %s", rr.Body.String())
|
|
}
|
|
}
|
|
|
|
type mockProvisionableRouter struct {
|
|
mockRouter
|
|
provisionErr error
|
|
provisioned bool
|
|
}
|
|
|
|
func (m *mockProvisionableRouter) Provision(Context) error {
|
|
m.provisioned = true
|
|
return m.provisionErr
|
|
}
|
|
|
|
type mockProvisionableModule struct {
|
|
*mockProvisionableRouter
|
|
}
|
|
|
|
func (m *mockProvisionableModule) CaddyModule() ModuleInfo {
|
|
return ModuleInfo{
|
|
ID: "admin.api.mock_provision",
|
|
New: func() Module {
|
|
mm := &mockProvisionableModule{
|
|
mockProvisionableRouter: &mockProvisionableRouter{
|
|
mockRouter: m.mockRouter,
|
|
provisionErr: m.provisionErr,
|
|
},
|
|
}
|
|
return mm
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestAdminRouterProvisioning(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
provisionErr error
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "successful provisioning",
|
|
provisionErr: nil,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "provisioning error",
|
|
provisionErr: fmt.Errorf("provision failed"),
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
originalModules := make(map[string]ModuleInfo)
|
|
maps.Copy(originalModules, modules)
|
|
defer func() {
|
|
modules = originalModules
|
|
}()
|
|
|
|
mockRoute := AdminRoute{
|
|
Pattern: "/mock",
|
|
Handler: AdminHandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
|
return nil
|
|
}),
|
|
}
|
|
|
|
// Create provisionable module
|
|
mock := &mockProvisionableModule{
|
|
mockProvisionableRouter: &mockProvisionableRouter{
|
|
mockRouter: mockRouter{
|
|
routes: []AdminRoute{mockRoute},
|
|
},
|
|
provisionErr: test.provisionErr,
|
|
},
|
|
}
|
|
RegisterModule(mock)
|
|
|
|
admin := &AdminConfig{}
|
|
addr, err := ParseNetworkAddress("localhost:2019")
|
|
if err != nil {
|
|
t.Fatalf("Failed to parse address: %v", err)
|
|
}
|
|
|
|
_, err = admin.newAdminHandler(addr, false, Context{})
|
|
|
|
if test.wantErr {
|
|
if err == nil {
|
|
t.Error("Expected error but got nil")
|
|
}
|
|
} else {
|
|
if err != nil {
|
|
t.Errorf("Expected no error but got: %v", err)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAllowedOriginsUnixSocket(t *testing.T) {
|
|
// see comment in allowedOrigins() as to why we do not fill out allowed origins for UDS
|
|
tests := []struct {
|
|
name string
|
|
addr NetworkAddress
|
|
origins []string
|
|
expectOrigins []string
|
|
}{
|
|
{
|
|
name: "unix socket with default origins",
|
|
addr: NetworkAddress{
|
|
Network: "unix",
|
|
Host: "/tmp/caddy.sock",
|
|
},
|
|
origins: nil, // default origins
|
|
expectOrigins: []string{},
|
|
},
|
|
{
|
|
name: "unix socket with custom origins",
|
|
addr: NetworkAddress{
|
|
Network: "unix",
|
|
Host: "/tmp/caddy.sock",
|
|
},
|
|
origins: []string{"example.com"},
|
|
expectOrigins: []string{
|
|
"example.com",
|
|
},
|
|
},
|
|
{
|
|
name: "tcp socket on localhost gets all loopback addresses",
|
|
addr: NetworkAddress{
|
|
Network: "tcp",
|
|
Host: "localhost",
|
|
StartPort: 2019,
|
|
EndPort: 2019,
|
|
},
|
|
origins: nil,
|
|
expectOrigins: []string{
|
|
"localhost:2019",
|
|
"[::1]:2019",
|
|
"127.0.0.1:2019",
|
|
},
|
|
},
|
|
}
|
|
|
|
for i, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
admin := AdminConfig{
|
|
Origins: test.origins,
|
|
}
|
|
|
|
got := admin.allowedOrigins(test.addr)
|
|
|
|
var gotOrigins []string
|
|
for _, u := range got {
|
|
gotOrigins = append(gotOrigins, u.Host)
|
|
}
|
|
|
|
if len(gotOrigins) != len(test.expectOrigins) {
|
|
t.Errorf("%d: Expected %d origins but got %d", i, len(test.expectOrigins), len(gotOrigins))
|
|
return
|
|
}
|
|
|
|
expectMap := make(map[string]struct{})
|
|
for _, origin := range test.expectOrigins {
|
|
expectMap[origin] = struct{}{}
|
|
}
|
|
|
|
gotMap := make(map[string]struct{})
|
|
for _, origin := range gotOrigins {
|
|
gotMap[origin] = struct{}{}
|
|
}
|
|
|
|
if !reflect.DeepEqual(expectMap, gotMap) {
|
|
t.Errorf("%d: Origins mismatch.\nExpected: %v\nGot: %v", i, test.expectOrigins, gotOrigins)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRemoteAdminAccessControlPathSegmentMatching(t *testing.T) {
|
|
const authorizedKey testAdminPublicKey = "authorized"
|
|
peerCert := &x509.Certificate{PublicKey: authorizedKey}
|
|
|
|
tests := []struct {
|
|
name string
|
|
allowedPath string
|
|
requestPath string
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "exact path",
|
|
allowedPath: "/pki/ca/prod",
|
|
requestPath: "/pki/ca/prod",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "subpath",
|
|
allowedPath: "/pki/ca/prod",
|
|
requestPath: "/pki/ca/prod/certificates",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "trailing slash subpath",
|
|
allowedPath: "/pki/ca/prod/",
|
|
requestPath: "/pki/ca/prod/certificates",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "trailing slash scope excludes exact path without slash",
|
|
allowedPath: "/pki/ca/prod/",
|
|
requestPath: "/pki/ca/prod",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "trailing slash scope includes exact path with slash",
|
|
allowedPath: "/pki/ca/prod/",
|
|
requestPath: "/pki/ca/prod/",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "sibling with shared prefix",
|
|
allowedPath: "/pki/ca/prod",
|
|
requestPath: "/pki/ca/prod-backup",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "same segment plus digit",
|
|
allowedPath: "/pki/ca/prod",
|
|
requestPath: "/pki/ca/prod1",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "root path",
|
|
allowedPath: "/",
|
|
requestPath: "/pki/ca/prod",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "empty path preserves allow all",
|
|
allowedPath: "",
|
|
requestPath: "/pki/ca/prod",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "dot-dot traversal out of scope",
|
|
allowedPath: "/pki/ca/prod",
|
|
requestPath: "/pki/ca/prod/../../../../load",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "dot-dot traversal out of trailing slash scope",
|
|
allowedPath: "/pki/ca/prod/",
|
|
requestPath: "/pki/ca/prod/../../../../load",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "encoded traversal (net/url decodes %2e to . before path.Clean resolves ..)",
|
|
allowedPath: "/pki/ca/prod",
|
|
requestPath: "/pki/ca/prod/%2e%2e/load",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "sibling traversal",
|
|
allowedPath: "/pki/ca/prod",
|
|
requestPath: "/pki/ca/prod/../staging",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "collapsed slashes",
|
|
allowedPath: "/pki/ca/prod",
|
|
requestPath: "/pki//ca/prod",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "exact request with trailing slash",
|
|
allowedPath: "/pki/ca/prod",
|
|
requestPath: "/pki/ca/prod/",
|
|
wantErr: false,
|
|
},
|
|
}
|
|
|
|
for i, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
remote := RemoteAdmin{
|
|
AccessControl: []*AdminAccess{
|
|
{
|
|
Permissions: []AdminPermissions{
|
|
{
|
|
Methods: []string{http.MethodGet},
|
|
Paths: []string{test.allowedPath},
|
|
},
|
|
},
|
|
publicKeys: []crypto.PublicKey{authorizedKey},
|
|
},
|
|
},
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "https://localhost:2021"+test.requestPath, nil)
|
|
req.TLS = &tls.ConnectionState{
|
|
VerifiedChains: [][]*x509.Certificate{{peerCert}},
|
|
}
|
|
|
|
err := remote.enforceAccessControls(req)
|
|
if test.wantErr {
|
|
if err == nil {
|
|
t.Errorf("test %d (%s): allowed path %q, request path %q: expected forbidden error, got nil", i, test.name, test.allowedPath, test.requestPath)
|
|
return
|
|
}
|
|
var apiErr APIError
|
|
if !errors.As(err, &apiErr) {
|
|
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)
|
|
return
|
|
}
|
|
if apiErr.HTTPStatus != http.StatusForbidden {
|
|
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)
|
|
}
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
t.Errorf("test %d (%s): allowed path %q, request path %q: expected no error, got %v", i, test.name, test.allowedPath, test.requestPath, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRemoteAdminAccessControlMultiplePermissions(t *testing.T) {
|
|
const authorizedKey testAdminPublicKey = "authorized"
|
|
peerCert := &x509.Certificate{PublicKey: authorizedKey}
|
|
|
|
tests := []struct {
|
|
name string
|
|
permissions []AdminPermissions
|
|
// extraAccess adds further access control entries holding the same key
|
|
extraAccess [][]AdminPermissions
|
|
method string
|
|
requestPath string
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "no permissions grants all",
|
|
permissions: nil,
|
|
method: http.MethodDelete,
|
|
requestPath: "/config/",
|
|
},
|
|
{
|
|
name: "first entry matches",
|
|
permissions: []AdminPermissions{
|
|
{Methods: []string{http.MethodGet}, Paths: []string{"/config/"}},
|
|
{Methods: []string{http.MethodPost}, Paths: []string{"/load"}},
|
|
},
|
|
method: http.MethodGet,
|
|
requestPath: "/config/",
|
|
},
|
|
{
|
|
name: "second entry matches",
|
|
permissions: []AdminPermissions{
|
|
{Methods: []string{http.MethodGet}, Paths: []string{"/config/"}},
|
|
{Methods: []string{http.MethodPost}, Paths: []string{"/load"}},
|
|
},
|
|
method: http.MethodPost,
|
|
requestPath: "/load",
|
|
},
|
|
{
|
|
name: "same path different methods across entries",
|
|
permissions: []AdminPermissions{
|
|
{Methods: []string{http.MethodGet}, Paths: []string{"/config/"}},
|
|
{Methods: []string{http.MethodPatch}, Paths: []string{"/config/"}},
|
|
},
|
|
method: http.MethodPatch,
|
|
requestPath: "/config/",
|
|
},
|
|
{
|
|
name: "method and path from different entries",
|
|
permissions: []AdminPermissions{
|
|
{Methods: []string{http.MethodGet}, Paths: []string{"/config/"}},
|
|
{Methods: []string{http.MethodPost}, Paths: []string{"/load"}},
|
|
},
|
|
method: http.MethodPost,
|
|
requestPath: "/config/",
|
|
wantErr: "not authorized to use this method",
|
|
},
|
|
{
|
|
name: "path allowed by no entry",
|
|
permissions: []AdminPermissions{
|
|
{Methods: []string{http.MethodGet}, Paths: []string{"/config/"}},
|
|
{Methods: []string{http.MethodPost}, Paths: []string{"/load"}},
|
|
},
|
|
method: http.MethodGet,
|
|
requestPath: "/stop",
|
|
wantErr: "not authorized to access this path",
|
|
},
|
|
{
|
|
name: "later access entry with the same key matches",
|
|
permissions: []AdminPermissions{
|
|
{Methods: []string{http.MethodGet}, Paths: []string{"/config/"}},
|
|
},
|
|
extraAccess: [][]AdminPermissions{
|
|
{{Methods: []string{http.MethodPost}, Paths: []string{"/load"}}},
|
|
},
|
|
method: http.MethodPost,
|
|
requestPath: "/load",
|
|
},
|
|
{
|
|
name: "no access entry with the same key matches",
|
|
permissions: []AdminPermissions{
|
|
{Methods: []string{http.MethodGet}, Paths: []string{"/config/"}},
|
|
},
|
|
extraAccess: [][]AdminPermissions{
|
|
{{Methods: []string{http.MethodGet}, Paths: []string{"/load"}}},
|
|
},
|
|
method: http.MethodPost,
|
|
requestPath: "/load",
|
|
wantErr: "not authorized to use this method",
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
remote := RemoteAdmin{
|
|
AccessControl: []*AdminAccess{
|
|
{
|
|
Permissions: test.permissions,
|
|
publicKeys: []crypto.PublicKey{authorizedKey},
|
|
},
|
|
},
|
|
}
|
|
for _, permissions := range test.extraAccess {
|
|
remote.AccessControl = append(remote.AccessControl, &AdminAccess{
|
|
Permissions: permissions,
|
|
publicKeys: []crypto.PublicKey{authorizedKey},
|
|
})
|
|
}
|
|
|
|
req := httptest.NewRequest(test.method, "https://localhost:2021"+test.requestPath, nil)
|
|
req.TLS = &tls.ConnectionState{
|
|
VerifiedChains: [][]*x509.Certificate{{peerCert}},
|
|
}
|
|
|
|
err := remote.enforceAccessControls(req)
|
|
if test.wantErr == "" {
|
|
if err != nil {
|
|
t.Errorf("%s %s: expected no error, got %v", test.method, test.requestPath, err)
|
|
}
|
|
return
|
|
}
|
|
|
|
var apiErr APIError
|
|
if !errors.As(err, &apiErr) {
|
|
t.Fatalf("%s %s: expected APIError, got %T: %v", test.method, test.requestPath, err, err)
|
|
}
|
|
if apiErr.HTTPStatus != http.StatusForbidden {
|
|
t.Errorf("%s %s: expected HTTP status %d, got %d", test.method, test.requestPath, http.StatusForbidden, apiErr.HTTPStatus)
|
|
}
|
|
if apiErr.Message != test.wantErr {
|
|
t.Errorf("%s %s: expected message %q, got %q", test.method, test.requestPath, test.wantErr, apiErr.Message)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestReplaceRemoteAdminServer(t *testing.T) {
|
|
const testCert = `MIIDCTCCAfGgAwIBAgIUXsqJ1mY8pKlHQtI3HJ23x2eZPqwwDQYJKoZIhvcNAQEL
|
|
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTIzMDEwMTAwMDAwMFoXDTI0MDEw
|
|
MTAwMDAwMFowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF
|
|
AAOCAQ8AMIIBCgKCAQEA4O4S6BSoYcoxvRqI+h7yPOjF6KjntjzVVm9M+uHK4lzX
|
|
F1L3pSxJ2nDD4wZEV3FJ5yFOHVFqkG2vXG3BIczOlYG7UeNmKbQnKc5kZj3HGUrS
|
|
VGEktA4OJbeZhhWP15gcXN5eDM2eH3g9BFXVX6AURxLiUXzhNBUEZuj/OEyH9yEF
|
|
/qPCE+EjzVvWxvBXwgz/io4r4yok/Vq/bxJ6FlV6R7DX5oJSXyO0VEHZPi9DIyNU
|
|
kK3F/r4U1sWiJGWOs8i3YQWZ2ejh1C0aLFZpPcCGGgMNpoF31gyYP6ZuPDUyCXsE
|
|
g36UUw1JHNtIXYcLhnXuqj4A8TybTDpgXLqvwA9DBQIDAQABo1MwUTAdBgNVHQ4E
|
|
FgQUc13z30pFC63rr/HGKOE7E82vjXwwHwYDVR0jBBgwFoAUc13z30pFC63rr/HG
|
|
KOE7E82vjXwwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAHO3j
|
|
oeiUXXJ7xD4P8Wj5t9d+E8lE1Xv1Dk3Z+EdG5+dan+RcToE42JJp9zB7FIh5Qz8g
|
|
W77LAjqh5oyqz3A2VJcyVgfE3uJP1R1mJM7JfGHf84QH4TZF2Q1RZY4SZs0VQ6+q
|
|
5wSlIZ4NXDy4Q4XkIJBGS61wT8IzYFXYBpx4PCP1Qj0PIE4sevEGwjsBIgxK307o
|
|
BxF8AWe6N6e4YZmQLGjQ+SeH0iwZb6vpkHyAY8Kj2hvK+cq2P7vU3VGi0t3r1F8L
|
|
IvrXHCvO2BMNJ/1UK1M4YNX8LYJqQhg9hEsIROe1OE/m3VhxIYMJI+qZXk9yHfgJ
|
|
vq+SH04xKhtFudVBAQ==`
|
|
|
|
tests := []struct {
|
|
name string
|
|
cfg *Config
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "nil config",
|
|
cfg: nil,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "nil admin config",
|
|
cfg: &Config{
|
|
Admin: nil,
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "nil remote config",
|
|
cfg: &Config{
|
|
Admin: &AdminConfig{},
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "invalid listen address",
|
|
cfg: &Config{
|
|
Admin: &AdminConfig{
|
|
Remote: &RemoteAdmin{
|
|
Listen: "invalid:address",
|
|
},
|
|
},
|
|
},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "valid config",
|
|
cfg: &Config{
|
|
Admin: &AdminConfig{
|
|
Identity: &IdentityConfig{},
|
|
Remote: &RemoteAdmin{
|
|
Listen: "localhost:2021",
|
|
AccessControl: []*AdminAccess{
|
|
{
|
|
PublicKeys: []string{testCert},
|
|
Permissions: []AdminPermissions{{Methods: []string{"GET"}, Paths: []string{"/test"}}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "invalid certificate",
|
|
cfg: &Config{
|
|
Admin: &AdminConfig{
|
|
Identity: &IdentityConfig{},
|
|
Remote: &RemoteAdmin{
|
|
Listen: "localhost:2021",
|
|
AccessControl: []*AdminAccess{
|
|
{
|
|
PublicKeys: []string{"invalid-cert-data"},
|
|
Permissions: []AdminPermissions{{Methods: []string{"GET"}, Paths: []string{"/test"}}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "non-canonical root path with trailing slash",
|
|
cfg: &Config{
|
|
Admin: &AdminConfig{
|
|
Identity: &IdentityConfig{},
|
|
Remote: &RemoteAdmin{
|
|
Listen: "localhost:2021",
|
|
AccessControl: []*AdminAccess{
|
|
{
|
|
PublicKeys: []string{testCert},
|
|
Permissions: []AdminPermissions{{Methods: []string{"GET"}, Paths: []string{"//"}}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "canonical subpath with trailing slash",
|
|
cfg: &Config{
|
|
Admin: &AdminConfig{
|
|
Identity: &IdentityConfig{},
|
|
Remote: &RemoteAdmin{
|
|
Listen: "localhost:2021",
|
|
AccessControl: []*AdminAccess{
|
|
{
|
|
PublicKeys: []string{testCert},
|
|
Permissions: []AdminPermissions{{Methods: []string{"GET"}, Paths: []string{"/foo/"}}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "non-canonical path /..",
|
|
cfg: &Config{
|
|
Admin: &AdminConfig{
|
|
Identity: &IdentityConfig{},
|
|
Remote: &RemoteAdmin{
|
|
Listen: "localhost:2021",
|
|
AccessControl: []*AdminAccess{
|
|
{
|
|
PublicKeys: []string{testCert},
|
|
Permissions: []AdminPermissions{{Methods: []string{"GET"}, Paths: []string{"/.."}}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "non-canonical path with double slashes",
|
|
cfg: &Config{
|
|
Admin: &AdminConfig{
|
|
Identity: &IdentityConfig{},
|
|
Remote: &RemoteAdmin{
|
|
Listen: "localhost:2021",
|
|
AccessControl: []*AdminAccess{
|
|
{
|
|
PublicKeys: []string{testCert},
|
|
Permissions: []AdminPermissions{{Methods: []string{"GET"}, Paths: []string{"/foo//bar"}}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "non-canonical path with double trailing slashes",
|
|
cfg: &Config{
|
|
Admin: &AdminConfig{
|
|
Identity: &IdentityConfig{},
|
|
Remote: &RemoteAdmin{
|
|
Listen: "localhost:2021",
|
|
AccessControl: []*AdminAccess{
|
|
{
|
|
PublicKeys: []string{testCert},
|
|
Permissions: []AdminPermissions{{Methods: []string{"GET"}, Paths: []string{"/foo//"}}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
ctx := Context{
|
|
Context: context.Background(),
|
|
cfg: test.cfg,
|
|
}
|
|
|
|
if test.cfg != nil {
|
|
test.cfg.storage = &certmagic.FileStorage{Path: t.TempDir()}
|
|
}
|
|
|
|
if test.cfg != nil && test.cfg.Admin != nil && test.cfg.Admin.Identity != nil {
|
|
identityCertCache = certmagic.NewCache(certmagic.CacheOptions{
|
|
GetConfigForCert: func(certmagic.Certificate) (*certmagic.Config, error) {
|
|
return &certmagic.Config{}, nil
|
|
},
|
|
})
|
|
}
|
|
|
|
err := replaceRemoteAdminServer(ctx, test.cfg)
|
|
|
|
if test.wantErr {
|
|
if err == nil {
|
|
t.Error("Expected error but got nil")
|
|
}
|
|
} else {
|
|
if err != nil {
|
|
t.Errorf("Expected no error but got: %v", err)
|
|
}
|
|
}
|
|
|
|
// Clean up
|
|
if remoteAdminServer != nil {
|
|
_ = stopAdminServer(remoteAdminServer)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
type mockIssuer struct {
|
|
configSet *certmagic.Config
|
|
}
|
|
|
|
func (m *mockIssuer) Issue(ctx context.Context, csr *x509.CertificateRequest) (*certmagic.IssuedCertificate, error) {
|
|
return &certmagic.IssuedCertificate{
|
|
Certificate: []byte(csr.Raw),
|
|
}, nil
|
|
}
|
|
|
|
func (m *mockIssuer) SetConfig(cfg *certmagic.Config) {
|
|
m.configSet = cfg
|
|
}
|
|
|
|
func (m *mockIssuer) IssuerKey() string {
|
|
return "mock"
|
|
}
|
|
|
|
type mockIssuerModule struct {
|
|
*mockIssuer
|
|
}
|
|
|
|
func (m *mockIssuerModule) CaddyModule() ModuleInfo {
|
|
return ModuleInfo{
|
|
ID: "tls.issuance.acme",
|
|
New: func() Module {
|
|
return &mockIssuerModule{mockIssuer: new(mockIssuer)}
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestManageIdentity(t *testing.T) {
|
|
originalModules := make(map[string]ModuleInfo)
|
|
maps.Copy(originalModules, modules)
|
|
defer func() {
|
|
modules = originalModules
|
|
}()
|
|
|
|
RegisterModule(&mockIssuerModule{})
|
|
|
|
certPEM := []byte(`-----BEGIN CERTIFICATE-----
|
|
MIIDujCCAqKgAwIBAgIIE31FZVaPXTUwDQYJKoZIhvcNAQEFBQAwSTELMAkGA1UE
|
|
BhMCVVMxEzARBgNVBAoTCkdvb2dsZSBJbmMxJTAjBgNVBAMTHEdvb2dsZSBJbnRl
|
|
cm5ldCBBdXRob3JpdHkgRzIwHhcNMTQwMTI5MTMyNzQzWhcNMTQwNTI5MDAwMDAw
|
|
WjBpMQswCQYDVQQGEwJVUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwN
|
|
TW91bnRhaW4gVmlldzETMBEGA1UECgwKR29vZ2xlIEluYzEYMBYGA1UEAwwPbWFp
|
|
bC5nb29nbGUuY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE3lcub2pUwkjC
|
|
5GJQA2ZZfJJi6d1QHhEmkX9VxKYGp6gagZuRqJWy9TXP6++1ZzQQxqZLD0TkuxZ9
|
|
8i9Nz00000CCBjCCAQQwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMGgG
|
|
CCsGAQUFBwEBBFwwWjArBggrBgEFBQcwAoYfaHR0cDovL3BraS5nb29nbGUuY29t
|
|
L0dJQUcyLmNydDArBggrBgEFBQcwAYYfaHR0cDovL2NsaWVudHMxLmdvb2dsZS5j
|
|
b20vb2NzcDAdBgNVHQ4EFgQUiJxtimAuTfwb+aUtBn5UYKreKvMwDAYDVR0TAQH/
|
|
BAIwADAfBgNVHSMEGDAWgBRK3QYWG7z2aLV29YG2u2IaulqBLzAXBgNVHREEEDAO
|
|
ggxtYWlsLmdvb2dsZTANBgkqhkiG9w0BAQUFAAOCAQEAMP6IWgNGZE8wP9TjFjSZ
|
|
3mmW3A1eIr0CuPwNZ2LJ5ZD1i70ojzcj4I9IdP5yPg9CAEV4hNASbM1LzfC7GmJE
|
|
tPzW5tRmpKVWZGRgTgZI8Hp/xZXMwLh9ZmXV4kESFAGj5G5FNvJyUV7R5Eh+7OZX
|
|
7G4jJ4ZGJh+5jzN9HdJJHQHGYNIYOzC7+HH9UMwCjX9vhQ4RjwFZJThS2Yb+y7pb
|
|
9yxTJZoXC6J0H5JpnZb7kZEJ+Xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
|
-----END CERTIFICATE-----`)
|
|
|
|
keyPEM := []byte(`-----BEGIN PRIVATE KEY-----
|
|
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDRS0LmTwUT0iwP
|
|
...
|
|
-----END PRIVATE KEY-----`)
|
|
|
|
tmpDir, err := os.MkdirTemp("", "TestManageIdentity-")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
testStorage := certmagic.FileStorage{Path: tmpDir}
|
|
// Clean up the temp dir after the test finishes. Ensure any background
|
|
// certificate maintenance is stopped first to avoid RemoveAll races.
|
|
t.Cleanup(func() {
|
|
if identityCertCache != nil {
|
|
identityCertCache.Stop()
|
|
identityCertCache = nil
|
|
}
|
|
// Give goroutines a moment to exit and release file handles.
|
|
time.Sleep(50 * time.Millisecond)
|
|
_ = os.RemoveAll(tmpDir)
|
|
})
|
|
|
|
err = testStorage.Store(context.Background(), "localhost/localhost.crt", certPEM)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
err = testStorage.Store(context.Background(), "localhost/localhost.key", keyPEM)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
cfg *Config
|
|
wantErr bool
|
|
checkState func(*testing.T, *Config)
|
|
}{
|
|
{
|
|
name: "nil config",
|
|
cfg: nil,
|
|
},
|
|
{
|
|
name: "nil admin config",
|
|
cfg: &Config{
|
|
Admin: nil,
|
|
},
|
|
},
|
|
{
|
|
name: "nil identity config",
|
|
cfg: &Config{
|
|
Admin: &AdminConfig{},
|
|
},
|
|
},
|
|
{
|
|
name: "default issuer when none specified",
|
|
cfg: &Config{
|
|
Admin: &AdminConfig{
|
|
Identity: &IdentityConfig{
|
|
Identifiers: []string{"localhost"},
|
|
},
|
|
},
|
|
storage: &testStorage,
|
|
},
|
|
checkState: func(t *testing.T, cfg *Config) {
|
|
if len(cfg.Admin.Identity.issuers) == 0 {
|
|
t.Error("Expected at least 1 issuer to be configured")
|
|
return
|
|
}
|
|
if _, ok := cfg.Admin.Identity.issuers[0].(*mockIssuerModule); !ok {
|
|
t.Error("Expected mock issuer to be configured")
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "custom issuer",
|
|
cfg: &Config{
|
|
Admin: &AdminConfig{
|
|
Identity: &IdentityConfig{
|
|
Identifiers: []string{"localhost"},
|
|
IssuersRaw: []json.RawMessage{
|
|
json.RawMessage(`{"module": "acme"}`),
|
|
},
|
|
},
|
|
},
|
|
storage: &testStorage,
|
|
},
|
|
checkState: func(t *testing.T, cfg *Config) {
|
|
if len(cfg.Admin.Identity.issuers) != 1 {
|
|
t.Fatalf("Expected 1 issuer, got %d", len(cfg.Admin.Identity.issuers))
|
|
}
|
|
mockIss, ok := cfg.Admin.Identity.issuers[0].(*mockIssuerModule)
|
|
if !ok {
|
|
t.Fatal("Expected mock issuer")
|
|
}
|
|
if mockIss.configSet == nil {
|
|
t.Error("Issuer config was not set")
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "invalid issuer module",
|
|
cfg: &Config{
|
|
Admin: &AdminConfig{
|
|
Identity: &IdentityConfig{
|
|
Identifiers: []string{"localhost"},
|
|
IssuersRaw: []json.RawMessage{
|
|
json.RawMessage(`{"module": "doesnt_exist"}`),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
if identityCertCache != nil {
|
|
// Reset the cert cache before each test
|
|
identityCertCache.Stop()
|
|
identityCertCache = nil
|
|
}
|
|
// Ensure any cache started by manageIdentity is stopped at the end
|
|
defer func() {
|
|
if identityCertCache != nil {
|
|
identityCertCache.Stop()
|
|
identityCertCache = nil
|
|
}
|
|
}()
|
|
|
|
ctx := Context{
|
|
Context: context.Background(),
|
|
cfg: test.cfg,
|
|
moduleInstances: make(map[string][]Module),
|
|
}
|
|
|
|
// If this test provided a FileStorage, set the package-level
|
|
// testCertMagicStorageOverride so certmagicConfig will use it.
|
|
if test.cfg != nil && test.cfg.storage != nil {
|
|
testCertMagicStorageOverride = test.cfg.storage
|
|
defer func() { testCertMagicStorageOverride = nil }()
|
|
}
|
|
|
|
err := manageIdentity(ctx, test.cfg)
|
|
|
|
if test.wantErr {
|
|
if err == nil {
|
|
t.Error("Expected error but got nil")
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("Expected no error but got: %v", err)
|
|
}
|
|
|
|
if test.checkState != nil {
|
|
test.checkState(t, test.cfg)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUnsyncedConfigAccessCanonicalArrayIndices(t *testing.T) {
|
|
rawCfg = map[string]any{
|
|
rawConfigKey: map[string]any{
|
|
"list": []any{"zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten"},
|
|
},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
path string
|
|
wantOutput string
|
|
wantErr bool
|
|
}{
|
|
{name: "allow zero", path: "/" + rawConfigKey + "/list/0", wantOutput: "\"zero\"\n"},
|
|
{name: "allow one", path: "/" + rawConfigKey + "/list/1", wantOutput: "\"one\"\n"},
|
|
{name: "allow ten", path: "/" + rawConfigKey + "/list/10", wantOutput: "\"ten\"\n"},
|
|
{name: "reject leading zero", path: "/" + rawConfigKey + "/list/01", wantErr: true},
|
|
{name: "reject multiple leading zeros", path: "/" + rawConfigKey + "/list/002", wantErr: true},
|
|
{name: "reject plus sign", path: "/" + rawConfigKey + "/list/+1", wantErr: true},
|
|
{name: "reject negative zero", path: "/" + rawConfigKey + "/list/-0", wantErr: true},
|
|
}
|
|
|
|
for i, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var gotOutput bytes.Buffer
|
|
err := unsyncedConfigAccess(http.MethodGet, tc.path, nil, &gotOutput)
|
|
|
|
if tc.wantErr {
|
|
if err == nil {
|
|
t.Errorf("test %d (%s): input path %q: expected error, got nil with output %q", i, tc.name, tc.path, gotOutput.String())
|
|
}
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
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())
|
|
}
|
|
if gotOutput.String() != tc.wantOutput {
|
|
t.Errorf("test %d (%s): input path %q: expected output %q, got %q", i, tc.name, tc.path, tc.wantOutput, gotOutput.String())
|
|
}
|
|
})
|
|
}
|
|
}
|