@marcel91200 / caddy Mirror · read-only / commits / c168dbc4
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, 01:00 UTC

admin: authorize remote requests matching any permission entry (#8317)

* 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.

D'Ali Ben Macha committed Nov 3, 2026 at 08:05 UTC c168dbc4ca788baf2de0111150a075f23355674e
2 files changed +171 -15
admin.go
+36 -15
@@ -680,6 +680,10 @@ func (ctx Context) IdentityCredentials(logger *zap.Logger) ([]tls.Certificate, e
680 // trust, and then looks for a matching, authorized public key that is allowed to access
681 // the defined path(s) using the defined method(s).
682 func (remote RemoteAdmin) enforceAccessControls(r *http.Request) error {
683 + // the same public key may appear in more than one access control entry;
684 + // the request is allowed if any entry containing the key permits it
685 + var keyRecognized, pathAllowedSomewhere bool
686 +
687 for _, chain := range r.TLS.VerifiedChains {
688 for _, peerCert := range chain {
689 for _, adminAccess := range remote.AccessControl {
@@ -692,18 +696,15 @@ func (remote RemoteAdmin) enforceAccessControls(r *http.Request) error {
696 if !ok || !comparer.Equal(allowedKey) {
697 continue
698 }
699 + keyRecognized = true
700
701 // key recognized; make sure its HTTP request is permitted
702 + // by at least one of the permission entries (if none are
703 + // configured, all permissions are granted)
704 + if len(adminAccess.Permissions) == 0 {
705 + return nil
706 + }
707 for _, accessPerm := range adminAccess.Permissions {
698 - // verify method
699 - methodFound := accessPerm.Methods == nil || slices.Contains(accessPerm.Methods, r.Method)
700 - if !methodFound {
701 - return APIError{
702 - HTTPStatus: http.StatusForbidden,
703 - Message: "not authorized to use this method",
704 - }
705 - }
706 -
708 // verify path
709 pathFound := accessPerm.Paths == nil
710 for _, allowedPath := range accessPerm.Paths {
@@ -713,20 +714,40 @@ func (remote RemoteAdmin) enforceAccessControls(r *http.Request) error {
714 }
715 }
716 if !pathFound {
716 - return APIError{
717 - HTTPStatus: http.StatusForbidden,
718 - Message: "not authorized to access this path",
719 - }
717 + continue
718 + }
719 + pathAllowedSomewhere = true
720 +
721 + // verify method
722 + if accessPerm.Methods == nil || slices.Contains(accessPerm.Methods, r.Method) {
723 + // public key authorized, method and path allowed
724 + return nil
725 }
726 }
727
723 - // public key authorized, method and path allowed
724 - return nil
728 + // this entry does not permit the request; other entries
729 + // containing the same key may
730 + break
731 }
732 }
733 }
734 }
735
736 + if keyRecognized {
737 + // no entry allows this request; if the path is allowed by
738 + // some entry, the method is what's missing
739 + if pathAllowedSomewhere {
740 + return APIError{
741 + HTTPStatus: http.StatusForbidden,
742 + Message: "not authorized to use this method",
743 + }
744 + }
745 + return APIError{
746 + HTTPStatus: http.StatusForbidden,
747 + Message: "not authorized to access this path",
748 + }
749 + }
750 +
751 // in theory, this should never happen; with an unverified chain, the TLS server
752 // should not accept the connection in the first place, and the acceptable cert
753 // pool is configured using the same list of public keys we verify against
admin_test.go
+135
@@ -805,6 +805,141 @@ func TestRemoteAdminAccessControlPathSegmentMatching(t *testing.T) {
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