@marcel91200 / caddy Mirror · read-only / commits / 2d332714
Fork 0

Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS

Mirrored from https://github.com/Slazee/caddy.git

Synced Nov 11, 12:15 UTC

admin: require path segment boundary in remote access control (#7673)

Amemoyoi committed Apr 27, 2026 at 23:43 UTC 2d332714829aa3b530424d47ae092074b09a2268
2 files changed +119 -3
admin.go
+16 -3
@@ -212,8 +212,8 @@ type AdminAccess struct {
212 // AdminPermissions specifies what kinds of requests are allowed
213 // to be made to the admin endpoint.
214 type AdminPermissions struct {
215 - // The API paths allowed. Paths are simple prefix matches.
216 - // Any subpath of the specified paths will be allowed.
215 + // The API paths allowed. A request path must either equal an
216 + // allowed path or be a subpath with a path-segment boundary.
217 Paths []string `json:"paths,omitempty"`
218
219 // The HTTP methods allowed for the given paths.
@@ -718,7 +718,7 @@ func (remote RemoteAdmin) enforceAccessControls(r *http.Request) error {
718 // verify path
719 pathFound := accessPerm.Paths == nil
720 for _, allowedPath := range accessPerm.Paths {
721 - if strings.HasPrefix(r.URL.Path, allowedPath) {
721 + if adminPathAllowed(r.URL.Path, allowedPath) {
722 pathFound = true
723 break
724 }
@@ -747,6 +747,19 @@ func (remote RemoteAdmin) enforceAccessControls(r *http.Request) error {
747 }
748 }
749
750 +func adminPathAllowed(reqPath, allowedPath string) bool {
751 + if allowedPath == "" || allowedPath == "/" {
752 + return strings.HasPrefix(reqPath, allowedPath)
753 + }
754 + if reqPath == allowedPath {
755 + return true
756 + }
757 + if strings.HasSuffix(allowedPath, "/") {
758 + return strings.HasPrefix(reqPath, allowedPath)
759 + }
760 + return strings.HasPrefix(reqPath, allowedPath+"/")
761 +}
762 +
763 func stopAdminServer(srv *http.Server) error {
764 if srv == nil {
765 return fmt.Errorf("no admin server")
admin_test.go
+103
@@ -16,8 +16,11 @@ package caddy
16
17 import (
18 "context"
19 + "crypto"
20 + "crypto/tls"
21 "crypto/x509"
22 "encoding/json"
23 + "errors"
24 "fmt"
25 "maps"
26 "net/http"
@@ -53,6 +56,13 @@ var testCfg = []byte(`{
56 }
57 `)
58
59 +type testAdminPublicKey string
60 +
61 +func (k testAdminPublicKey) Equal(x crypto.PublicKey) bool {
62 + other, ok := x.(testAdminPublicKey)
63 + return ok && k == other
64 +}
65 +
66 func TestUnsyncedConfigAccess(t *testing.T) {
67 // each test is performed in sequence, so
68 // each change builds on the previous ones;
@@ -651,6 +661,99 @@ func TestAllowedOriginsUnixSocket(t *testing.T) {
661 }
662 }
663
664 +func TestRemoteAdminAccessControlPathSegmentMatching(t *testing.T) {
665 + const authorizedKey testAdminPublicKey = "authorized"
666 + peerCert := &x509.Certificate{PublicKey: authorizedKey}
667 +
668 + tests := []struct {
669 + name string
670 + allowedPath string
671 + requestPath string
672 + wantErr bool
673 + }{
674 + {
675 + name: "exact path",
676 + allowedPath: "/pki/ca/prod",
677 + requestPath: "/pki/ca/prod",
678 + wantErr: false,
679 + },
680 + {
681 + name: "subpath",
682 + allowedPath: "/pki/ca/prod",
683 + requestPath: "/pki/ca/prod/certificates",
684 + wantErr: false,
685 + },
686 + {
687 + name: "trailing slash subpath",
688 + allowedPath: "/pki/ca/prod/",
689 + requestPath: "/pki/ca/prod/certificates",
690 + wantErr: false,
691 + },
692 + {
693 + name: "sibling with shared prefix",
694 + allowedPath: "/pki/ca/prod",
695 + requestPath: "/pki/ca/prod-backup",
696 + wantErr: true,
697 + },
698 + {
699 + name: "same segment plus digit",
700 + allowedPath: "/pki/ca/prod",
701 + requestPath: "/pki/ca/prod1",
702 + wantErr: true,
703 + },
704 + {
705 + name: "root path",
706 + allowedPath: "/",
707 + requestPath: "/pki/ca/prod",
708 + wantErr: false,
709 + },
710 + }
711 +
712 + for i, test := range tests {
713 + t.Run(test.name, func(t *testing.T) {
714 + remote := RemoteAdmin{
715 + AccessControl: []*AdminAccess{
716 + {
717 + Permissions: []AdminPermissions{
718 + {
719 + Methods: []string{http.MethodGet},
720 + Paths: []string{test.allowedPath},
721 + },
722 + },
723 + publicKeys: []crypto.PublicKey{authorizedKey},
724 + },
725 + },
726 + }
727 +
728 + req := httptest.NewRequest(http.MethodGet, "https://localhost:2021"+test.requestPath, nil)
729 + req.TLS = &tls.ConnectionState{
730 + VerifiedChains: [][]*x509.Certificate{{peerCert}},
731 + }
732 +
733 + err := remote.enforceAccessControls(req)
734 + if test.wantErr {
735 + if err == nil {
736 + t.Errorf("test %d (%s): allowed path %q, request path %q: expected forbidden error, got nil", i, test.name, test.allowedPath, test.requestPath)
737 + return
738 + }
739 + var apiErr APIError
740 + if !errors.As(err, &apiErr) {
741 + 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)
742 + return
743 + }
744 + if apiErr.HTTPStatus != http.StatusForbidden {
745 + 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)
746 + }
747 + return
748 + }
749 +
750 + if err != nil {
751 + t.Errorf("test %d (%s): allowed path %q, request path %q: expected no error, got %v", i, test.name, test.allowedPath, test.requestPath, err)
752 + }
753 + })
754 + }
755 +}
756 +
757 func TestReplaceRemoteAdminServer(t *testing.T) {
758 const testCert = `MIIDCTCCAfGgAwIBAgIUXsqJ1mY8pKlHQtI3HJ23x2eZPqwwDQYJKoZIhvcNAQEL
759 BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTIzMDEwMTAwMDAwMFoXDTI0MDEw