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