[FEATURE] Validate ACLs and add network groups (#1568)

* adds validation to ACL's
* adds a new networks section that can be used as aliases in other sections (currently access_control)
This commit is contained in:
Amir Zarrinkafsh 2021-01-04 21:55:23 +11:00 committed by GitHub
parent 29a900226d
commit 9ca0e940da
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 239 additions and 106 deletions

View File

@ -244,6 +244,14 @@ access_control:
# to the user. # to the user.
default_policy: deny default_policy: deny
networks:
- name: internal
networks:
- 10.10.0.0/16
- 192.168.2.0/24
- name: VPN
networks: 10.9.0.0/16
rules: rules:
# Rules applied to everyone # Rules applied to everyone
- domain: public.example.com - domain: public.example.com
@ -253,7 +261,10 @@ access_control:
policy: one_factor policy: one_factor
# Network based rule, if not provided any network matches. # Network based rule, if not provided any network matches.
networks: networks:
- internal
- VPN
- 192.168.1.0/24 - 192.168.1.0/24
- 10.0.0.1
- domain: - domain:
- secure.example.com - secure.example.com

View File

@ -29,7 +29,7 @@ The criteria are:
* domain: domain targeted by the request. * domain: domain targeted by the request.
* resources: list of patterns that the path should match (one is sufficient). * resources: list of patterns that the path should match (one is sufficient).
* subject: the user or group of users to define the policy for. * subject: the user or group of users to define the policy for.
* networks: the network range from where should comes the request. * networks: the network addresses, ranges (CIDR notation) or groups from where the request originates.
A rule is matched when all criteria of the rule match. A rule is matched when all criteria of the rule match.
@ -88,8 +88,8 @@ username is `john` OR the group is `admins`.
## Networks ## Networks
A list of network ranges can be specified in a rule in order to apply different policies when A list of network addresses, ranges (CIDR notation) or groups can be specified in a rule in order to apply different
requests come from different networks. policies when requests originate from different networks.
The main use case is when, lets say a resource should be exposed both on the Internet and from an The main use case is when, lets say a resource should be exposed both on the Internet and from an
authenticated VPN for instance. Passing a second factor a first time to get access to the VPN and authenticated VPN for instance. Passing a second factor a first time to get access to the VPN and
@ -108,6 +108,13 @@ Here is a complete example of complex access control list that can be defined in
```yaml ```yaml
access_control: access_control:
default_policy: deny default_policy: deny
networks:
- name: internal
networks:
- 10.10.0.0/16
- 192.168.2.0/24
- name: VPN
networks: 10.9.0.0/16
rules: rules:
- domain: public.example.com - domain: public.example.com
policy: bypass policy: bypass
@ -115,7 +122,10 @@ access_control:
- domain: secure.example.com - domain: secure.example.com
policy: one_factor policy: one_factor
networks: networks:
- internal
- VPN
- 192.168.1.0/24 - 192.168.1.0/24
- 10.0.0.1
- domain: - domain:
- secure.example.com - secure.example.com

View File

@ -43,19 +43,19 @@ type Object struct {
} }
// selectMatchingSubjectRules take a set of rules and select only the rules matching the subject constraints. // selectMatchingSubjectRules take a set of rules and select only the rules matching the subject constraints.
func selectMatchingSubjectRules(rules []schema.ACLRule, subject Subject) []schema.ACLRule { func selectMatchingSubjectRules(rules []schema.ACLRule, networks []schema.ACLNetwork, subject Subject) []schema.ACLRule {
selectedRules := []schema.ACLRule{} selectedRules := []schema.ACLRule{}
for _, rule := range rules { for _, rule := range rules {
switch { switch {
case len(rule.Subjects) > 0: case len(rule.Subjects) > 0:
for _, subjectRule := range rule.Subjects { for _, subjectRule := range rule.Subjects {
if isSubjectMatching(subject, subjectRule) && isIPMatching(subject.IP, rule.Networks) { if isSubjectMatching(subject, subjectRule) && isIPMatching(subject.IP, rule.Networks, networks) {
selectedRules = append(selectedRules, rule) selectedRules = append(selectedRules, rule)
} }
} }
default: default:
if isIPMatching(subject.IP, rule.Networks) { if isIPMatching(subject.IP, rule.Networks, networks) {
selectedRules = append(selectedRules, rule) selectedRules = append(selectedRules, rule)
} }
} }
@ -76,8 +76,8 @@ func selectMatchingObjectRules(rules []schema.ACLRule, object Object) []schema.A
return selectedRules return selectedRules
} }
func selectMatchingRules(rules []schema.ACLRule, subject Subject, object Object) []schema.ACLRule { func selectMatchingRules(rules []schema.ACLRule, networks []schema.ACLNetwork, subject Subject, object Object) []schema.ACLRule {
matchingRules := selectMatchingSubjectRules(rules, subject) matchingRules := selectMatchingSubjectRules(rules, networks, subject)
return selectMatchingObjectRules(matchingRules, object) return selectMatchingObjectRules(matchingRules, object)
} }
@ -117,7 +117,7 @@ func (p *Authorizer) GetRequiredLevel(subject Subject, requestURL url.URL) Level
logging.Logger().Tracef("Check authorization of subject %s and url %s.", logging.Logger().Tracef("Check authorization of subject %s and url %s.",
subject.String(), requestURL.String()) subject.String(), requestURL.String())
matchingRules := selectMatchingRules(p.configuration.Rules, subject, Object{ matchingRules := selectMatchingRules(p.configuration.Rules, p.configuration.Networks, subject, Object{
Domain: requestURL.Hostname(), Domain: requestURL.Hostname(),
Path: requestURL.Path, Path: requestURL.Path,
}) })

View File

@ -1,5 +1,7 @@
package authorization package authorization
import "github.com/authelia/authelia/internal/configuration/schema"
// Level is the type representing an authorization level. // Level is the type representing an authorization level.
type Level int type Level int
@ -13,3 +15,14 @@ const (
// Denied denied level. // Denied denied level.
Denied Level = iota Denied Level = iota
) )
var testACLNetwork = []schema.ACLNetwork{
{
Name: []string{"localhost"},
Networks: []string{"127.0.0.1"},
},
{
Name: []string{"internal"},
Networks: []string{"10.0.0.0/8"},
},
}

View File

@ -3,33 +3,70 @@ package authorization
import ( import (
"net" "net"
"strings" "strings"
"github.com/authelia/authelia/internal/configuration/schema"
"github.com/authelia/authelia/internal/logging"
) )
func selectMatchingNetworkGroups(networks []string, aclNetworks []schema.ACLNetwork) []schema.ACLNetwork {
selectedNetworkGroups := []schema.ACLNetwork{}
for _, network := range networks {
for _, n := range aclNetworks {
for _, ng := range n.Name {
if network == ng {
selectedNetworkGroups = append(selectedNetworkGroups, n)
}
}
}
}
return selectedNetworkGroups
}
func isIPAddressOrCIDR(ip net.IP, network string) bool {
switch {
case ip.String() == network:
return true
case strings.Contains(network, "/"):
return parseCIDR(ip, network)
}
return false
}
func parseCIDR(ip net.IP, network string) bool {
_, ipNet, err := net.ParseCIDR(network)
if err != nil {
logging.Logger().Errorf("Failed to parse network %s: %s", network, err)
}
if ipNet.Contains(ip) {
return true
}
return false
}
// isIPMatching check whether user's IP is in one of the network ranges. // isIPMatching check whether user's IP is in one of the network ranges.
func isIPMatching(ip net.IP, networks []string) bool { func isIPMatching(ip net.IP, networks []string, aclNetworks []schema.ACLNetwork) bool {
// If no network is provided in the rule, we match any network // If no network is provided in the rule, we match any network
if len(networks) == 0 { if len(networks) == 0 {
return true return true
} }
matchingNetworkGroups := selectMatchingNetworkGroups(networks, aclNetworks)
for _, network := range networks { for _, network := range networks {
if !strings.Contains(network, "/") { if net.ParseIP(network) == nil && !strings.Contains(network, "/") {
if ip.String() == network { for _, n := range matchingNetworkGroups {
for _, network := range n.Networks {
if isIPAddressOrCIDR(ip, network) {
return true return true
} }
continue
} }
_, ipNet, err := net.ParseCIDR(network)
if err != nil {
// TODO(c.michaud): make sure the rule is valid at startup to
// to such a case here.
continue
} }
} else if isIPAddressOrCIDR(ip, network) {
if ipNet.Contains(ip) {
return true return true
} }
} }

View File

@ -9,15 +9,28 @@ import (
func TestIPMatcher(t *testing.T) { func TestIPMatcher(t *testing.T) {
// Default policy is 'allow all ips' if no IP is defined // Default policy is 'allow all ips' if no IP is defined
assert.True(t, isIPMatching(net.ParseIP("127.0.0.1"), []string{})) assert.True(t, isIPMatching(net.ParseIP("127.0.0.1"), []string{}, testACLNetwork))
assert.True(t, isIPMatching(net.ParseIP("127.0.0.1"), []string{"127.0.0.1"})) assert.True(t, isIPMatching(net.ParseIP("127.0.0.1"), []string{"127.0.0.1"}, testACLNetwork))
assert.False(t, isIPMatching(net.ParseIP("127.1"), []string{"127.0.0.1"})) assert.False(t, isIPMatching(net.ParseIP("127.1"), []string{"127.0.0.1"}, testACLNetwork))
assert.False(t, isIPMatching(net.ParseIP("not-an-ip"), []string{"127.0.0.1"})) assert.False(t, isIPMatching(net.ParseIP("not-an-ip"), []string{"127.0.0.1"}, testACLNetwork))
assert.False(t, isIPMatching(net.ParseIP("127.0.0.1"), []string{"10.0.0.1"})) assert.False(t, isIPMatching(net.ParseIP("127.0.0.1"), []string{"10.0.0.1"}, testACLNetwork))
assert.False(t, isIPMatching(net.ParseIP("127.0.0.1"), []string{"10.0.0.0/8"})) assert.False(t, isIPMatching(net.ParseIP("127.0.0.1"), []string{"10.0.0.0/8"}, testACLNetwork))
assert.True(t, isIPMatching(net.ParseIP("10.230.5.1"), []string{"10.0.0.0/8"})) assert.True(t, isIPMatching(net.ParseIP("10.230.5.1"), []string{"10.0.0.0/8"}, testACLNetwork))
assert.True(t, isIPMatching(net.ParseIP("10.230.5.1"), []string{"192.168.0.0/24", "10.0.0.0/8"})) assert.True(t, isIPMatching(net.ParseIP("10.230.5.1"), []string{"192.168.0.0/24", "10.0.0.0/8"}, testACLNetwork))
// Test network groups
assert.True(t, isIPMatching(net.ParseIP("127.0.0.1"), []string{}, testACLNetwork))
assert.True(t, isIPMatching(net.ParseIP("127.0.0.1"), []string{"localhost"}, testACLNetwork))
assert.False(t, isIPMatching(net.ParseIP("127.1"), []string{"localhost"}, testACLNetwork))
assert.False(t, isIPMatching(net.ParseIP("not-an-ip"), []string{"localhost"}, testACLNetwork))
assert.False(t, isIPMatching(net.ParseIP("127.0.0.1"), []string{"internal"}, testACLNetwork))
assert.False(t, isIPMatching(net.ParseIP("127.0.0.1"), []string{"internal"}, testACLNetwork))
assert.True(t, isIPMatching(net.ParseIP("10.230.5.1"), []string{"internal"}, testACLNetwork))
assert.True(t, isIPMatching(net.ParseIP("10.230.5.1"), []string{"192.168.0.0/24", "internal"}, testACLNetwork))
} }

View File

@ -1,10 +1,17 @@
package schema package schema
import ( // AccessControlConfiguration represents the configuration related to ACLs.
"fmt" type AccessControlConfiguration struct {
"net" DefaultPolicy string `mapstructure:"default_policy"`
"strings" Networks []ACLNetwork `mapstructure:"networks"`
) Rules []ACLRule `mapstructure:"rules"`
}
// ACLNetwork represents one ACL network group entry; "weak" coerces a single value into slice.
type ACLNetwork struct {
Name []string `mapstructure:"name,weak"`
Networks []string `mapstructure:"networks"`
}
// ACLRule represents one ACL rule entry; "weak" coerces a single value into slice. // ACLRule represents one ACL rule entry; "weak" coerces a single value into slice.
type ACLRule struct { type ACLRule struct {
@ -14,61 +21,3 @@ type ACLRule struct {
Networks []string `mapstructure:"networks"` Networks []string `mapstructure:"networks"`
Resources []string `mapstructure:"resources"` Resources []string `mapstructure:"resources"`
} }
// IsPolicyValid check if policy is valid.
func IsPolicyValid(policy string) bool {
return policy == denyPolicy || policy == "one_factor" || policy == "two_factor" || policy == "bypass"
}
// IsSubjectValid check if a subject is valid.
func IsSubjectValid(subject string) bool {
return subject == "" || strings.HasPrefix(subject, "user:") || strings.HasPrefix(subject, "group:")
}
// IsNetworkValid check if a network is valid.
func IsNetworkValid(network string) bool {
_, _, err := net.ParseCIDR(network)
return err == nil
}
// Validate validate an ACL Rule.
func (r *ACLRule) Validate(validator *StructValidator) {
if len(r.Domains) == 0 {
validator.Push(fmt.Errorf("Domain must be provided"))
}
if !IsPolicyValid(r.Policy) {
validator.Push(fmt.Errorf("A policy must either be 'deny', 'two_factor', 'one_factor' or 'bypass'"))
}
for i, subjectRule := range r.Subjects {
for j, subject := range subjectRule {
if !IsSubjectValid(subject) {
validator.Push(fmt.Errorf("Subject %d-%d must start with 'user:' or 'group:'", i, j))
}
}
}
for i, network := range r.Networks {
if !IsNetworkValid(network) {
validator.Push(fmt.Errorf("Network %d must be a valid CIDR", i))
}
}
}
// AccessControlConfiguration represents the configuration related to ACLs.
type AccessControlConfiguration struct {
DefaultPolicy string `mapstructure:"default_policy"`
Rules []ACLRule `mapstructure:"rules"`
}
// Validate validate the access control configuration.
func (acc *AccessControlConfiguration) Validate(validator *StructValidator) {
if acc.DefaultPolicy == "" {
acc.DefaultPolicy = denyPolicy
}
if !IsPolicyValid(acc.DefaultPolicy) {
validator.Push(fmt.Errorf("'default_policy' must either be 'deny', 'two_factor', 'one_factor' or 'bypass'"))
}
}

View File

@ -4,8 +4,6 @@ import (
"time" "time"
) )
const denyPolicy = "deny"
const argon2id = "argon2id" const argon2id = "argon2id"
// ProfileRefreshDisabled represents a value for refresh_interval that disables the check entirely. // ProfileRefreshDisabled represents a value for refresh_interval that disables the check entirely.

View File

@ -74,7 +74,7 @@ access_control:
resources: resources:
- "^/deny-all.*$" - "^/deny-all.*$"
subject: ["group:dev", "user:john"] subject: ["group:dev", "user:john"]
policy: denied policy: deny
# Rules applied to user 'harry' # Rules applied to user 'harry'
- domain: dev.example.com - domain: dev.example.com

View File

@ -74,7 +74,7 @@ access_control:
resources: resources:
- "^/deny-all.*$" - "^/deny-all.*$"
subject: ["group:dev", "user:john"] subject: ["group:dev", "user:john"]
policy: denied policy: deny
# Rules applied to user 'harry' # Rules applied to user 'harry'
- domain: dev.example.com - domain: dev.example.com

View File

@ -75,7 +75,7 @@ access_control:
resources: resources:
- "^/deny-all.*$" - "^/deny-all.*$"
subject: ["group:dev", "user:john"] subject: ["group:dev", "user:john"]
policy: denied policy: deny
# Rules applied to user 'harry' # Rules applied to user 'harry'
- domain: dev.example.com - domain: dev.example.com

View File

@ -75,7 +75,7 @@ access_control:
resources: resources:
- "^/deny-all.*$" - "^/deny-all.*$"
subject: ["group:dev", "user:john"] subject: ["group:dev", "user:john"]
policy: denied policy: deny
# Rules applied to user 'harry' # Rules applied to user 'harry'
- domain: dev.example.com - domain: dev.example.com

View File

@ -0,0 +1,89 @@
package validator
import (
"fmt"
"net"
"strings"
"github.com/authelia/authelia/internal/configuration/schema"
"github.com/authelia/authelia/internal/utils"
)
// IsPolicyValid check if policy is valid.
func IsPolicyValid(policy string) bool {
return policy == denyPolicy || policy == "one_factor" || policy == "two_factor" || policy == "bypass"
}
// IsSubjectValid check if a subject is valid.
func IsSubjectValid(subject string) bool {
return subject == "" || strings.HasPrefix(subject, "user:") || strings.HasPrefix(subject, "group:")
}
// IsNetworkGroupValid check if a network group is valid.
func IsNetworkGroupValid(configuration schema.AccessControlConfiguration, network string) bool {
for _, networks := range configuration.Networks {
if !utils.IsStringInSlice(network, networks.Name) {
continue
} else {
return true
}
}
return false
}
// IsNetworkValid check if a network is valid.
func IsNetworkValid(network string) bool {
if net.ParseIP(network) == nil {
_, _, err := net.ParseCIDR(network)
return err == nil
}
return true
}
// ValidateAccessControl validates access control configuration.
func ValidateAccessControl(configuration schema.AccessControlConfiguration, validator *schema.StructValidator) {
if !IsPolicyValid(configuration.DefaultPolicy) {
validator.Push(fmt.Errorf("'default_policy' must either be 'deny', 'two_factor', 'one_factor' or 'bypass'"))
}
if configuration.Networks != nil {
for _, n := range configuration.Networks {
for _, networks := range n.Networks {
if !IsNetworkValid(networks) {
validator.Push(fmt.Errorf("Network %s from group %s must be a valid IP or CIDR", networks, n.Name))
}
}
}
}
}
// ValidateRules validates an ACL Rule configuration.
func ValidateRules(configuration schema.AccessControlConfiguration, validator *schema.StructValidator) {
for _, r := range configuration.Rules {
if len(r.Domains) == 0 {
validator.Push(fmt.Errorf("No access control rules have been defined"))
}
if !IsPolicyValid(r.Policy) {
validator.Push(fmt.Errorf("Policy for domain: %s is invalid, a policy must either be 'deny', 'two_factor', 'one_factor' or 'bypass'", r.Domains))
}
for i, subjectRule := range r.Subjects {
for j, subject := range subjectRule {
if !IsSubjectValid(subject) {
validator.Push(fmt.Errorf("Subject %d-%d must start with 'user:' or 'group:'", i, j))
}
}
}
for _, network := range r.Networks {
if !IsNetworkValid(network) {
if !IsNetworkGroupValid(configuration, network) {
validator.Push(fmt.Errorf("Network %s is not a valid network or network group", network))
}
}
}
}
}

View File

@ -64,6 +64,12 @@ func ValidateConfiguration(configuration *schema.Configuration, validator *schem
configuration.AccessControl.DefaultPolicy = "deny" configuration.AccessControl.DefaultPolicy = "deny"
} }
ValidateAccessControl(configuration.AccessControl, validator)
if configuration.AccessControl.Rules != nil {
ValidateRules(configuration.AccessControl, validator)
}
ValidateSession(&configuration.Session, validator) ValidateSession(&configuration.Session, validator)
if configuration.Regulation == nil { if configuration.Regulation == nil {

View File

@ -26,6 +26,7 @@ var validKeys = []string{
// Access Control Keys. // Access Control Keys.
"access_control.rules", "access_control.rules",
"access_control.default_policy", "access_control.default_policy",
"access_control.networks",
// Session Keys. // Session Keys.
"session.name", "session.name",
@ -167,6 +168,8 @@ var specificErrorKeys = map[string]string{
"authentication_backend.file.hashing.parallelism": "config key incorrect: authentication_backend.file.hashing should be authentication_backend.file.password", "authentication_backend.file.hashing.parallelism": "config key incorrect: authentication_backend.file.hashing should be authentication_backend.file.password",
} }
const denyPolicy = "deny"
const argon2id = "argon2id" const argon2id = "argon2id"
const sha512 = "sha512" const sha512 = "sha512"

View File

@ -32,6 +32,11 @@ storage:
# resources. # resources.
access_control: access_control:
default_policy: deny default_policy: deny
networks:
- name: Clients
networks:
- 192.168.240.202/32
- 192.168.240.203/32
rules: rules:
- domain: secure.example.com - domain: secure.example.com
policy: one_factor policy: one_factor
@ -41,8 +46,7 @@ access_control:
- domain: secure.example.com - domain: secure.example.com
policy: bypass policy: bypass
networks: networks:
- 192.168.240.202/32 - Clients
- 192.168.240.203/32
- domain: secure.example.com - domain: secure.example.com
policy: two_factor policy: two_factor