Skip to content

Commit

Permalink
Add the allowed_email_domains and the allowed_groups on the auth_requ…
Browse files Browse the repository at this point in the history
…est endpoint + support standard wildcard char for validation with sub-domain and email-domain.

Signed-off-by: Valentin Pichard <[email protected]>
  • Loading branch information
w3st3ry committed Feb 14, 2022
1 parent c5a98c6 commit 2b4c8a9
Show file tree
Hide file tree
Showing 11 changed files with 305 additions and 90 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions docs/docs/configuration/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 8 additions & 0 deletions docs/docs/features/endpoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
80 changes: 55 additions & 25 deletions oauthproxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
Expand All @@ -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 {
Expand Down
91 changes: 91 additions & 0 deletions oauthproxy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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: "[email protected]",
querystring: "",
expectedStatusCode: http.StatusAccepted,
},
{
name: "UserInAllowedEmailDomain",
email: "[email protected]",
querystring: "?allowed_email_domains=example.com",
expectedStatusCode: http.StatusAccepted,
},
{
name: "UserNotInAllowedEmailDomain",
email: "[email protected]",
querystring: "?allowed_email_domains=a.example.com",
expectedStatusCode: http.StatusForbidden,
},
{
name: "UserInAllowedEmailDomains",
email: "[email protected]",
querystring: "?allowed_email_domains=a.example.com,b.example.com",
expectedStatusCode: http.StatusForbidden,
},
{
name: "UserInAllowedEmailDomains",
email: "[email protected]",
querystring: "?allowed_email_domains=a.example.com,example.com",
expectedStatusCode: http.StatusAccepted,
},
{
name: "UserInAllowedEmailDomainWildcard",
email: "[email protected]",
querystring: "?allowed_email_domains=*.example.com",
expectedStatusCode: http.StatusAccepted,
},
{
name: "UserNotInAllowedEmailDomainWildcard",
email: "[email protected]",
querystring: "?allowed_email_domains=*.a.example.com",
expectedStatusCode: http.StatusForbidden,
},
{
name: "UserInAllowedEmailDomainsWildcard",
email: "[email protected]",
querystring: "?allowed_email_domains=*.a.example.com,*.b.example.com",
expectedStatusCode: http.StatusForbidden,
},
{
name: "UserInAllowedEmailDomainsWildcard",
email: "[email protected]",
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)
})
}
}
2 changes: 1 addition & 1 deletion pkg/apis/options/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down
62 changes: 4 additions & 58 deletions pkg/app/redirect/validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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)
Expand All @@ -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
}
22 changes: 20 additions & 2 deletions pkg/app/redirect/validator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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",
}
})

Expand Down Expand Up @@ -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),
)
})

Expand All @@ -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))
},
Expand Down
Loading

0 comments on commit 2b4c8a9

Please sign in to comment.