@marcel91200 / caddy Mirror · read-only / commits / d6637934
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, 07:15 UTC

admin: normalize request path in remote admin access-control check (defense-in-depth) (#7910)

* admin: normalize request path in remote admin access-control check Co-authored-by: atlarix-agent <agent@atlarix.dev> * admin: fix empty allowedPath regression and dead code in path normalization path.Clean("") returns ".", so cleaning allowedPath unconditionally silently broke the allow-all behavior when Paths: [""] is configured. Short-circuit the empty case before cleaning to preserve that behavior. Also remove the dead strings.HasSuffix(allowedPath, "/") branch — after path.Clean the path never has a trailing slash, so the unified reqPath == allowedPath || HasPrefix(reqPath, allowedPath+"/") form covers exact match, subpath boundary, and trailing-slash requests. Co-authored-by: atlarix-agent <agent@atlarix.dev> Co-authored-by: iabdullah215 <muhammadabdullah8040@gmail.com> * admin: validate non-canonical configured paths at provisioning path.Clean(allowedPath) silently broadens misconfigured values like // or /.. into /, which grants unintended access to all endpoints. Reject non-canonical paths during provisioning in replaceRemoteAdminServer so misconfigurations fail fast with a clear error. The path.Clean in adminPathAllowed remains as defense-in-depth but is now a safe no-op on validated inputs. Co-authored-by: atlarix-agent <agent@atlarix.dev> * admin: validate non-canonical configured paths at provisioning path.Clean(allowedPath) silently broadens misconfigured values like // or /.. into /, which grants unintended access to all endpoints. Reject non-canonical paths during provisioning in replaceRemoteAdminServer so misconfigurations fail fast with a clear error. The path.Clean in adminPathAllowed remains as defense-in-depth but is now a safe no-op on validated inputs. Co-authored-by: atlarix-agent <agent@atlarix.dev> * admin: fix TrimRight → TrimSuffix in provisioning path validation strings.TrimRight strips all trailing slashes, so a configured path like /foo// passed validation (both slashes trimmed to /foo matching path.Clean output) but was silently broadened to /foo at runtime. Use strings.TrimSuffix instead, which removes exactly one trailing slash — the only form the exemption was meant to allow (users write /pki/ca/prod/ meaning the /pki/ca/prod scope). Also update the // test case: with TrimSuffix, // is just / + one trailing slash, which is valid under the exemption. Add a new test for /foo// (double trailing slashes → wantErr: true). Co-authored-by: atlarix-agent <agent@atlarix.dev> * admin: reject non-canonical root permission path * admin: preserve trailing-slash permission semantics --------- Co-authored-by: atlarix-agent <agent@atlarix.dev> Co-authored-by: iabdullah215 <muhammadabdullah8040@gmail.com> Co-authored-by: Zen Dodd <mail@steadytao.com>

Amariah Abishai committed Aug 25, 2026 at 21:12 UTC d6637934e866e34f5676210f8a409b139098f954
2 files changed +165 -5
admin.go
+21 -5
@@ -554,6 +554,18 @@ func replaceRemoteAdminServer(ctx Context, cfg *Config) error {
554 accessControl.publicKeys = append(accessControl.publicKeys, cert.PublicKey)
555 clientCertPool.AddCert(cert)
556 }
557 + for j, perm := range accessControl.Permissions {
558 + for _, permPath := range perm.Paths {
559 + if permPath == "" || permPath == "/" {
560 + continue
561 + }
562 + cleanPath := path.Clean(permPath)
563 + hasCanonicalTrailingSlash := cleanPath != "/" && strings.TrimSuffix(permPath, "/") == cleanPath
564 + if cleanPath != permPath && !hasCanonicalTrailingSlash {
565 + return fmt.Errorf("access control %d permission %d: path %q is not canonical (did you mean %q?)", i, j, permPath, cleanPath)
566 + }
567 + }
568 + }
569 }
570
571 // create TLS config that will enforce mutual authentication
@@ -723,14 +735,18 @@ func (remote RemoteAdmin) enforceAccessControls(r *http.Request) error {
735 }
736
737 func adminPathAllowed(reqPath, allowedPath string) bool {
726 - if allowedPath == "" || allowedPath == "/" {
727 - return strings.HasPrefix(reqPath, allowedPath)
738 + reqPathHadTrailingSlash := strings.HasSuffix(reqPath, "/")
739 + reqPath = path.Clean(reqPath)
740 + if allowedPath == "" {
741 + return true
742 }
729 - if reqPath == allowedPath {
743 + allowedPathHadTrailingSlash := allowedPath != "/" && strings.HasSuffix(allowedPath, "/")
744 + allowedPath = path.Clean(allowedPath)
745 + if allowedPath == "/" {
746 return true
747 }
732 - if strings.HasSuffix(allowedPath, "/") {
733 - return strings.HasPrefix(reqPath, allowedPath)
748 + if reqPath == allowedPath {
749 + return !allowedPathHadTrailingSlash || reqPathHadTrailingSlash
750 }
751 return strings.HasPrefix(reqPath, allowedPath+"/")
752 }
admin_test.go
+144
@@ -684,6 +684,18 @@ func TestRemoteAdminAccessControlPathSegmentMatching(t *testing.T) {
684 requestPath: "/pki/ca/prod/certificates",
685 wantErr: false,
686 },
687 + {
688 + name: "trailing slash scope excludes exact path without slash",
689 + allowedPath: "/pki/ca/prod/",
690 + requestPath: "/pki/ca/prod",
691 + wantErr: true,
692 + },
693 + {
694 + name: "trailing slash scope includes exact path with slash",
695 + allowedPath: "/pki/ca/prod/",
696 + requestPath: "/pki/ca/prod/",
697 + wantErr: false,
698 + },
699 {
700 name: "sibling with shared prefix",
701 allowedPath: "/pki/ca/prod",
@@ -702,6 +714,48 @@ func TestRemoteAdminAccessControlPathSegmentMatching(t *testing.T) {
714 requestPath: "/pki/ca/prod",
715 wantErr: false,
716 },
717 + {
718 + name: "empty path preserves allow all",
719 + allowedPath: "",
720 + requestPath: "/pki/ca/prod",
721 + wantErr: false,
722 + },
723 + {
724 + name: "dot-dot traversal out of scope",
725 + allowedPath: "/pki/ca/prod",
726 + requestPath: "/pki/ca/prod/../../../../load",
727 + wantErr: true,
728 + },
729 + {
730 + name: "dot-dot traversal out of trailing slash scope",
731 + allowedPath: "/pki/ca/prod/",
732 + requestPath: "/pki/ca/prod/../../../../load",
733 + wantErr: true,
734 + },
735 + {
736 + name: "encoded traversal (net/url decodes %2e to . before path.Clean resolves ..)",
737 + allowedPath: "/pki/ca/prod",
738 + requestPath: "/pki/ca/prod/%2e%2e/load",
739 + wantErr: true,
740 + },
741 + {
742 + name: "sibling traversal",
743 + allowedPath: "/pki/ca/prod",
744 + requestPath: "/pki/ca/prod/../staging",
745 + wantErr: true,
746 + },
747 + {
748 + name: "collapsed slashes",
749 + allowedPath: "/pki/ca/prod",
750 + requestPath: "/pki//ca/prod",
751 + wantErr: false,
752 + },
753 + {
754 + name: "exact request with trailing slash",
755 + allowedPath: "/pki/ca/prod",
756 + requestPath: "/pki/ca/prod/",
757 + wantErr: false,
758 + },
759 }
760
761 for i, test := range tests {
@@ -839,6 +893,96 @@ vq+SH04xKhtFudVBAQ==`
893 },
894 wantErr: true,
895 },
896 + {
897 + name: "non-canonical root path with trailing slash",
898 + cfg: &Config{
899 + Admin: &AdminConfig{
900 + Identity: &IdentityConfig{},
901 + Remote: &RemoteAdmin{
902 + Listen: "localhost:2021",
903 + AccessControl: []*AdminAccess{
904 + {
905 + PublicKeys: []string{testCert},
906 + Permissions: []AdminPermissions{{Methods: []string{"GET"}, Paths: []string{"//"}}},
907 + },
908 + },
909 + },
910 + },
911 + },
912 + wantErr: true,
913 + },
914 + {
915 + name: "canonical subpath with trailing slash",
916 + cfg: &Config{
917 + Admin: &AdminConfig{
918 + Identity: &IdentityConfig{},
919 + Remote: &RemoteAdmin{
920 + Listen: "localhost:2021",
921 + AccessControl: []*AdminAccess{
922 + {
923 + PublicKeys: []string{testCert},
924 + Permissions: []AdminPermissions{{Methods: []string{"GET"}, Paths: []string{"/foo/"}}},
925 + },
926 + },
927 + },
928 + },
929 + },
930 + wantErr: false,
931 + },
932 + {
933 + name: "non-canonical path /..",
934 + cfg: &Config{
935 + Admin: &AdminConfig{
936 + Identity: &IdentityConfig{},
937 + Remote: &RemoteAdmin{
938 + Listen: "localhost:2021",
939 + AccessControl: []*AdminAccess{
940 + {
941 + PublicKeys: []string{testCert},
942 + Permissions: []AdminPermissions{{Methods: []string{"GET"}, Paths: []string{"/.."}}},
943 + },
944 + },
945 + },
946 + },
947 + },
948 + wantErr: true,
949 + },
950 + {
951 + name: "non-canonical path with double slashes",
952 + cfg: &Config{
953 + Admin: &AdminConfig{
954 + Identity: &IdentityConfig{},
955 + Remote: &RemoteAdmin{
956 + Listen: "localhost:2021",
957 + AccessControl: []*AdminAccess{
958 + {
959 + PublicKeys: []string{testCert},
960 + Permissions: []AdminPermissions{{Methods: []string{"GET"}, Paths: []string{"/foo//bar"}}},
961 + },
962 + },
963 + },
964 + },
965 + },
966 + wantErr: true,
967 + },
968 + {
969 + name: "non-canonical path with double trailing slashes",
970 + cfg: &Config{
971 + Admin: &AdminConfig{
972 + Identity: &IdentityConfig{},
973 + Remote: &RemoteAdmin{
974 + Listen: "localhost:2021",
975 + AccessControl: []*AdminAccess{
976 + {
977 + PublicKeys: []string{testCert},
978 + Permissions: []AdminPermissions{{Methods: []string{"GET"}, Paths: []string{"/foo//"}}},
979 + },
980 + },
981 + },
982 + },
983 + },
984 + wantErr: true,
985 + },
986 }
987
988 for _, test := range tests {