1
0
mirror of https://github.com/oauth2-proxy/oauth2-proxy.git synced 2024-11-24 08:52:25 +02:00

Add the allowed_email_domains and the allowed_groups on the auth_request endpoint + support standard wildcard char for validation with sub-domain and email-domain.

Signed-off-by: Valentin Pichard <github@w3st.fr>
This commit is contained in:
Valentin Pichard 2021-07-28 10:12:00 +02:00 committed by Valentin Pichard
parent c5a98c6d03
commit 2b4c8a9846
11 changed files with 305 additions and 90 deletions

View File

@ -13,6 +13,7 @@
- [#1509](https://github.com/oauth2-proxy/oauth2-proxy/pull/1509) Update LoginGovProvider ValidateSession to pass access_token in Header (@pksheldon4)
- [#1474](https://github.com/oauth2-proxy/oauth2-proxy/pull/1474) Support configuration of minimal acceptable TLS version (@polarctos)
- [#1545](https://github.com/oauth2-proxy/oauth2-proxy/pull/1545) Fix issue with query string allowed group panic on skip methods (@andytson)
- [#1286](https://github.com/oauth2-proxy/oauth2-proxy/pull/1286) Add the `allowed_email_domains` and the `allowed_groups` on the `auth_request` + support standard wildcard char for validation with sub-domain and email-domain. (@w3st3ry @armandpicard)
# V7.2.1

View File

@ -197,12 +197,12 @@ An example [oauth2-proxy.cfg](https://github.com/oauth2-proxy/oauth2-proxy/blob/
| `--allowed-role` | string \| list | restrict logins to users with this role (may be given multiple times). Only works with the keycloak-oidc provider. | |
| `--validate-url` | string | Access token validation endpoint | |
| `--version` | n/a | print version string | |
| `--whitelist-domain` | string \| list | allowed domains for redirection after authentication. Prefix domain with a `.` to allow subdomains (e.g. `.example.com`)&nbsp;\[[2](#footnote2)\] | |
| `--whitelist-domain` | string \| list | allowed domains for redirection after authentication. Prefix domain with a `.` or a `*.` to allow subdomains (e.g. `.example.com`, `*.example.com`)&nbsp;\[[2](#footnote2)\] | |
| `--trusted-ip` | string \| list | list of IPs or CIDR ranges to allow to bypass authentication (may be given multiple times). When combined with `--reverse-proxy` and optionally `--real-client-ip-header` this will evaluate the trust of the IP stored in an HTTP header by a reverse proxy rather than the layer-3/4 remote address. WARNING: trusting IPs has inherent security flaws, especially when obtaining the IP address from an HTTP header (reverse-proxy mode). Use this option only if you understand the risks and how to manage them. | |
\[<a name="footnote1">1</a>\]: Only these providers support `--cookie-refresh`: GitLab, Google and OIDC
\[<a name="footnote2">2</a>\]: When using the `whitelist-domain` option, any domain prefixed with a `.` will allow any subdomain of the specified domain as a valid redirect URL. By default, only empty ports are allowed. This translates to allowing the default port of the URL's protocol (80 for HTTP, 443 for HTTPS, etc.) since browsers omit them. To allow only a specific port, add it to the whitelisted domain: `example.com:8080`. To allow any port, use `*`: `example.com:*`.
\[<a name="footnote2">2</a>\]: When using the `whitelist-domain` option, any domain prefixed with a `.` or a `*.` will allow any subdomain of the specified domain as a valid redirect URL. By default, only empty ports are allowed. This translates to allowing the default port of the URL's protocol (80 for HTTP, 443 for HTTPS, etc.) since browsers omit them. To allow only a specific port, add it to the whitelisted domain: `example.com:8080`. To allow any port, use `*`: `example.com:*`.
See below for provider specific options

View File

@ -34,3 +34,11 @@ X-Auth-Request-Redirect: https://my-oidc-provider/sign_out_page
(The "sign_out_page" should be the [`end_session_endpoint`](https://openid.net/specs/openid-connect-session-1_0.html#rfc.section.2.1) from [the metadata](https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig) if your OIDC provider supports Session Management and Discovery.)
BEWARE that the domain you want to redirect to (`my-oidc-provider.example.com` in the example) must be added to the [`--whitelist-domain`](../configuration/overview) configuration option otherwise the redirect will be ignored.
### Auth
This endpoint returns 202 Accepted response or a 401 Unauthorized response.
It can be configured using the following query parameters query parameters:
- `allowed_groups`: comma separated list of allowed groups
- `allowed_email_domains`: comma separated list of allowed email domains

View File

@ -26,6 +26,7 @@ import (
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/authentication/basic"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/cookies"
proxyhttp "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/http"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/util"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/ip"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger"
@ -971,28 +972,72 @@ func (p *OAuthProxy) getAuthenticatedSession(rw http.ResponseWriter, req *http.R
// authOnlyAuthorize handles special authorization logic that is only done
// on the AuthOnly endpoint for use with Nginx subrequest architectures.
//
// TODO (@NickMeves): This method is a placeholder to be extended but currently
// fails the linter. Remove the nolint when functionality expands.
//
//nolint:gosimple
func authOnlyAuthorize(req *http.Request, s *sessionsapi.SessionState) bool {
// Allow requests previously allowed to be bypassed
if s == nil {
return true
}
// Allow secondary group restrictions based on the `allowed_groups`
// querystring parameter
if !checkAllowedGroups(req, s) {
return false
constraints := []func(*http.Request, *sessionsapi.SessionState) bool{
checkAllowedGroups,
checkAllowedEmailDomains,
}
for _, constraint := range constraints {
if !constraint(req, s) {
return false
}
}
return true
}
// extractAllowedEntities aims to extract and split allowed entities linked by a key,
// from an HTTP request query. Output is a map[string]struct{} where keys are valuable,
// the goal is to avoid time complexity O(N^2) while finding matches during membership checks.
func extractAllowedEntities(req *http.Request, key string) map[string]struct{} {
entities := map[string]struct{}{}
query := req.URL.Query()
for _, allowedEntities := range query[key] {
for _, entity := range strings.Split(allowedEntities, ",") {
if entity != "" {
entities[entity] = struct{}{}
}
}
}
return entities
}
// checkAllowedEmailDomains allow email domain restrictions based on the `allowed_email_domains`
// querystring parameter
func checkAllowedEmailDomains(req *http.Request, s *sessionsapi.SessionState) bool {
allowedEmailDomains := extractAllowedEntities(req, "allowed_email_domains")
if len(allowedEmailDomains) == 0 {
return true
}
splitEmail := strings.Split(s.Email, "@")
if len(splitEmail) != 2 {
return false
}
endpoint, _ := url.Parse("")
endpoint.Host = splitEmail[1]
allowedEmailDomainsList := []string{}
for ed := range allowedEmailDomains {
allowedEmailDomainsList = append(allowedEmailDomainsList, ed)
}
return util.IsEndpointAllowed(endpoint, allowedEmailDomainsList)
}
// checkAllowedGroups allow secondary group restrictions based on the `allowed_groups`
// querystring parameter
func checkAllowedGroups(req *http.Request, s *sessionsapi.SessionState) bool {
allowedGroups := extractAllowedGroups(req)
allowedGroups := extractAllowedEntities(req, "allowed_groups")
if len(allowedGroups) == 0 {
return true
}
@ -1006,21 +1051,6 @@ func checkAllowedGroups(req *http.Request, s *sessionsapi.SessionState) bool {
return false
}
func extractAllowedGroups(req *http.Request) map[string]struct{} {
groups := map[string]struct{}{}
query := req.URL.Query()
for _, allowedGroups := range query["allowed_groups"] {
for _, group := range strings.Split(allowedGroups, ",") {
if group != "" {
groups[group] = struct{}{}
}
}
}
return groups
}
// encodedState builds the OAuth state param out of our nonce and
// original application redirect
func encodeState(nonce string, redirect string) string {

View File

@ -2683,3 +2683,94 @@ func TestAuthOnlyAllowedGroupsWithSkipMethods(t *testing.T) {
})
}
}
func TestAuthOnlyAllowedEmailDomains(t *testing.T) {
testCases := []struct {
name string
email string
querystring string
expectedStatusCode int
}{
{
name: "NotEmailRestriction",
email: "toto@example.com",
querystring: "",
expectedStatusCode: http.StatusAccepted,
},
{
name: "UserInAllowedEmailDomain",
email: "toto@example.com",
querystring: "?allowed_email_domains=example.com",
expectedStatusCode: http.StatusAccepted,
},
{
name: "UserNotInAllowedEmailDomain",
email: "toto@example.com",
querystring: "?allowed_email_domains=a.example.com",
expectedStatusCode: http.StatusForbidden,
},
{
name: "UserInAllowedEmailDomains",
email: "toto@example.com",
querystring: "?allowed_email_domains=a.example.com,b.example.com",
expectedStatusCode: http.StatusForbidden,
},
{
name: "UserInAllowedEmailDomains",
email: "toto@example.com",
querystring: "?allowed_email_domains=a.example.com,example.com",
expectedStatusCode: http.StatusAccepted,
},
{
name: "UserInAllowedEmailDomainWildcard",
email: "toto@foo.example.com",
querystring: "?allowed_email_domains=*.example.com",
expectedStatusCode: http.StatusAccepted,
},
{
name: "UserNotInAllowedEmailDomainWildcard",
email: "toto@example.com",
querystring: "?allowed_email_domains=*.a.example.com",
expectedStatusCode: http.StatusForbidden,
},
{
name: "UserInAllowedEmailDomainsWildcard",
email: "toto@example.com",
querystring: "?allowed_email_domains=*.a.example.com,*.b.example.com",
expectedStatusCode: http.StatusForbidden,
},
{
name: "UserInAllowedEmailDomainsWildcard",
email: "toto@c.example.com",
querystring: "?allowed_email_domains=a.b.c.example.com,*.c.example.com",
expectedStatusCode: http.StatusAccepted,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
groups := []string{}
created := time.Now()
session := &sessions.SessionState{
Groups: groups,
Email: tc.email,
AccessToken: "oauth_token",
CreatedAt: &created,
}
test, err := NewAuthOnlyEndpointTest(tc.querystring, func(opts *options.Options) {})
if err != nil {
t.Fatal(err)
}
err = test.SaveSession(session)
assert.NoError(t, err)
test.proxy.ServeHTTP(test.rw, test.req)
assert.Equal(t, tc.expectedStatusCode, test.rw.Code)
})
}
}

View File

@ -126,7 +126,7 @@ func NewFlagSet() *pflag.FlagSet {
flagSet.StringSlice("extra-jwt-issuers", []string{}, "if skip-jwt-bearer-tokens is set, a list of extra JWT issuer=audience pairs (where the issuer URL has a .well-known/openid-configuration or a .well-known/jwks.json)")
flagSet.StringSlice("email-domain", []string{}, "authenticate emails with the specified domain (may be given multiple times). Use * to authenticate any email")
flagSet.StringSlice("whitelist-domain", []string{}, "allowed domains for redirection after authentication. Prefix domain with a . to allow subdomains (eg .example.com)")
flagSet.StringSlice("whitelist-domain", []string{}, "allowed domains for redirection after authentication. Prefix domain with a . or a *. to allow subdomains (eg .example.com, *.example.com)")
flagSet.String("authenticated-emails-file", "", "authenticate against emails via file (one per line)")
flagSet.String("htpasswd-file", "", "additionally authenticate against a htpasswd file. Entries must be created with \"htpasswd -B\" for bcrypt encryption")
flagSet.StringSlice("htpasswd-user-group", []string{}, "the groups to be set on sessions for htpasswd users (may be given multiple times)")

View File

@ -6,6 +6,8 @@ import (
"strings"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger"
util "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/util"
)
var (
@ -50,28 +52,9 @@ func (v *validator) IsValidRedirect(redirect string) bool {
logger.Printf("Rejecting invalid redirect %q: scheme unsupported or missing", redirect)
return false
}
redirectHostname := redirectURL.Hostname()
for _, allowedDomain := range v.allowedDomains {
allowedHost, allowedPort := splitHostPort(allowedDomain)
if allowedHost == "" {
continue
}
if redirectHostname == strings.TrimPrefix(allowedHost, ".") ||
(strings.HasPrefix(allowedHost, ".") &&
strings.HasSuffix(redirectHostname, allowedHost)) {
// the domain names match, now validate the ports
// if the whitelisted domain's port is '*', allow all ports
// if the whitelisted domain contains a specific port, only allow that port
// if the whitelisted domain doesn't contain a port at all, only allow empty redirect ports ie http and https
redirectPort := redirectURL.Port()
if allowedPort == "*" ||
allowedPort == redirectPort ||
(allowedPort == "" && redirectPort == "") {
return true
}
}
if util.IsEndpointAllowed(redirectURL, v.allowedDomains) {
return true
}
logger.Printf("Rejecting invalid redirect %q: domain / port not in whitelist", redirect)
@ -81,40 +64,3 @@ func (v *validator) IsValidRedirect(redirect string) bool {
return false
}
}
// splitHostPort separates host and port. If the port is not valid, it returns
// the entire input as host, and it doesn't check the validity of the host.
// Unlike net.SplitHostPort, but per RFC 3986, it requires ports to be numeric.
// *** taken from net/url, modified validOptionalPort() to accept ":*"
func splitHostPort(hostport string) (host, port string) {
host = hostport
colon := strings.LastIndexByte(host, ':')
if colon != -1 && validOptionalPort(host[colon:]) {
host, port = host[:colon], host[colon+1:]
}
if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
host = host[1 : len(host)-1]
}
return
}
// validOptionalPort reports whether port is either an empty string
// or matches /^:\d*$/
// *** taken from net/url, modified to accept ":*"
func validOptionalPort(port string) bool {
if port == "" || port == ":*" {
return true
}
if port[0] != ':' {
return false
}
for _, b := range port[1:] {
if b < '0' || b > '9' {
return false
}
}
return true
}

View File

@ -5,6 +5,7 @@ import (
"net/url"
"os"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/util"
. "github.com/onsi/ginkgo"
. "github.com/onsi/ginkgo/extensions/table"
. "github.com/onsi/gomega"
@ -22,6 +23,10 @@ var _ = Describe("Validator suite", func() {
"anyport.bar:*",
".sub.anyport.bar:*",
"www.whitelisteddomain.tld",
"*.wildcard.sub.port.bar:8080",
"*.wildcard.sub.anyport.bar:*",
"*.wildcard.bar",
"*.wildcard.proxy.foo.bar",
}
})
@ -96,7 +101,20 @@ var _ = Describe("Validator suite", func() {
Entry("Quad Tab 2", "/\t\t\\\t\t/evil.com", false),
Entry("Relative Path", "/./\\evil.com", false),
Entry("Relative Subpath", "/./../../\\evil.com", false),
Entry("Partial Subdomain", "evilbar.foo", false),
Entry("Valid HTTP Wildcard Subdomain", "http://foo.wildcard.bar/redirect", true),
Entry("Valid HTTPS Wildcard Subdomain", "https://foo.wildcard.bar/redirect", true),
Entry("Valid HTTP Wildcard Subdomain Root", "http://wildcard.bar/redirect", true),
Entry("Valid HTTPS Wildcard Subdomain Root", "https://wildcard.bar/redirect", true),
Entry("Valid HTTP Wildcard Subdomain anyport", "http://foo.wildcard.sub.anyport.bar:4242/redirect", true),
Entry("Valid HTTPS Wildcard Subdomain anyport", "https://foo.wildcard.sub.anyport.bar:4242/redirect", true),
Entry("Valid HTTP Wildcard Subdomain Anyport Root", "http://wildcard.sub.anyport.bar:4242/redirect", true),
Entry("Valid HTTPS Wildcard Subdomain Anyport Root", "https://wildcard.sub.anyport.bar:4242/redirect", true),
Entry("Valid HTTP Wildcard Subdomain Defined Port", "http://foo.wildcard.sub.port.bar:8080/redirect", true),
Entry("Valid HTTPS Wildcard Subdomain Defined Port", "https://foo.wildcard.sub.port.bar:8080/redirect", true),
Entry("Valid HTTP Wildcard Subdomain Defined Port Root", "http://wildcard.sub.port.bar:8080/redirect", true),
Entry("Valid HTTPS Wildcard Subdomain Defined Port Root", "https://wildcard.sub.port.bar:8080/redirect", true),
Entry("Missing Protocol Root Domain", "foo.bar/redirect", false),
Entry("Missing Protocol Wildcard Subdomain", "proxy.wildcard.bar/redirect", false),
)
})
@ -109,7 +127,7 @@ var _ = Describe("Validator suite", func() {
DescribeTable("Should split the host and port",
func(in splitHostPortTableInput) {
host, port := splitHostPort(in.hostport)
host, port := util.SplitHostPort(in.hostport)
Expect(host).To(Equal(in.expectedHost))
Expect(port).To(Equal(in.expectedPort))
},

View File

@ -9,6 +9,8 @@ import (
"io/ioutil"
"math/big"
"net"
"net/url"
"strings"
"time"
)
@ -66,3 +68,84 @@ func GenerateCert() ([]byte, []byte, error) {
certBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
return certBytes, keyBytes, err
}
// SplitHostPort separates host and port. If the port is not valid, it returns
// the entire input as host, and it doesn't check the validity of the host.
// Unlike net.SplitHostPort, but per RFC 3986, it requires ports to be numeric.
// *** taken from net/url, modified validOptionalPort() to accept ":*"
func SplitHostPort(hostport string) (host, port string) {
host = hostport
colon := strings.LastIndexByte(host, ':')
if colon != -1 && validOptionalPort(host[colon:]) {
host, port = host[:colon], host[colon+1:]
}
if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
host = host[1 : len(host)-1]
}
return
}
// validOptionalPort reports whether port is either an empty string
// or matches /^:\d*$/
// *** taken from net/url, modified to accept ":*"
func validOptionalPort(port string) bool {
if port == "" || port == ":*" {
return true
}
if port[0] != ':' {
return false
}
for _, b := range port[1:] {
if b < '0' || b > '9' {
return false
}
}
return true
}
// IsEndpointAllowed checks whether the endpoint URL is allowed based
// on an allowed domains list.
func IsEndpointAllowed(endpoint *url.URL, allowedDomains []string) bool {
hostname := endpoint.Hostname()
for _, allowedDomain := range allowedDomains {
allowedHost, allowedPort := SplitHostPort(allowedDomain)
if allowedHost == "" {
continue
}
if isHostnameAllowed(hostname, allowedHost) {
// the domain names match, now validate the ports
// if the allowed domain's port is '*', allow all ports
// if the allowed domain contains a specific port, only allow that port
// if the allowed domain doesn't contain a port at all, only allow empty redirect ports ie http and https
redirectPort := endpoint.Port()
if allowedPort == "*" ||
allowedPort == redirectPort ||
(allowedPort == "" && redirectPort == "") {
return true
}
}
}
return false
}
func isHostnameAllowed(hostname, allowedHost string) bool {
// check if we have a perfect match between hostname and allowedHost
if hostname == strings.TrimPrefix(allowedHost, ".") ||
hostname == strings.TrimPrefix(allowedHost, "*.") {
return true
}
// check if hostname is a sub domain of the allowedHost
if (strings.HasPrefix(allowedHost, ".") && strings.HasSuffix(hostname, allowedHost)) ||
(strings.HasPrefix(allowedHost, "*.") && strings.HasSuffix(hostname, allowedHost[1:])) {
return true
}
return false
}

View File

@ -115,12 +115,15 @@ func isEmailValidWithDomains(email string, allowedDomains []string) bool {
return true
}
// allow if the domain is prefixed with . and
// allow if the domain is prefixed with . or *. and
// the last element (split on @) has the suffix as the domain
atoms := strings.Split(email, "@")
if strings.HasPrefix(domain, ".") && strings.HasSuffix(atoms[len(atoms)-1], domain) {
if (strings.HasPrefix(domain, ".") && strings.HasSuffix(atoms[len(atoms)-1], domain)) ||
(strings.HasPrefix(domain, "*.") && strings.HasSuffix(atoms[len(atoms)-1], domain[1:])) {
return true
}
}
return false
}

View File

@ -153,6 +153,13 @@ func TestValidatorCases(t *testing.T) {
email: "foo.bar@example0.com",
expectedAuthZ: false,
},
{
name: "EmailNotInCorrect1stSubDomainsNotInEmailsWildcard",
allowedEmails: []string{"xyzzy@example.com", "plugh@example.com"},
allowedDomains: []string{"*.example0.com", "*.example1.com"},
email: "foo.bar@example0.com",
expectedAuthZ: false,
},
{
name: "EmailInFirstDomain",
allowedEmails: []string{"xyzzy@example.com", "plugh@example.com"},
@ -160,6 +167,13 @@ func TestValidatorCases(t *testing.T) {
email: "foo@bar.example0.com",
expectedAuthZ: true,
},
{
name: "EmailInFirstDomainWildcard",
allowedEmails: []string{"xyzzy@example.com", "plugh@example.com"},
allowedDomains: []string{"*.example0.com", "*.example1.com"},
email: "foo@bar.example0.com",
expectedAuthZ: true,
},
{
name: "EmailNotInCorrect2ndSubDomainsNotInEmails",
allowedEmails: []string{"xyzzy@example.com", "plugh@example.com"},
@ -174,6 +188,13 @@ func TestValidatorCases(t *testing.T) {
email: "baz@quux.example1.com",
expectedAuthZ: true,
},
{
name: "EmailInSecondDomainWildcard",
allowedEmails: []string{"xyzzy@example.com", "plugh@example.com"},
allowedDomains: []string{"*.example0.com", "*.example1.com"},
email: "baz@quux.example1.com",
expectedAuthZ: true,
},
{
name: "EmailInFirstEmailList",
allowedEmails: []string{"xyzzy@example.com", "plugh@example.com"},
@ -181,6 +202,13 @@ func TestValidatorCases(t *testing.T) {
email: "xyzzy@example.com",
expectedAuthZ: true,
},
{
name: "EmailInFirstEmailListWildcard",
allowedEmails: []string{"xyzzy@example.com", "plugh@example.com"},
allowedDomains: []string{"*.example0.com", "*.example1.com"},
email: "xyzzy@example.com",
expectedAuthZ: true,
},
{
name: "EmailNotInDomainsNotInEmails",
allowedEmails: []string{"xyzzy@example.com", "plugh@example.com"},
@ -370,6 +398,13 @@ func TestValidatorCases(t *testing.T) {
allowedDomains: []string{"company.com"},
expectedAuthZ: false,
},
{
name: "CheckForEqualityNotSuffixWildcard",
email: "foo@evilcompany.com",
allowedEmails: []string(nil),
allowedDomains: []string{"*.company.com"},
expectedAuthZ: false,
},
}
for _, tc := range testCases {