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