diff --git a/CHANGELOG.md b/CHANGELOG.md index f2c8d0811..1e2ac2077 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,15 @@ - Add programmatic authentication support. [GH-177] - Add Prometheus format metrics endpoint. [GH-35] +- Add policy setting to enable self-signed certificate support. [GH-179] +- Add policy setting to skip tls certificate verification. [GH-179] ### CHANGED +- Policy `to` and `from` settings must be set to valid HTTP URLs including [schemes](https://en.wikipedia.org/wiki/Uniform_Resource_Identifier) and hostnames (e.g. `http.corp.domain.example` should now be `https://http.corp.domain.example`). - Proxy's sign out handler `{}/.pomerium/sign_out` now accepts an optional `redirect_uri` parameter which can be used to specify a custom redirect page, so long as it is under the same top-level domain. [GH-183] -- Policy configuration can now be empty at startup [GH-190] +- Policy configuration can now be empty at startup. [GH-190] +- Websocket support is now set per-route instead of globally. [GH-204] ### FIXED diff --git a/authenticate/authenticate.go b/authenticate/authenticate.go index 66b23f27f..11c5b872a 100644 --- a/authenticate/authenticate.go +++ b/authenticate/authenticate.go @@ -18,7 +18,7 @@ import ( // The checks do not modify the internal state of the Option structure. Returns // on first error found. func ValidateOptions(o config.Options) error { - if o.AuthenticateURL.Hostname() == "" { + if o.AuthenticateURL == nil { return errors.New("authenticate: 'AUTHENTICATE_SERVICE_URL' missing") } if o.ClientID == "" { @@ -35,7 +35,7 @@ func ValidateOptions(o config.Options) error { return fmt.Errorf("authenticate: 'COOKIE_SECRET' must be base64 encoded: %v", err) } if len(decodedCookieSecret) != 32 { - return fmt.Errorf("authenticate: 'COOKIE_SECRET' should be 32; got %d", len(decodedCookieSecret)) + return fmt.Errorf("authenticate: 'COOKIE_SECRET' %s be 32; got %d", o.CookieSecret, len(decodedCookieSecret)) } return nil } @@ -80,7 +80,7 @@ func New(opts config.Options) (*Authenticate, error) { provider, err := identity.New( opts.Provider, &identity.Provider{ - RedirectURL: &redirectURL, + RedirectURL: redirectURL, ProviderName: opts.Provider, ProviderURL: opts.ProviderURL, ClientID: opts.ClientID, @@ -97,7 +97,7 @@ func New(opts config.Options) (*Authenticate, error) { } return &Authenticate{ SharedKey: opts.SharedKey, - RedirectURL: &redirectURL, + RedirectURL: redirectURL, templates: templates.New(), csrfStore: cookieStore, sessionStore: cookieStore, diff --git a/authenticate/authenticate_test.go b/authenticate/authenticate_test.go index 6bc0ea182..45219b8fd 100644 --- a/authenticate/authenticate_test.go +++ b/authenticate/authenticate_test.go @@ -1,53 +1,49 @@ package authenticate import ( - "net/url" "testing" - "time" "github.com/pomerium/pomerium/internal/config" ) -func testOptions() config.Options { - redirectURL, _ := url.Parse("https://example.com/oauth2/callback") - return config.Options{ - AuthenticateURL: *redirectURL, - SharedKey: "80ldlrU2d7w+wVpKNfevk6fmb8otEx6CqOfshj2LwhQ=", - ClientID: "test-client-id", - ClientSecret: "OromP1gurwGWjQPYb1nNgSxtbVB5NnLzX6z5WOKr0Yw=", - CookieSecret: "OromP1gurwGWjQPYb1nNgSxtbVB5NnLzX6z5WOKr0Yw=", - CookieRefresh: time.Duration(1) * time.Hour, - CookieExpire: time.Duration(168) * time.Hour, - CookieName: "pomerium", +func newTestOptions(t *testing.T) *config.Options { + opts, err := config.NewOptions("https://authenticate.example", "https://authorize.example") + if err != nil { + t.Fatal(err) } + opts.ClientID = "client-id" + opts.Provider = "google" + opts.ClientSecret = "OromP1gurwGWjQPYb1nNgSxtbVB5NnLzX6z5WOKr0Yw=" + opts.CookieSecret = "OromP1gurwGWjQPYb1nNgSxtbVB5NnLzX6z5WOKr0Yw=" + return opts } func TestOptions_Validate(t *testing.T) { - good := testOptions() - badRedirectURL := testOptions() - badRedirectURL.AuthenticateURL = url.URL{} - emptyClientID := testOptions() + good := newTestOptions(t) + badRedirectURL := newTestOptions(t) + badRedirectURL.AuthenticateURL = nil + emptyClientID := newTestOptions(t) emptyClientID.ClientID = "" - emptyClientSecret := testOptions() + emptyClientSecret := newTestOptions(t) emptyClientSecret.ClientSecret = "" - emptyCookieSecret := testOptions() + emptyCookieSecret := newTestOptions(t) emptyCookieSecret.CookieSecret = "" - invalidCookieSecret := testOptions() + invalidCookieSecret := newTestOptions(t) invalidCookieSecret.CookieSecret = "OromP1gurwGWjQPYb1nNgSxtbVB5NnLzX6z5WOKr0Yw^" - shortCookieLength := testOptions() + shortCookieLength := newTestOptions(t) shortCookieLength.CookieSecret = "gN3xnvfsAwfCXxnJorGLKUG4l2wC8sS8nfLMhcStPg==" - badSharedKey := testOptions() + badSharedKey := newTestOptions(t) badSharedKey.SharedKey = "" - badAuthenticateURL := testOptions() - badAuthenticateURL.AuthenticateURL = url.URL{} + badAuthenticateURL := newTestOptions(t) + badAuthenticateURL.AuthenticateURL = nil tests := []struct { name string - o config.Options + o *config.Options wantErr bool }{ {"minimum options", good, false}, - {"nil options", config.Options{}, true}, + {"nil options", &config.Options{}, true}, {"bad redirect url", badRedirectURL, true}, {"no cookie secret", emptyCookieSecret, true}, {"invalid cookie secret", invalidCookieSecret, true}, @@ -59,8 +55,7 @@ func TestOptions_Validate(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - o := tt.o - if err := ValidateOptions(o); (err != nil) != tt.wantErr { + if err := ValidateOptions(*tt.o); (err != nil) != tt.wantErr { t.Errorf("Options.Validate() error = %v, wantErr %v", err, tt.wantErr) } }) @@ -68,25 +63,24 @@ func TestOptions_Validate(t *testing.T) { } func TestNew(t *testing.T) { - good := testOptions() - good.Provider = "google" + good := newTestOptions(t) - badRedirectURL := testOptions() - badRedirectURL.AuthenticateURL = url.URL{} + badRedirectURL := newTestOptions(t) + badRedirectURL.AuthenticateURL = nil tests := []struct { name string - opts config.Options + opts *config.Options // want *Authenticate wantErr bool }{ {"good", good, false}, - {"empty opts", config.Options{}, true}, + {"empty opts", &config.Options{}, true}, {"fails to validate", badRedirectURL, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - _, err := New(tt.opts) + _, err := New(*tt.opts) if (err != nil) != tt.wantErr { t.Errorf("New() error = %v, wantErr %v", err, tt.wantErr) return diff --git a/authenticate/handlers.go b/authenticate/handlers.go index 7289db81a..350bb14c4 100644 --- a/authenticate/handlers.go +++ b/authenticate/handlers.go @@ -309,5 +309,4 @@ func (a *Authenticate) ExchangeToken(w http.ResponseWriter, r *http.Request) { httputil.ErrorResponse(w, r, &httputil.Error{Code: http.StatusInternalServerError, Message: "authenticate: failed returning new session"}) return } - return } diff --git a/authorize/authorize.go b/authorize/authorize.go index 9e45632c8..f3fe5c5cb 100644 --- a/authorize/authorize.go +++ b/authorize/authorize.go @@ -4,11 +4,8 @@ import ( "encoding/base64" "fmt" - "github.com/pomerium/pomerium/internal/log" - "github.com/pomerium/pomerium/internal/config" - - "github.com/pomerium/pomerium/internal/policy" + "github.com/pomerium/pomerium/internal/log" ) // ValidateOptions checks to see if configuration values are valid for the @@ -21,7 +18,6 @@ func ValidateOptions(o config.Options) error { if len(decoded) != 32 { return fmt.Errorf("authorize: `SHARED_SECRET` want 32 but got %d bytes", len(decoded)) } - return nil } @@ -50,7 +46,7 @@ func New(opts config.Options) (*Authorize, error) { // NewIdentityWhitelist returns an indentity validator. // todo(bdd) : a radix-tree implementation is probably more efficient -func NewIdentityWhitelist(policies []policy.Policy, admins []string) IdentityValidator { +func NewIdentityWhitelist(policies []config.Policy, admins []string) IdentityValidator { return newIdentityWhitelistMap(policies, admins) } @@ -59,7 +55,7 @@ func (a *Authorize) ValidIdentity(route string, identity *Identity) bool { return a.identityAccess.Valid(route, identity) } -// UpdateOptions updates internal structres based on config.Options +// UpdateOptions updates internal structures based on config.Options func (a *Authorize) UpdateOptions(o config.Options) error { log.Info().Msg("authorize: updating options") a.identityAccess = NewIdentityWhitelist(o.Policies, o.Administrators) diff --git a/authorize/authorize_test.go b/authorize/authorize_test.go index 73aa34279..8e2617d98 100644 --- a/authorize/authorize_test.go +++ b/authorize/authorize_test.go @@ -4,26 +4,24 @@ import ( "testing" "github.com/pomerium/pomerium/internal/config" - "github.com/pomerium/pomerium/internal/policy" ) func TestNew(t *testing.T) { t.Parallel() - policies := testPolicies() + policies := testPolicies(t) tests := []struct { name string SharedKey string - Policies []policy.Policy + Policies []config.Policy wantErr bool }{ {"good", "gXK6ggrlIW2HyKyUF9rUO4azrDgxhDPWqw9y+lJU7B8=", policies, false}, {"bad shared secret", "AZA85podM73CjLCjViDNz1EUvvejKpWp7Hysr0knXA==", policies, true}, {"really bad shared secret", "sup", policies, true}, {"validation error, short secret", "AZA85podM73CjLCjViDNz1EUvvejKpWp7Hysr0knXA==", policies, true}, - {"empty options", "", []policy.Policy{}, true}, // special case - {"missing policies", "gXK6ggrlIW2HyKyUF9rUO4azrDgxhDPWqw9y+lJU7B8=", []policy.Policy{}, false}, // special case + {"empty options", "", []config.Policy{}, true}, // special case } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -43,10 +41,13 @@ func TestNew(t *testing.T) { } } -func testPolicies() []policy.Policy { - testPolicy := policy.Policy{From: "pomerium.io", To: "httpbin.org", AllowedEmails: []string{"test@gmail.com"}} - testPolicy.Validate() - policies := []policy.Policy{ +func testPolicies(t *testing.T) []config.Policy { + testPolicy := config.Policy{From: "https://pomerium.io", To: "http://httpbin.org", AllowedEmails: []string{"test@gmail.com"}} + err := testPolicy.Validate() + if err != nil { + t.Fatal(err) + } + policies := []config.Policy{ testPolicy, } @@ -55,31 +56,39 @@ func testPolicies() []policy.Policy { func Test_UpdateOptions(t *testing.T) { t.Parallel() - policies := testPolicies() - newPolicy := policy.Policy{From: "foo.notatld", To: "bar.notatld", AllowedEmails: []string{"test@gmail.com"}} - newPolicy.Validate() - newPolicies := []policy.Policy{ + policies := testPolicies(t) + newPolicy := config.Policy{From: "https://source.example", To: "http://destination.example", AllowedEmails: []string{"test@gmail.com"}} + if err := newPolicy.Validate(); err != nil { + t.Fatal(err) + } + + newPolicies := []config.Policy{ newPolicy, } identity := &Identity{Email: "test@gmail.com"} tests := []struct { name string SharedKey string - Policies []policy.Policy - newPolices []policy.Policy + Policies []config.Policy + newPolices []config.Policy route string wantAllowed bool }{ {"good", "gXK6ggrlIW2HyKyUF9rUO4azrDgxhDPWqw9y+lJU7B8=", policies, policies, "pomerium.io", true}, - {"changed", "gXK6ggrlIW2HyKyUF9rUO4azrDgxhDPWqw9y+lJU7B8=", policies, newPolicies, "foo.notatld", true}, + {"changed", "gXK6ggrlIW2HyKyUF9rUO4azrDgxhDPWqw9y+lJU7B8=", policies, newPolicies, "source.example", true}, {"changed and missing", "gXK6ggrlIW2HyKyUF9rUO4azrDgxhDPWqw9y+lJU7B8=", policies, newPolicies, "pomerium.io", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { o := config.Options{SharedKey: tt.SharedKey, Policies: tt.Policies} - authorize, _ := New(o) + authorize, err := New(o) + if err != nil { + t.Fatal(err) + } o.Policies = tt.newPolices - authorize.UpdateOptions(o) + if err := authorize.UpdateOptions(o); err != nil { + t.Fatal(err) + } allowed := authorize.ValidIdentity(tt.route, identity) if allowed != tt.wantAllowed { diff --git a/authorize/identity.go b/authorize/identity.go index 9b7e05646..139facbf8 100644 --- a/authorize/identity.go +++ b/authorize/identity.go @@ -5,8 +5,8 @@ import ( "strings" "sync" + "github.com/pomerium/pomerium/internal/config" "github.com/pomerium/pomerium/internal/log" - "github.com/pomerium/pomerium/internal/policy" ) // Identity contains a user's identity information. @@ -55,28 +55,24 @@ type whitelist struct { // newIdentityWhitelistMap takes a slice of policies and creates a hashmap of identity // authorizations per-route for each allowed group, domain, and email. -func newIdentityWhitelistMap(policies []policy.Policy, admins []string) *whitelist { - - policyCount := len(policies) - if policyCount == 0 { - log.Warn().Msg("authorize: loaded configuration with no policies specified") +func newIdentityWhitelistMap(policies []config.Policy, admins []string) *whitelist { + if len(policies) == 0 { + log.Warn().Msg("authorize: loaded configuration with no policies") } - log.Info().Int("policy-count", policyCount).Msg("authorize: updated policies") - var wl whitelist wl.access = make(map[string]bool, len(policies)*3) for _, p := range policies { for _, group := range p.AllowedGroups { - wl.PutGroup(p.From, group) - log.Debug().Str("route", p.From).Str("group", group).Msg("add group") + wl.PutGroup(p.Source.Host, group) + log.Debug().Str("route", p.Source.Host).Str("group", group).Msg("add group") } for _, domain := range p.AllowedDomains { - wl.PutDomain(p.From, domain) - log.Debug().Str("route", p.From).Str("domain", domain).Msg("add domain") + wl.PutDomain(p.Source.Host, domain) + log.Debug().Str("route", p.Source.Host).Str("domain", domain).Msg("add domain") } for _, email := range p.AllowedEmails { - wl.PutEmail(p.From, email) - log.Debug().Str("route", p.From).Str("email", email).Msg("add email") + wl.PutEmail(p.Source.Host, email) + log.Debug().Str("route", p.Source.Host).Str("email", email).Msg("add email") } } diff --git a/authorize/identity_test.go b/authorize/identity_test.go index 2ac398225..807a84d4c 100644 --- a/authorize/identity_test.go +++ b/authorize/identity_test.go @@ -3,7 +3,7 @@ package authorize import ( "testing" - "github.com/pomerium/pomerium/internal/policy" + "github.com/pomerium/pomerium/internal/config" ) func TestIdentity_EmailDomain(t *testing.T) { @@ -32,40 +32,46 @@ func Test_IdentityWhitelistMap(t *testing.T) { t.Parallel() tests := []struct { name string - policies []policy.Policy + policies []config.Policy route string Identity *Identity admins []string want bool }{ - {"valid domain", []policy.Policy{{From: "example.com", AllowedDomains: []string{"example.com"}}}, "example.com", &Identity{Email: "user@example.com"}, nil, true}, - {"valid domain with admins", []policy.Policy{{From: "example.com", AllowedDomains: []string{"example.com"}}}, "example.com", &Identity{Email: "user@example.com"}, []string{"admin@example.com"}, true}, - {"invalid domain prepend", []policy.Policy{{From: "example.com", AllowedDomains: []string{"example.com"}}}, "example.com", &Identity{Email: "a@1example.com"}, nil, false}, - {"invalid domain postpend", []policy.Policy{{From: "example.com", AllowedDomains: []string{"example.com"}}}, "example.com", &Identity{Email: "user@example.com2"}, nil, false}, - {"valid group", []policy.Policy{{From: "example.com", AllowedGroups: []string{"admin"}}}, "example.com", &Identity{Email: "user@example.com", Groups: []string{"admin"}}, nil, true}, - {"invalid group", []policy.Policy{{From: "example.com", AllowedGroups: []string{"admin"}}}, "example.com", &Identity{Email: "user@example.com", Groups: []string{"everyone"}}, nil, false}, - {"invalid empty", []policy.Policy{{From: "example.com", AllowedGroups: []string{"admin"}}}, "example.com", &Identity{Email: "user@example.com", Groups: []string{""}}, nil, false}, - {"valid group multiple", []policy.Policy{{From: "example.com", AllowedGroups: []string{"admin"}}}, "example.com", &Identity{Email: "user@example.com", Groups: []string{"everyone", "admin"}}, nil, true}, - {"invalid group multiple", []policy.Policy{{From: "example.com", AllowedGroups: []string{"admin"}}}, "example.com", &Identity{Email: "user@example.com", Groups: []string{"everyones", "sadmin"}}, nil, false}, - {"valid user email", []policy.Policy{{From: "example.com", AllowedEmails: []string{"user@example.com"}}}, "example.com", &Identity{Email: "user@example.com"}, nil, true}, - {"invalid user email", []policy.Policy{{From: "example.com", AllowedEmails: []string{"user@example.com"}}}, "example.com", &Identity{Email: "user2@example.com"}, nil, false}, - {"empty everything", []policy.Policy{{From: "example.com"}}, "example.com", &Identity{Email: "user2@example.com"}, nil, false}, - {"empty policy", []policy.Policy{}, "example.com", &Identity{Email: "user@example.com"}, nil, false}, + {"valid domain", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedDomains: []string{"example.com"}}}, "from.example", &Identity{Email: "user@example.com"}, nil, true}, + {"valid domain with admins", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedDomains: []string{"example.com"}}}, "from.example", &Identity{Email: "user@example.com"}, []string{"admin@example.com"}, true}, + {"invalid domain prepend", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedDomains: []string{"example.com"}}}, "from.example", &Identity{Email: "a@1example.com"}, nil, false}, + {"invalid domain postpend", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedDomains: []string{"example.com"}}}, "from.example", &Identity{Email: "user@example.com2"}, nil, false}, + {"valid group", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedGroups: []string{"admin"}}}, "from.example", &Identity{Email: "user@example.com", Groups: []string{"admin"}}, nil, true}, + {"invalid group", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedGroups: []string{"admin"}}}, "from.example", &Identity{Email: "user@example.com", Groups: []string{"everyone"}}, nil, false}, + {"invalid empty", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedGroups: []string{"admin"}}}, "from.example", &Identity{Email: "user@example.com", Groups: []string{""}}, nil, false}, + {"valid group multiple", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedGroups: []string{"admin"}}}, "from.example", &Identity{Email: "user@example.com", Groups: []string{"everyone", "admin"}}, nil, true}, + {"invalid group multiple", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedGroups: []string{"admin"}}}, "from.example", &Identity{Email: "user@example.com", Groups: []string{"everyones", "sadmin"}}, nil, false}, + {"valid user email", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedEmails: []string{"user@example.com"}}}, "from.example", &Identity{Email: "user@example.com"}, nil, true}, + {"invalid user email", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedEmails: []string{"user@example.com"}}}, "from.example", &Identity{Email: "user2@example.com"}, nil, false}, + {"empty everything", []config.Policy{{From: "https://from.example", To: "https://to.example"}}, "from.example", &Identity{Email: "user2@example.com"}, nil, false}, + {"empty policy", []config.Policy{}, "from.example", &Identity{Email: "user2@example.com"}, nil, false}, // impersonation related - {"admin not impersonating allowed", []policy.Policy{{From: "example.com", AllowedDomains: []string{"example.com"}}}, "example.com", &Identity{Email: "admin@example.com"}, []string{"admin@example.com"}, true}, - {"admin not impersonating denied", []policy.Policy{{From: "example.com", AllowedDomains: []string{"example.com"}}}, "example.com", &Identity{Email: "admin@admin-domain.com"}, []string{"admin@admin-domain.com"}, false}, - {"impersonating match domain", []policy.Policy{{From: "example.com", AllowedDomains: []string{"example.com"}}}, "example.com", &Identity{Email: "admin@admin-domain.com", ImpersonateEmail: "user@example.com"}, []string{"admin@admin-domain.com"}, true}, - {"impersonating does not match domain", []policy.Policy{{From: "example.com", AllowedDomains: []string{"example.com"}}}, "example.com", &Identity{Email: "admin@admin-domain.com", ImpersonateEmail: "user@not-example.com"}, []string{"admin@admin-domain.com"}, false}, - {"impersonating match email", []policy.Policy{{From: "example.com", AllowedEmails: []string{"user@example.com"}}}, "example.com", &Identity{Email: "admin@admin-domain.com", ImpersonateEmail: "user@example.com"}, []string{"admin@admin-domain.com"}, true}, - {"impersonating does not match email", []policy.Policy{{From: "example.com", AllowedEmails: []string{"user@example.com"}}}, "example.com", &Identity{Email: "admin@admin-domain.com", ImpersonateEmail: "user@not-example.com"}, []string{"admin@admin-domain.com"}, false}, - {"impersonating match groups", []policy.Policy{{From: "example.com", AllowedGroups: []string{"support"}}}, "example.com", &Identity{Email: "admin@admin-domain.com", ImpersonateGroups: []string{"support"}}, []string{"admin@admin-domain.com"}, true}, - {"impersonating match many groups", []policy.Policy{{From: "example.com", AllowedGroups: []string{"support"}}}, "example.com", &Identity{Email: "admin@admin-domain.com", ImpersonateGroups: []string{"a", "b", "c", "support"}}, []string{"admin@admin-domain.com"}, true}, - {"impersonating does not match groups", []policy.Policy{{From: "example.com", AllowedGroups: []string{"support"}}}, "example.com", &Identity{Email: "admin@admin-domain.com", ImpersonateGroups: []string{"not support"}}, []string{"admin@admin-domain.com"}, false}, - {"impersonating does not match many groups", []policy.Policy{{From: "example.com", AllowedGroups: []string{"support"}}}, "example.com", &Identity{Email: "admin@admin-domain.com", ImpersonateGroups: []string{"not support", "b", "c"}}, []string{"admin@admin-domain.com"}, false}, - {"impersonating does not match empty groups", []policy.Policy{{From: "example.com", AllowedGroups: []string{"support"}}}, "example.com", &Identity{Email: "admin@admin-domain.com", ImpersonateGroups: []string{""}}, []string{"admin@admin-domain.com"}, false}, + {"admin not impersonating allowed", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedDomains: []string{"example.com"}}}, "from.example", &Identity{Email: "admin@example.com"}, []string{"admin@example.com"}, true}, + {"admin not impersonating denied", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedDomains: []string{"example.com"}}}, "from.example", &Identity{Email: "admin@admin-domain.com"}, []string{"admin@admin-domain.com"}, false}, + {"impersonating match domain", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedDomains: []string{"example.com"}}}, "from.example", &Identity{Email: "admin@admin-domain.com", ImpersonateEmail: "user@example.com"}, []string{"admin@admin-domain.com"}, true}, + {"impersonating does not match domain", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedDomains: []string{"example.com"}}}, "from.example", &Identity{Email: "admin@admin-domain.com", ImpersonateEmail: "user@not-example.com"}, []string{"admin@admin-domain.com"}, false}, + {"impersonating match email", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedEmails: []string{"user@example.com"}}}, "from.example", &Identity{Email: "admin@admin-domain.com", ImpersonateEmail: "user@example.com"}, []string{"admin@admin-domain.com"}, true}, + {"impersonating does not match email", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedEmails: []string{"user@example.com"}}}, "from.example", &Identity{Email: "admin@admin-domain.com", ImpersonateEmail: "user@not-example.com"}, []string{"admin@admin-domain.com"}, false}, + {"impersonating match groups", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedGroups: []string{"support"}}}, "from.example", &Identity{Email: "admin@admin-domain.com", ImpersonateGroups: []string{"support"}}, []string{"admin@admin-domain.com"}, true}, + {"impersonating match many groups", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedGroups: []string{"support"}}}, "from.example", &Identity{Email: "admin@admin-domain.com", ImpersonateGroups: []string{"a", "b", "c", "support"}}, []string{"admin@admin-domain.com"}, true}, + {"impersonating does not match groups", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedGroups: []string{"support"}}}, "from.example", &Identity{Email: "admin@admin-domain.com", ImpersonateGroups: []string{"not support"}}, []string{"admin@admin-domain.com"}, false}, + {"impersonating does not match many groups", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedGroups: []string{"support"}}}, "from.example", &Identity{Email: "admin@admin-domain.com", ImpersonateGroups: []string{"not support", "b", "c"}}, []string{"admin@admin-domain.com"}, false}, + {"impersonating does not match empty groups", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedGroups: []string{"support"}}}, "from.example", &Identity{Email: "admin@admin-domain.com", ImpersonateGroups: []string{""}}, []string{"admin@admin-domain.com"}, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + for i := range tt.policies { + if err := (&tt.policies[i]).Validate(); err != nil { + t.Fatal(err) + } + } + wl := NewIdentityWhitelist(tt.policies, tt.admins) if got := wl.Valid(tt.route, tt.Identity); got != tt.want { t.Errorf("wl.Valid() = %v, want %v", got, tt.want) diff --git a/cmd/pomerium/main.go b/cmd/pomerium/main.go index a8c9f1c50..e5c811502 100644 --- a/cmd/pomerium/main.go +++ b/cmd/pomerium/main.go @@ -15,7 +15,7 @@ import ( "github.com/pomerium/pomerium/authenticate" "github.com/pomerium/pomerium/authorize" "github.com/pomerium/pomerium/internal/config" - "github.com/pomerium/pomerium/internal/https" + "github.com/pomerium/pomerium/internal/httputil" "github.com/pomerium/pomerium/internal/log" "github.com/pomerium/pomerium/internal/metrics" "github.com/pomerium/pomerium/internal/middleware" @@ -46,17 +46,17 @@ func main() { mux := http.NewServeMux() - _, err = newAuthenticateService(opt, mux, grpcServer) + _, err = newAuthenticateService(*opt, mux, grpcServer) if err != nil { log.Fatal().Err(err).Msg("cmd/pomerium: authenticate") } - authz, err := newAuthorizeService(opt, grpcServer) + authz, err := newAuthorizeService(*opt, grpcServer) if err != nil { log.Fatal().Err(err).Msg("cmd/pomerium: authorize") } - proxy, err := newProxyService(opt, mux) + proxy, err := newProxyService(*opt, mux) if err != nil { log.Fatal().Err(err).Msg("cmd/pomerium: proxy") } @@ -73,7 +73,7 @@ func main() { // defer proxyService.AuthenticateClient.Close() // defer proxyService.AuthorizeClient.Close() - httpOpts := &https.Options{ + httpOpts := &httputil.Options{ Addr: opt.Addr, Cert: opt.Cert, Key: opt.Key, @@ -95,7 +95,7 @@ func main() { defer srv.Close() } - if err := https.ListenAndServeTLS(httpOpts, wrapMiddleware(opt, mux), grpcServer); err != nil { + if err := httputil.ListenAndServeTLS(httpOpts, wrapMiddleware(opt, mux), grpcServer); err != nil { log.Fatal().Err(err).Msg("cmd/pomerium: https server") } } @@ -166,7 +166,7 @@ func newPromListener(addr string) { log.Error().Err(metrics.NewPromHTTPListener(addr)).Str("MetricsAddr", addr).Msg("cmd/pomerium: could not start metrics exporter") } -func wrapMiddleware(o config.Options, mux *http.ServeMux) http.Handler { +func wrapMiddleware(o *config.Options, mux *http.ServeMux) http.Handler { c := middleware.NewChain() c = c.Append(metrics.HTTPMetricsHandler("proxy")) c = c.Append(log.NewHandler(log.Logger)) @@ -194,10 +194,10 @@ func wrapMiddleware(o config.Options, mux *http.ServeMux) http.Handler { return c.Then(mux) } -func parseOptions(configFile string) (config.Options, error) { +func parseOptions(configFile string) (*config.Options, error) { o, err := config.OptionsFromViper(configFile) if err != nil { - return o, err + return nil, err } if o.Debug { log.SetDebugMode() @@ -209,8 +209,12 @@ func parseOptions(configFile string) (config.Options, error) { return o, nil } -func handleConfigUpdate(opt config.Options, services []config.OptionsUpdater) config.Options { +func handleConfigUpdate(opt *config.Options, services []config.OptionsUpdater) *config.Options { newOpt, err := parseOptions(*configFile) + if err != nil { + log.Error().Err(err).Msg("cmd/pomerium: could not reload configuration") + return opt + } optChecksum := opt.Checksum() newOptChecksum := newOpt.Checksum() @@ -224,22 +228,10 @@ func handleConfigUpdate(opt config.Options, services []config.OptionsUpdater) co return opt } - if err != nil { - log.Error(). - Err(err). - Msg("cmd/pomerium: could not reload configuration") - return opt - } - - log.Info(). - Str("config-checksum", newOptChecksum). - Msg("cmd/pomerium: running configuration has changed") + log.Info().Str("checksum", newOptChecksum).Msg("cmd/pomerium: checksum changed") for _, service := range services { - err := service.UpdateOptions(newOpt) - if err != nil { - log.Error(). - Err(err). - Msg("cmd/pomerium: could not update options") + if err := service.UpdateOptions(*newOpt); err != nil { + log.Error().Err(err).Msg("cmd/pomerium: could not update options") } } diff --git a/cmd/pomerium/main_test.go b/cmd/pomerium/main_test.go index b3212a629..70a8700fe 100644 --- a/cmd/pomerium/main_test.go +++ b/cmd/pomerium/main_test.go @@ -11,8 +11,6 @@ import ( "strings" "testing" - "github.com/pomerium/pomerium/internal/policy" - "github.com/pomerium/pomerium/internal/config" "github.com/pomerium/pomerium/internal/middleware" "google.golang.org/grpc" @@ -72,21 +70,22 @@ func Test_newAuthenticateService(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - authURL, _ := url.Parse("http://auth.server.com") - testOpts := config.NewOptions() + testOpts, err := config.NewOptions("https://authenticate.example", "https://authorize.example") + if err != nil { + t.Fatal(err) + } testOpts.Provider = "google" testOpts.ClientSecret = "TEST" testOpts.SharedKey = "YixWi1MYh77NMECGGIJQevoonYtVF+ZPRkQZrrmeRqM=" testOpts.CookieSecret = "YixWi1MYh77NMECGGIJQevoonYtVF+ZPRkQZrrmeRqM=" - testOpts.AuthenticateURL = *authURL testOpts.Services = tt.s if tt.Field != "" { - testOptsField := reflect.ValueOf(&testOpts).Elem().FieldByName(tt.Field) + testOptsField := reflect.ValueOf(testOpts).Elem().FieldByName(tt.Field) testOptsField.Set(reflect.ValueOf(tt).FieldByName("Value")) } - _, err := newAuthenticateService(testOpts, mux, grpcServer) + _, err = newAuthenticateService(*testOpts, mux, grpcServer) if (err != nil) != tt.wantErr { t.Errorf("newAuthenticateService() error = %v, wantErr %v", err, tt.wantErr) return @@ -116,21 +115,26 @@ func Test_newAuthorizeService(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - testOpts := config.NewOptions() + testOpts, err := config.NewOptions("https://some.example", "https://some.example") + if err != nil { + t.Fatal(err) + } testOpts.Services = tt.s testOpts.CookieSecret = "YixWi1MYh77NMECGGIJQevoonYtVF+ZPRkQZrrmeRqM=" - testPolicy := policy.Policy{From: "pomerium.io", To: "httpbin.org"} - testPolicy.Validate() - testOpts.Policies = []policy.Policy{ + testPolicy := config.Policy{From: "http://some.example", To: "https://some.example"} + if err := testPolicy.Validate(); err != nil { + t.Fatal(err) + } + testOpts.Policies = []config.Policy{ testPolicy, } if tt.Field != "" { - testOptsField := reflect.ValueOf(&testOpts).Elem().FieldByName(tt.Field) + testOptsField := reflect.ValueOf(testOpts).Elem().FieldByName(tt.Field) testOptsField.Set(reflect.ValueOf(tt).FieldByName("Value")) } - _, err := newAuthorizeService(testOpts, grpcServer) + _, err = newAuthorizeService(*testOpts, grpcServer) if (err != nil) != tt.wantErr { t.Errorf("newAuthorizeService() error = %v, wantErr %v", err, tt.wantErr) return @@ -156,26 +160,31 @@ func Test_newProxyeService(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mux := http.NewServeMux() - testOpts := config.NewOptions() - testPolicy := policy.Policy{From: "pomerium.io", To: "httpbin.org"} - testPolicy.Validate() - testOpts.Policies = []policy.Policy{ + testOpts, err := config.NewOptions("https://authenticate.example", "https://authorize.example") + if err != nil { + t.Fatal(err) + } + testPolicy := config.Policy{From: "http://some.example", To: "http://some.example"} + if err := testPolicy.Validate(); err != nil { + t.Fatal(err) + } + testOpts.Policies = []config.Policy{ testPolicy, } AuthenticateURL, _ := url.Parse("https://authenticate.example.com") AuthorizeURL, _ := url.Parse("https://authorize.example.com") - testOpts.AuthenticateURL = *AuthenticateURL - testOpts.AuthorizeURL = *AuthorizeURL + testOpts.AuthenticateURL = AuthenticateURL + testOpts.AuthorizeURL = AuthorizeURL testOpts.CookieSecret = "YixWi1MYh77NMECGGIJQevoonYtVF+ZPRkQZrrmeRqM=" testOpts.Services = tt.s if tt.Field != "" { - testOptsField := reflect.ValueOf(&testOpts).Elem().FieldByName(tt.Field) + testOptsField := reflect.ValueOf(testOpts).Elem().FieldByName(tt.Field) testOptsField.Set(reflect.ValueOf(tt).FieldByName("Value")) } - _, err := newProxyService(testOpts, mux) + _, err = newProxyService(*testOpts, mux) if (err != nil) != tt.wantErr { t.Errorf("newProxyService() error = %v, wantErr %v", err, tt.wantErr) return @@ -205,7 +214,7 @@ func Test_wrapMiddleware(t *testing.T) { }) mux.Handle("/404", h) - out := wrapMiddleware(o, mux) + out := wrapMiddleware(&o, mux) out.ServeHTTP(rr, req) expected := fmt.Sprintf("OK") body := rr.Body.String() @@ -216,27 +225,31 @@ func Test_wrapMiddleware(t *testing.T) { } func Test_parseOptions(t *testing.T) { tests := []struct { - name string - envKey string - envValue string - - wantSharedKey string - wantErr bool + name string + envKey string + envValue string + servicesEnvKey string + servicesEnvValue string + wantSharedKey string + wantErr bool }{ - {"no shared secret", "", "", "", true}, - {"good", "SHARED_SECRET", "YixWi1MYh77NMECGGIJQevoonYtVF+ZPRkQZrrmeRqM=", "YixWi1MYh77NMECGGIJQevoonYtVF+ZPRkQZrrmeRqM=", false}, + {"no shared secret", "", "", "SERVICES", "authenticate", "skip", true}, + {"no shared secret in all mode", "", "", "", "", "", false}, + {"good", "SHARED_SECRET", "YixWi1MYh77NMECGGIJQevoonYtVF+ZPRkQZrrmeRqM=", "", "", "YixWi1MYh77NMECGGIJQevoonYtVF+ZPRkQZrrmeRqM=", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + os.Setenv(tt.servicesEnvKey, tt.servicesEnvValue) os.Setenv(tt.envKey, tt.envValue) defer os.Unsetenv(tt.envKey) + defer os.Unsetenv(tt.servicesEnvKey) got, err := parseOptions("") if (err != nil) != tt.wantErr { t.Errorf("parseOptions() error = %v, wantErr %v", err, tt.wantErr) return } - if got.SharedKey != tt.wantSharedKey { + if got != nil && got.Services != "all" && got.SharedKey != tt.wantSharedKey { t.Errorf("parseOptions()\n") t.Errorf("got: %+v\n", got.SharedKey) t.Errorf("want: %+v\n", tt.wantSharedKey) @@ -265,22 +278,29 @@ func Test_handleConfigUpdate(t *testing.T) { os.Setenv("SHARED_SECRET", "foo") defer os.Unsetenv("SHARED_SECRET") - blankOpts := config.NewOptions() - goodOpts, _ := config.OptionsFromViper("") + blankOpts, err := config.NewOptions("https://authenticate.example", "https://authorize.example") + if err != nil { + t.Fatal(err) + } + + goodOpts, err := config.OptionsFromViper("") + if err != nil { + t.Fatal(err) + } tests := []struct { name string service *mockService oldOpts config.Options wantUpdate bool }{ - {"good", &mockService{fail: false}, blankOpts, true}, - {"bad", &mockService{fail: true}, blankOpts, true}, - {"no change", &mockService{fail: false}, goodOpts, false}, + {"good", &mockService{fail: false}, *blankOpts, true}, + {"bad", &mockService{fail: true}, *blankOpts, true}, + {"no change", &mockService{fail: false}, *goodOpts, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - handleConfigUpdate(tt.oldOpts, []config.OptionsUpdater{tt.service}) + handleConfigUpdate(&tt.oldOpts, []config.OptionsUpdater{tt.service}) if tt.service.Updated != tt.wantUpdate { t.Errorf("Failed to update config on service") } diff --git a/docs/reference/readme.md b/docs/reference/readme.md index 292c75df6..de9c6cd6e 100644 --- a/docs/reference/readme.md +++ b/docs/reference/readme.md @@ -15,7 +15,7 @@ If you are coming from a kubernetes or docker background this should feel famili Using both [environmental variables] and config file keys is allowed and encouraged (for instance, secret keys are probably best set as environmental variables). However, if duplicate configuration keys are found, environment variables take precedence. -Pomerium will automatically reload the configuration file if it is changed. At this time, only policy is re-configured when this reload occurs, but additional options may be added in the future. It is suggested that your policy is stored in a configuration file so that you can take advantage of this feature. +Pomerium will automatically reload the configuration file if it is changed. At this time, only policy is re-configured when this reload occurs, but additional options may be added in the future. It is suggested that your policy is stored in a configuration file so that you can take advantage of this feature. ## Global settings @@ -73,7 +73,7 @@ head -c32 /dev/urandom | base64 ::: danger -Enabling the debug flag will result in sensitive information being logged!!! +Enabling the debug flag will result in sensitive information being logged!!! ::: @@ -149,19 +149,6 @@ Timeouts set the global server timeouts. For route-specific timeouts, see [polic If set, the HTTP Redirect Address specifies the host and port to redirect http to https traffic on. If unset, no redirect server is started. -### Websocket Connections - -- Environmental Variable: `ALLOW_WEBSOCKETS` -- Config File Key: `allow_websockets` -- Type: `bool` -- Default: `false` - -If set, enables proxying of websocket connections. -Otherwise the proxy responds with `400 Bad Request` to all websocket connections. - -**Use with caution:** By definition, websockets are long-lived connections, so [global timeouts](#global-timeouts) are not enforced. -Allowing websocket connections to the proxy could result in abuse via DOS attacks. - ### Metrics Address - Environmental Variable: `METRICS_ADDRESS` @@ -171,31 +158,32 @@ Allowing websocket connections to the proxy could result in abuse via DOS attack - Default: `disabled` - Optional -Expose a prometheus format HTTP endpoint on the specified port. Disabled by default. +Expose a prometheus format HTTP endpoint on the specified port. Disabled by default. -**Use with caution:** the endpoint can expose frontend and backend server names or addresses. Do not expose the metrics port publicly. +**Use with caution:** the endpoint can expose frontend and backend server names or addresses. Do not expose the metrics port publicly. #### Metrics tracked -| Name | Type | Description | -|:------------- |:-------------|:-----| -|http_server_requests_total| Counter | Total HTTP server requests handled by service| -|http_server_response_size_bytes| Histogram | HTTP server response size by service| -|http_server_request_duration_ms| Histogram | HTTP server request duration by service| -|http_client_requests_total| Counter | Total HTTP client requests made by service| -|http_client_response_size_bytes| Histogram | HTTP client response size by service| -|http_client_request_duration_ms| Histogram | HTTP client request duration by service| -|grpc_client_requests_total| Counter | Total GRPC client requests made by service| -|grpc_client_response_size_bytes| Histogram | GRPC client response size by service| -|grpc_client_request_duration_ms| Histogram | GRPC client request duration by service| +Name | Type | Description +:------------------------------ | :-------- | :-------------------------------------------- +http_server_requests_total | Counter | Total HTTP server requests handled by service +http_server_response_size_bytes | Histogram | HTTP server response size by service +http_server_request_duration_ms | Histogram | HTTP server request duration by service +http_client_requests_total | Counter | Total HTTP client requests made by service +http_client_response_size_bytes | Histogram | HTTP client response size by service +http_client_request_duration_ms | Histogram | HTTP client request duration by service +grpc_client_requests_total | Counter | Total GRPC client requests made by service +grpc_client_response_size_bytes | Histogram | GRPC client response size by service +grpc_client_request_duration_ms | Histogram | GRPC client request duration by service ### Policy - Environmental Variable: `POLICY` - Config File Key: `policy` - Type: [base64 encoded] `string` or inline policy structure in config file -- Required - - Required to forward traffic. Pomerium will safely start without a policy configured, but will be unable to authorize or proxy traffic until the configuration is updated to contain a policy. +- Required + + - Required to forward traffic. Pomerium will safely start without a policy configured, but will be unable to authorize or proxy traffic until the configuration is updated to contain a policy. Policy contains route specific settings, and access control details. If you are configuring via POLICY environment variable, just the contents of the policy needs to be passed. If you are configuring via file, the policy should be present under the policy key. For example, @@ -277,6 +265,34 @@ If this setting is enabled, no whitelists (e.g. Allowed Users) should be provide Policy timeout establishes the per-route timeout value. Cannot exceed global timeout values. +#### Websocket Connections + +- Config File Key: `allow_websockets` +- Type: `bool` +- Default: `false` + +If set, enables proxying of websocket connections. + +**Use with caution:** By definition, websockets are long-lived connections, so [global timeouts](#global-timeouts) are not enforced. Allowing websocket connections to the proxy could result in abuse via [DOS attacks](https://www.cloudflare.com/learning/ddos/ddos-attack-tools/slowloris/). + +#### TLS Skip Verification + +- Config File Key: `tls_skip_verify` +- Type: `bool` +- Default: `false` + +TLS Skip Verification controls whether a client verifies the server's certificate chain and host name. If enabled, TLS accepts any certificate presented by the server and any host name in that certificate. In this mode, TLS is susceptible to man-in-the-middle attacks. This should be used only for testing. + +#### TLS Custom Certificate Authority + +- Config File Key: `tls_custom_ca` +- Type: [base64 encoded] `string` +- Optional + +TLS Custom Certificate Authority defines the set of root certificate authorities that clients use when verifying server certificates. + +Note: This setting will replace (not append) the system's trust store for a given route. + ## Authenticate Service ### Authenticate Service URL @@ -398,7 +414,7 @@ If your load balancer does not support gRPC pass-through you'll need to set this - Optional (but typically required if Authenticate Internal Service Address is set) - Example: `*.corp.example.com` if wild card or `authenticate.corp.example.com`/`authorize.corp.example.com` -When Authenticate Internal Service Address is set, secure service communication can fail because the external certificate name will not match the internally routed service hostname/[SNI](). This setting allows you to override that check. +When Authenticate Internal Service Address is set, secure service communication can fail because the external certificate name will not match the internally routed service hostname/[SNI](https://en.wikipedia.org/wiki/Server_Name_Indication). This setting allows you to override that check. ### Certificate Authority @@ -414,17 +430,19 @@ Certificate Authority is set when behind-the-ingress service communication uses - Environmental Variable: `HEADERS` - Config File Key: `headers` - Type: map of `strings` key value pairs -- Examples: - - Comma Separated: - `X-Content-Type-Options:nosniff,X-Frame-Options:SAMEORIGIN` - - JSON: `'{"X-Test": "X-Value"}'` - - YAML: - ```yaml - headers: - X-Test: X-Value - ``` +- Examples: + + - Comma Separated: `X-Content-Type-Options:nosniff,X-Frame-Options:SAMEORIGIN` + - JSON: `'{"X-Test": "X-Value"}'` + - YAML: + + ```yaml + headers: + X-Test: X-Value + ``` - To disable: `disable:true` + - Default : ```javascript @@ -460,7 +478,6 @@ Refresh cooldown is the minimum amount of time between allowed manually refreshe Default Upstream Timeout is the default timeout applied to a proxied route when no `timeout` key is specified by the policy. - [base64 encoded]: https://en.wikipedia.org/wiki/Base64 [environmental variables]: https://en.wikipedia.org/wiki/Environment_variable [identity provider]: ./identity-providers.md diff --git a/internal/config/helpers.go b/internal/config/helpers.go new file mode 100644 index 000000000..d916c55ce --- /dev/null +++ b/internal/config/helpers.go @@ -0,0 +1,58 @@ +package config // import "github.com/pomerium/pomerium/internal/config" + +import "os" + +// findPwd returns best guess at current working directory +func findPwd() string { + p, err := os.Getwd() + if err != nil { + return "." + } + return p +} + +// IsValidService checks to see if a service is a valid service mode +func IsValidService(s string) bool { + switch s { + case + "all", + "proxy", + "authorize", + "authenticate": + return true + } + return false +} + +// IsAuthenticate checks to see if we should be running the authenticate service +func IsAuthenticate(s string) bool { + switch s { + case + "all", + "authenticate": + return true + } + return false +} + +// IsAuthorize checks to see if we should be running the authorize service +func IsAuthorize(s string) bool { + switch s { + case + "all", + "authorize": + return true + } + return false +} + +// IsProxy checks to see if we should be running the proxy service +func IsProxy(s string) bool { + switch s { + case + "all", + "proxy": + return true + } + return false +} diff --git a/internal/config/helpers_test.go b/internal/config/helpers_test.go new file mode 100644 index 000000000..14f6680ea --- /dev/null +++ b/internal/config/helpers_test.go @@ -0,0 +1,94 @@ +package config // import "github.com/pomerium/pomerium/internal/config" + +import ( + "testing" +) + +func Test_isValidService(t *testing.T) { + tests := []struct { + name string + service string + want bool + }{ + {"proxy", "proxy", true}, + {"all", "all", true}, + {"authenticate", "authenticate", true}, + {"authenticate bad case", "AuThenticate", false}, + {"authorize implemented", "authorize", true}, + {"jiberish", "xd23", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsValidService(tt.service); got != tt.want { + t.Errorf("isValidService() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_isAuthenticate(t *testing.T) { + tests := []struct { + name string + service string + want bool + }{ + {"proxy", "proxy", false}, + {"all", "all", true}, + {"authenticate", "authenticate", true}, + {"authenticate bad case", "AuThenticate", false}, + {"authorize implemented", "authorize", false}, + {"jiberish", "xd23", false}, + } + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + if got := IsAuthenticate(tt.service); got != tt.want { + t.Errorf("isAuthenticate() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_isAuthorize(t *testing.T) { + tests := []struct { + name string + service string + want bool + }{ + {"proxy", "proxy", false}, + {"all", "all", true}, + {"authorize", "authorize", true}, + {"authorize bad case", "AuThorize", false}, + {"authenticate implemented", "authenticate", false}, + {"jiberish", "xd23", false}, + } + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + if got := IsAuthorize(tt.service); got != tt.want { + t.Errorf("isAuthenticate() = %v, want %v", got, tt.want) + } + }) + } +} +func Test_IsProxy(t *testing.T) { + tests := []struct { + name string + service string + want bool + }{ + {"proxy", "proxy", true}, + {"all", "all", true}, + {"authorize", "authorize", false}, + {"proxy bad case", "PrOxY", false}, + {"jiberish", "xd23", false}, + } + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + if got := IsProxy(tt.service); got != tt.want { + t.Errorf("IsProxy() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/config/options.go b/internal/config/options.go index edf9d25f7..ddbe82d2a 100644 --- a/internal/config/options.go +++ b/internal/config/options.go @@ -1,19 +1,20 @@ -package config +package config // import "github.com/pomerium/pomerium/internal/config" import ( "encoding/base64" "errors" "fmt" "net/url" - "os" "path/filepath" "reflect" "strings" "time" - "github.com/mitchellh/hashstructure" + "github.com/pomerium/pomerium/internal/cryptutil" "github.com/pomerium/pomerium/internal/log" - "github.com/pomerium/pomerium/internal/policy" + "github.com/pomerium/pomerium/internal/urlutil" + + "github.com/mitchellh/hashstructure" "github.com/spf13/viper" "gopkg.in/yaml.v2" ) @@ -22,9 +23,6 @@ import ( const DisableHeaderKey = "disable" // Options are the global environmental flags used to set up pomerium's services. -// If a base64 encoded certificate and key are not provided as environmental variables, -// or if a file location is not provided, the server will attempt to find a matching keypair -// in the local directory as `./cert.pem` and `./privkey.pem` respectively. type Options struct { // Debug outputs human-readable logs to Stdout. Debug bool `mapstructure:"pomerium_debug"` @@ -42,10 +40,10 @@ type Options struct { Services string `mapstructure:"services"` // Addr specifies the host and port on which the server should serve - // HTTPS requests. If empty, ":https" is used. + // HTTPS requests. If empty, ":https" (localhost:443) is used. Addr string `mapstructure:"address"` - // Cert and Key specifies the base64 encoded TLS certificates to use. + // Cert and Key specifies the TLS certificates to use. Cert string `mapstructure:"certificate"` Key string `mapstructure:"certificate_key"` @@ -64,15 +62,15 @@ type Options struct { ReadHeaderTimeout time.Duration `mapstructure:"timeout_read_header"` IdleTimeout time.Duration `mapstructure:"timeout_idle"` - // Policy is a base64 encoded yaml blob which enumerates - // per-route access control policies. + // Policies define per-route configuration and access control policies. + Policies []Policy PolicyEnv string PolicyFile string `mapstructure:"policy_file"` // AuthenticateURL represents the externally accessible http endpoints // used for authentication requests and callbacks AuthenticateURLString string `mapstructure:"authenticate_service_url"` - AuthenticateURL url.URL + AuthenticateURL *url.URL // Session/Cookie management // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie @@ -93,8 +91,6 @@ type Options struct { Scopes []string `mapstructure:"idp_scopes"` ServiceAccount string `mapstructure:"idp_service_account"` - Policies []policy.Policy - // Administrators contains a set of emails with users who have super user // (sudo) access including the ability to impersonate other users' access Administrators []string `mapstructure:"administrators"` @@ -104,20 +100,20 @@ type Options struct { // NOTE: As many load balancers do not support externally routed gRPC so // this may be an internal location. AuthenticateInternalAddrString string `mapstructure:"authenticate_internal_url"` - AuthenticateInternalAddr url.URL + AuthenticateInternalAddr *url.URL // AuthorizeURL is the routable destination of the authorize service's // gRPC endpoint. NOTE: As many load balancers do not support // externally routed gRPC so this may be an internal location. AuthorizeURLString string `mapstructure:"authorize_service_url"` - AuthorizeURL url.URL + AuthorizeURL *url.URL // Settings to enable custom behind-the-ingress service communication OverrideCertificateName string `mapstructure:"override_certificate_name"` CA string `mapstructure:"certificate_authority"` CAFile string `mapstructure:"certificate_authority_file"` - // SigningKey is a base64 encoded private key used to add a JWT-signature. + // SigningKey is the private key used to add a JWT-signature. // https://www.pomerium.io/docs/signed-headers.html SigningKey string `mapstructure:"signing_key"` @@ -128,219 +124,170 @@ type Options struct { // RefreshCooldown limits the rate a user can refresh her session RefreshCooldown time.Duration `mapstructure:"refresh_cooldown"` - // Sub-routes - Routes map[string]string `mapstructure:"routes"` - DefaultUpstreamTimeout time.Duration `mapstructure:"default_upstream_timeout"` - - // Enable proxying of websocket connections. Defaults to "false". - // Caution: Enabling this feature could result in abuse via DOS attacks. - AllowWebsockets bool `mapstructure:"allow_websockets"` + //Routes map[string]string `mapstructure:"routes"` + DefaultUpstreamTimeout time.Duration `mapstructure:"default_upstream_timeout"` // Address/Port to bind to for prometheus metrics MetricsAddr string `mapstructure:"metrics_address"` } -// NewOptions returns a new options struct with default values -func NewOptions() Options { - o := Options{ - Debug: false, - LogLevel: "debug", - Services: "all", - CookieHTTPOnly: true, - CookieSecure: true, - CookieExpire: time.Duration(14) * time.Hour, - CookieRefresh: time.Duration(30) * time.Minute, - CookieName: "_pomerium", - DefaultUpstreamTimeout: time.Duration(30) * time.Second, - Headers: map[string]string{ - "X-Content-Type-Options": "nosniff", - "X-Frame-Options": "SAMEORIGIN", - "X-XSS-Protection": "1; mode=block", - "Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload", - }, - Addr: ":https", - CertFile: filepath.Join(findPwd(), "cert.pem"), - KeyFile: filepath.Join(findPwd(), "privkey.pem"), - ReadHeaderTimeout: 10 * time.Second, - ReadTimeout: 30 * time.Second, - WriteTimeout: 0, // support streaming by default - IdleTimeout: 5 * time.Minute, - RefreshCooldown: time.Duration(5 * time.Minute), - AllowWebsockets: false, +var defaultOptions = Options{ + Debug: false, + LogLevel: "debug", + Services: "all", + CookieHTTPOnly: true, + CookieSecure: true, + CookieExpire: time.Duration(14) * time.Hour, + CookieRefresh: time.Duration(30) * time.Minute, + CookieName: "_pomerium", + DefaultUpstreamTimeout: time.Duration(30) * time.Second, + Headers: map[string]string{ + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "SAMEORIGIN", + "X-XSS-Protection": "1; mode=block", + "Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload", + }, + Addr: ":https", + CertFile: filepath.Join(findPwd(), "cert.pem"), + KeyFile: filepath.Join(findPwd(), "privkey.pem"), + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 0, // support streaming by default + IdleTimeout: 5 * time.Minute, + RefreshCooldown: time.Duration(5 * time.Minute), +} + +// NewOptions returns a minimal options configuration built from default options. +// Any modifications to the structure should be followed up by a subsequent +// call to validate. +func NewOptions(authenticateURL, authorizeURL string) (*Options, error) { + o := defaultOptions + o.AuthenticateURLString = authenticateURL + o.AuthorizeURLString = authorizeURL + if err := o.Validate(); err != nil { + return nil, fmt.Errorf("internal/config: validation error %s", err) } - return o + return &o, nil } // OptionsFromViper builds the main binary's configuration // options by parsing environmental variables and config file -func OptionsFromViper(configFile string) (Options, error) { - o := NewOptions() - +func OptionsFromViper(configFile string) (*Options, error) { + // start a copy of the default options + o := defaultOptions // Load up config o.bindEnvs() if configFile != "" { - log.Info(). - Str("file", configFile). - Msg("loading config from file") - viper.SetConfigFile(configFile) - err := viper.ReadInConfig() - if err != nil { - return o, fmt.Errorf("failed to read config: %s", err) + if err := viper.ReadInConfig(); err != nil { + return nil, fmt.Errorf("internal/config: failed to read config: %s", err) } } - err := viper.Unmarshal(&o) - if err != nil { - return o, fmt.Errorf("failed to load options from config: %s", err) + if err := viper.Unmarshal(&o); err != nil { + return nil, fmt.Errorf("internal/config: failed to unmarshal config: %s", err) } - // Turn URL strings into url structs - err = o.parseURLs() - if err != nil { - return o, fmt.Errorf("failed to parse URLs: %s", err) + if err := o.Validate(); err != nil { + return nil, fmt.Errorf("internal/config: validation error %s", err) } - - // Load and initialize policy - err = o.parsePolicy() - if err != nil { - return o, fmt.Errorf("failed to parse Policy: %s", err) - } - - // Parse Headers - err = o.parseHeaders() - if err != nil { - return o, fmt.Errorf("failed to parse Headers: %s", err) - } - - if o.Debug { - log.SetDebugMode() - } - if o.LogLevel != "" { - log.SetLevel(o.LogLevel) - } - if _, disable := o.Headers[DisableHeaderKey]; disable { - o.Headers = make(map[string]string) - } - - err = o.validate() - if err != nil { - return o, err - } - - log.Debug(). - Str("config-checksum", o.Checksum()). - Msg("read configuration with checksum") - return o, nil + return &o, nil } -// validate ensures the Options fields are properly formed and present -func (o *Options) validate() error { - +// Validate ensures the Options fields are properly formed, present, and hydrated. +func (o *Options) Validate() error { if !IsValidService(o.Services) { return fmt.Errorf("%s is an invalid service type", o.Services) } + // shared key must be set for all modes other than "all" if o.SharedKey == "" { - return errors.New("shared-key cannot be empty") + if o.Services == "all" { + o.SharedKey = cryptutil.GenerateRandomString(32) + } else { + return errors.New("shared-key cannot be empty") + } } - if len(o.Routes) != 0 { - return errors.New("routes setting is deprecated, use policy instead") + if o.AuthenticateURLString != "" { + u, err := urlutil.ParseAndValidateURL(o.AuthenticateURLString) + if err != nil { + return fmt.Errorf("bad authenticate-url %s : %v", o.AuthenticateURLString, err) + } + o.AuthenticateURL = u } + if o.AuthorizeURLString != "" { + u, err := urlutil.ParseAndValidateURL(o.AuthorizeURLString) + if err != nil { + return fmt.Errorf("bad authorize-url %s : %v", o.AuthorizeURLString, err) + } + o.AuthorizeURL = u + } + + if o.AuthenticateInternalAddrString != "" { + u, err := urlutil.ParseAndValidateURL(o.AuthenticateInternalAddrString) + if err != nil { + return fmt.Errorf("bad authenticate-internal-addr %s : %v", o.AuthenticateInternalAddrString, err) + } + o.AuthenticateInternalAddr = u + } if o.PolicyFile != "" { - return errors.New("Setting POLICY_FILE is deprecated, use policy env var or config file instead") + return errors.New("policy file setting is deprecated") + } + if err := o.parsePolicy(); err != nil { + return fmt.Errorf("failed to parse policy: %s", err) + } + + if err := o.parseHeaders(); err != nil { + return fmt.Errorf("failed to parse headers: %s", err) + } + + if _, disable := o.Headers[DisableHeaderKey]; disable { + o.Headers = make(map[string]string) } return nil } -// parsePolicy initializes policy +// parsePolicy initializes policy to the options from either base64 environmental +// variables or from a file func (o *Options) parsePolicy() error { - var policies []policy.Policy + var policies []Policy // Parse from base64 env var if o.PolicyEnv != "" { policyBytes, err := base64.StdEncoding.DecodeString(o.PolicyEnv) if err != nil { - return fmt.Errorf("Could not decode POLICY env var: %s", err) + return fmt.Errorf("could not decode POLICY env var: %s", err) } if err := yaml.Unmarshal(policyBytes, &policies); err != nil { - return fmt.Errorf("Could not parse POLICY env var: %s", err) + return fmt.Errorf("could not unmarshal policy yaml: %s", err) } - // Parse from file } else { - err := viper.UnmarshalKey("policy", &policies) - if err != nil { + // Parse from file + if err := viper.UnmarshalKey("policy", &policies); err != nil { return err } } - + if len(policies) != 0 { + o.Policies = policies + } // Finish initializing policies - for i := range policies { - err := (&policies[i]).Validate() - if err != nil { + for i := range o.Policies { + if err := (&o.Policies[i]).Validate(); err != nil { return err } } - o.Policies = policies return nil } -// parseAndValidateURL wraps standard library's default url.Parse because it's much more -// lenient about what type of urls it accepts than pomerium can be. -func parseAndValidateURL(rawurl string) (*url.URL, error) { - u, err := url.Parse(rawurl) - if err != nil { - return nil, err - } - if u.Host == "" { - return nil, fmt.Errorf("%s does have a valid hostname", rawurl) - } - if u.Scheme == "" || u.Scheme != "https" { - return nil, fmt.Errorf("%s does have a valid https scheme", rawurl) - } - return u, nil -} - -// parseURLs parses URL strings into actual URL pointers -func (o *Options) parseURLs() error { - if o.AuthenticateURLString != "" { - AuthenticateURL, err := parseAndValidateURL(o.AuthenticateURLString) - if err != nil { - return fmt.Errorf("internal/config: bad authenticate-url %s : %v", o.AuthenticateURLString, err) - } - o.AuthenticateURL = *AuthenticateURL - } - - if o.AuthorizeURLString != "" { - AuthorizeURL, err := parseAndValidateURL(o.AuthorizeURLString) - if err != nil { - return fmt.Errorf("internal/config: bad authorize-url %s : %v", o.AuthorizeURLString, err) - } - o.AuthorizeURL = *AuthorizeURL - } - - if o.AuthenticateInternalAddrString != "" { - AuthenticateInternalAddr, err := parseAndValidateURL(o.AuthenticateInternalAddrString) - if err != nil { - return fmt.Errorf("internal/config: bad authenticate-internal-addr %s : %v", o.AuthenticateInternalAddrString, err) - } - o.AuthenticateInternalAddr = *AuthenticateInternalAddr - } - - return nil -} - -// parseHeaders handles unmarshalling any custom headers correctly from the environment or -// viper's parsed keys +// parseHeaders handles unmarshalling any custom headers correctly from the +// environment or viper's parsed keys func (o *Options) parseHeaders() error { var headers map[string]string if o.HeadersEnv != "" { - // Handle JSON by default via viper if headers = viper.GetStringMapString("HeadersEnv"); len(headers) == 0 { - // Try to parse "Key1:Value1,Key2:Value2" syntax headerSlice := strings.Split(o.HeadersEnv, ",") for n := range headerSlice { @@ -350,7 +297,7 @@ func (o *Options) parseHeaders() error { } else { // Something went wrong - return fmt.Errorf("Failed to decode headers environment variable from '%s'", o.HeadersEnv) + return fmt.Errorf("failed to decode headers from '%s'", o.HeadersEnv) } } @@ -358,7 +305,7 @@ func (o *Options) parseHeaders() error { o.Headers = headers } else if viper.IsSet("headers") { if err := viper.UnmarshalKey("headers", &headers); err != nil { - return err + return fmt.Errorf("header %s failed to parse: %s", viper.Get("headers"), err) } o.Headers = headers } @@ -381,61 +328,6 @@ func (o *Options) bindEnvs() { viper.BindEnv("HeadersEnv", "HEADERS") } -// findPwd returns best guess at current working directory -func findPwd() string { - p, err := os.Getwd() - if err != nil { - return "." - } - return p -} - -// IsValidService checks to see if a service is a valid service mode -func IsValidService(s string) bool { - switch s { - case - "all", - "proxy", - "authorize", - "authenticate": - return true - } - return false -} - -// IsAuthenticate checks to see if we should be running the authenticate service -func IsAuthenticate(s string) bool { - switch s { - case - "all", - "authenticate": - return true - } - return false -} - -// IsAuthorize checks to see if we should be running the authorize service -func IsAuthorize(s string) bool { - switch s { - case - "all", - "authorize": - return true - } - return false -} - -// IsProxy checks to see if we should be running the proxy service -func IsProxy(s string) bool { - switch s { - case - "all", - "proxy": - return true - } - return false -} - // OptionsUpdater updates local state based on an Options struct type OptionsUpdater interface { UpdateOptions(Options) error @@ -444,10 +336,9 @@ type OptionsUpdater interface { // Checksum returns the checksum of the current options struct func (o *Options) Checksum() string { hash, err := hashstructure.Hash(o, nil) - if err != nil { - log.Warn().Msg("could not calculate Option checksum") - return "no checksum availablle" + log.Warn().Err(err).Msg("internal/config: checksum failure") + return "no checksum available" } return fmt.Sprintf("%x", hash) } diff --git a/internal/config/options_test.go b/internal/config/options_test.go index 17031073b..8fd7279a1 100644 --- a/internal/config/options_test.go +++ b/internal/config/options_test.go @@ -6,18 +6,16 @@ import ( "io/ioutil" "net/url" "os" - "reflect" "testing" "github.com/google/go-cmp/cmp" - "github.com/pomerium/pomerium/internal/policy" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/spf13/viper" ) func Test_validate(t *testing.T) { - testOptions := func() Options { - o := NewOptions() + o := defaultOptions o.SharedKey = "test" o.Services = "all" return o @@ -27,8 +25,10 @@ func Test_validate(t *testing.T) { badServices.Services = "blue" badSecret := testOptions() badSecret.SharedKey = "" - badRoutes := testOptions() - badRoutes.Routes = map[string]string{"foo": "bar"} + badSecret.Services = "authenticate" + badSecretAllServices := testOptions() + badSecretAllServices.SharedKey = "" + badPolicyFile := testOptions() badPolicyFile.PolicyFile = "file" @@ -40,12 +40,12 @@ func Test_validate(t *testing.T) { {"good default with no env settings", good, false}, {"invalid service type", badServices, true}, {"missing shared secret", badSecret, true}, - {"routes present", badRoutes, true}, + {"missing shared secret but all service", badSecretAllServices, false}, {"policy file specified", badPolicyFile, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := tt.testOpts.validate() + err := tt.testOpts.Validate() if (err != nil) != tt.wantErr { t.Errorf("optionsFromEnvConfig() error = %v, wantErr %v", err, tt.wantErr) return @@ -54,95 +54,6 @@ func Test_validate(t *testing.T) { } } -func Test_isValidService(t *testing.T) { - tests := []struct { - name string - service string - want bool - }{ - {"proxy", "proxy", true}, - {"all", "all", true}, - {"authenticate", "authenticate", true}, - {"authenticate bad case", "AuThenticate", false}, - {"authorize implemented", "authorize", true}, - {"jiberish", "xd23", false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := IsValidService(tt.service); got != tt.want { - t.Errorf("isValidService() = %v, want %v", got, tt.want) - } - }) - } -} - -func Test_isAuthenticate(t *testing.T) { - tests := []struct { - name string - service string - want bool - }{ - {"proxy", "proxy", false}, - {"all", "all", true}, - {"authenticate", "authenticate", true}, - {"authenticate bad case", "AuThenticate", false}, - {"authorize implemented", "authorize", false}, - {"jiberish", "xd23", false}, - } - for _, tt := range tests { - - t.Run(tt.name, func(t *testing.T) { - if got := IsAuthenticate(tt.service); got != tt.want { - t.Errorf("isAuthenticate() = %v, want %v", got, tt.want) - } - }) - } -} - -func Test_isAuthorize(t *testing.T) { - tests := []struct { - name string - service string - want bool - }{ - {"proxy", "proxy", false}, - {"all", "all", true}, - {"authorize", "authorize", true}, - {"authorize bad case", "AuThorize", false}, - {"authenticate implemented", "authenticate", false}, - {"jiberish", "xd23", false}, - } - for _, tt := range tests { - - t.Run(tt.name, func(t *testing.T) { - if got := IsAuthorize(tt.service); got != tt.want { - t.Errorf("isAuthenticate() = %v, want %v", got, tt.want) - } - }) - } -} -func Test_IsProxy(t *testing.T) { - tests := []struct { - name string - service string - want bool - }{ - {"proxy", "proxy", true}, - {"all", "all", true}, - {"authorize", "authorize", false}, - {"proxy bad case", "PrOxY", false}, - {"jiberish", "xd23", false}, - } - for _, tt := range tests { - - t.Run(tt.name, func(t *testing.T) { - if got := IsProxy(tt.service); got != tt.want { - t.Errorf("IsProxy() = %v, want %v", got, tt.want) - } - }) - } -} - func Test_bindEnvs(t *testing.T) { o := &Options{} os.Clearenv() @@ -171,44 +82,6 @@ func Test_bindEnvs(t *testing.T) { } } -func Test_parseURLs(t *testing.T) { - tests := []struct { - name string - authorizeURL string - authenticateURL string - authenticateInternalURL string - wantErr bool - }{ - {"good", "https://authz.mydomain.example", "https://authn.mydomain.example", "https://internal.svc.local", false}, - {"bad not https scheme", "http://authz.mydomain.example", "http://authn.mydomain.example", "http://internal.svc.local", true}, - {"missing scheme", "authz.mydomain.example", "authn.mydomain.example", "internal.svc.local", true}, - {"bad authorize", "notaurl", "https://authn.mydomain.example", "", true}, - {"bad authenticate", "https://authz.mydomain.example", "notaurl", "", true}, - {"bad authenticate internal", "", "", "just.some.naked.domain.example", true}, - {"only authn", "", "https://authn.mydomain.example", "", false}, - {"only authz", "https://authz.mydomain.example", "", "", false}, - {"malformed", "http://a b.com/", "", "", true}, - } - for _, test := range tests { - o := &Options{ - AuthenticateURLString: test.authenticateURL, - AuthorizeURLString: test.authorizeURL, - AuthenticateInternalAddrString: test.authenticateInternalURL, - } - err := o.parseURLs() - if (err != nil) != test.wantErr { - t.Errorf("Failed to parse URLs %v: %s", test, err) - } - if err == nil && o.AuthenticateURL.String() != test.authenticateURL { - t.Errorf("Failed to update AuthenticateURL: %v", test) - } - if err == nil && o.AuthorizeURL.String() != test.authorizeURL { - t.Errorf("Failed to update AuthorizeURL: %v", test) - } - } - -} - func Test_parseHeaders(t *testing.T) { tests := []struct { name string @@ -219,15 +92,15 @@ func Test_parseHeaders(t *testing.T) { }{ {"good env", map[string]string{"X-Custom-1": "foo", "X-Custom-2": "bar"}, `{"X-Custom-1":"foo", "X-Custom-2":"bar"}`, map[string]string{"X": "foo"}, false}, {"good env not_json", map[string]string{"X-Custom-1": "foo", "X-Custom-2": "bar"}, `X-Custom-1:foo,X-Custom-2:bar`, map[string]string{"X": "foo"}, false}, - {"good viper", map[string]string{"X-Custom-1": "foo", "X-Custom-2": "bar"}, "", map[string]string{"X-Custom-1": "foo", "X-Custom-2": "bar"}, false}, {"bad env", map[string]string{}, "xyyyy", map[string]string{"X": "foo"}, true}, {"bad env not_json", map[string]string{"X-Custom-1": "foo", "X-Custom-2": "bar"}, `X-Custom-1:foo,X-Custom-2bar`, map[string]string{"X": "foo"}, true}, {"bad viper", map[string]string{}, "", "notaheaderstruct", true}, + {"good viper", map[string]string{"X-Custom-1": "foo", "X-Custom-2": "bar"}, "", map[string]string{"X-Custom-1": "foo", "X-Custom-2": "bar"}, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - o := NewOptions() + o := defaultOptions viper.Set("headers", tt.viperHeaders) viper.Set("HeadersEnv", tt.envHeaders) o.HeadersEnv = tt.envHeaders @@ -241,23 +114,28 @@ func Test_parseHeaders(t *testing.T) { if !tt.wantErr && !cmp.Equal(tt.want, o.Headers) { t.Errorf("Did get expected headers: %s", cmp.Diff(tt.want, o.Headers)) } - + viper.Reset() }) } + } func Test_OptionsFromViper(t *testing.T) { - testPolicy := policy.Policy{ + viper.Reset() + + testPolicy := Policy{ To: "https://httpbin.org", From: "https://pomerium.io", } - testPolicy.Validate() - testPolicies := []policy.Policy{ + if err := testPolicy.Validate(); err != nil { + t.Fatal(err) + } + testPolicies := []Policy{ testPolicy, } goodConfigBytes := []byte(`{"authorize_service_url":"https://authorize.corp.example","authenticate_service_url":"https://authenticate.corp.example","shared_secret":"Setec Astronomy","service":"all","policy":[{"from":"https://pomerium.io","to":"https://httpbin.org"}]}`) - goodOptions := NewOptions() + goodOptions := defaultOptions goodOptions.SharedKey = "Setec Astronomy" goodOptions.Services = "all" goodOptions.Policies = testPolicies @@ -272,21 +150,23 @@ func Test_OptionsFromViper(t *testing.T) { if err != nil { t.Fatal(err) } - goodOptions.AuthorizeURL = *authorize - goodOptions.AuthenticateURL = *authenticate - + goodOptions.AuthorizeURL = authorize + goodOptions.AuthenticateURL = authenticate + if err := goodOptions.Validate(); err != nil { + t.Fatal(err) + } badConfigBytes := []byte("badjson!") badUnmarshalConfigBytes := []byte(`"debug": "blue"`) tests := []struct { name string configBytes []byte - want Options + want *Options wantErr bool }{ - {"good", goodConfigBytes, goodOptions, false}, - {"bad json", badConfigBytes, NewOptions(), true}, - {"bad unmarshal", badUnmarshalConfigBytes, NewOptions(), true}, + {"good", goodConfigBytes, &goodOptions, false}, + {"bad json", badConfigBytes, nil, true}, + {"bad unmarshal", badUnmarshalConfigBytes, nil, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -302,8 +182,13 @@ func Test_OptionsFromViper(t *testing.T) { if (err != nil) != tt.wantErr { t.Errorf("OptionsFromViper() error = \n%v, wantErr \n%v", err, tt.wantErr) } + if tt.want != nil { + if err := tt.want.Validate(); err != nil { + t.Fatal(err) + } + } if diff := cmp.Diff(got, tt.want); diff != "" { - t.Errorf("OptionsFromViper() = \n%s\n, \ngot\n%v\n, want \n%v", diff, got, tt.want) + t.Errorf("OptionsFromViper() = \n%s\n, \ngot\n%+v\n, want \n%+v", diff, got, tt.want) } }) @@ -318,6 +203,8 @@ func Test_OptionsFromViper(t *testing.T) { func Test_parsePolicyEnv(t *testing.T) { t.Parallel() + viper.Reset() + source := "https://pomerium.io" sourceURL, _ := url.ParseRequestURI(source) dest := "https://httpbin.org" @@ -326,12 +213,12 @@ func Test_parsePolicyEnv(t *testing.T) { tests := []struct { name string policyBytes []byte - want []policy.Policy + want []Policy wantErr bool }{ - {"simple json", []byte(fmt.Sprintf(`[{"from": "%s","to":"%s"}]`, source, dest)), []policy.Policy{{From: source, To: dest, Source: sourceURL, Destination: destURL}}, false}, - {"bad from", []byte(`[{"from": "%","to":"httpbin.org"}]`), nil, true}, - {"bad to", []byte(`[{"from": "pomerium.io","to":"%"}]`), nil, true}, + {"simple json", []byte(fmt.Sprintf(`[{"from": "%s","to":"%s"}]`, source, dest)), []Policy{{From: source, To: dest, Source: sourceURL, Destination: destURL}}, false}, + {"bad from", []byte(`[{"from": "%","to":"httpbin.org"}]`), []Policy{{From: "%", To: "httpbin.org"}}, true}, + {"bad to", []byte(`[{"from": "pomerium.io","to":"%"}]`), []Policy{{From: "pomerium.io", To: "%"}}, true}, {"simple error", []byte(`{}`), nil, true}, } for _, tt := range tests { @@ -341,11 +228,11 @@ func Test_parsePolicyEnv(t *testing.T) { o.PolicyEnv = base64.StdEncoding.EncodeToString(tt.policyBytes) err := o.parsePolicy() if (err != nil) != tt.wantErr { - t.Errorf("parasePolicy() error = %v, wantErr %v", err, tt.wantErr) + t.Errorf("parsePolicyEnv() error = %v, wantErr %v", err, tt.wantErr) return } - if !reflect.DeepEqual(o.Policies, tt.want) { - t.Errorf("parasePolicy() = \n%v, want \n%v", o, tt.want) + if diff := cmp.Diff(o.Policies, tt.want); diff != "" { + t.Errorf("parsePolicyEnv() = %s", diff) } }) } @@ -355,11 +242,12 @@ func Test_parsePolicyEnv(t *testing.T) { o.PolicyEnv = "foo" err := o.parsePolicy() if err == nil { - t.Errorf("parasePolicy() did not catch bad base64 %v", o) + t.Errorf("parsePolicyEnv() did not catch bad base64 %v", o) } } func Test_parsePolicyFile(t *testing.T) { + viper.Reset() source := "https://pomerium.io" sourceURL, _ := url.ParseRequestURI(source) dest := "https://httpbin.org" @@ -368,38 +256,41 @@ func Test_parsePolicyFile(t *testing.T) { tests := []struct { name string policyBytes []byte - want []policy.Policy + want []Policy wantErr bool }{ - {"simple json", []byte(fmt.Sprintf(`{"policy":[{"from": "%s","to":"%s"}]}`, source, dest)), []policy.Policy{{From: source, To: dest, Source: sourceURL, Destination: destURL}}, false}, + {"simple json", []byte(fmt.Sprintf(`{"policy":[{"from": "%s","to":"%s"}]}`, source, dest)), []Policy{{From: source, To: dest, Source: sourceURL, Destination: destURL}}, false}, {"bad from", []byte(`{"policy":[{"from": "%","to":"httpbin.org"}]}`), nil, true}, {"bad to", []byte(`{"policy":[{"from": "pomerium.io","to":"%"}]}`), nil, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - o := new(Options) - tempFile, _ := ioutil.TempFile("", "*.json") defer tempFile.Close() defer os.Remove(tempFile.Name()) tempFile.Write(tt.policyBytes) - o = new(Options) + o := new(Options) viper.SetConfigFile(tempFile.Name()) - err := viper.ReadInConfig() - err = o.parsePolicy() + if err := viper.ReadInConfig(); err != nil { + t.Fatal(err) + } + err := o.parsePolicy() if (err != nil) != tt.wantErr { - t.Errorf("parasePolicy() error = %v, wantErr %v", err, tt.wantErr) + t.Errorf("parsePolicyEnv() error = %v, wantErr %v", err, tt.wantErr) return } - if !reflect.DeepEqual(o.Policies, tt.want) { - t.Errorf("parasePolicy() = \n%v, want \n%v", o, tt.want) + if err == nil { + if diff := cmp.Diff(o.Policies, tt.want); diff != "" { + t.Errorf("parsePolicyEnv() = diff:%s", diff) + } } + }) } } func Test_Checksum(t *testing.T) { - o := NewOptions() + o := defaultOptions oldChecksum := o.Checksum() o.SharedKey = "changemeplease" @@ -417,3 +308,103 @@ func Test_Checksum(t *testing.T) { t.Error("Checksum() inconsistent") } } + +func TestNewOptions(t *testing.T) { + viper.Reset() + tests := []struct { + name string + authenticateURL string + authorizeURL string + want *Options + wantErr bool + }{ + {"good", "https://authenticate.example", "https://authorize.example", nil, false}, + {"bad authenticate url no scheme", "authenticate.example", "https://authorize.example", nil, true}, + {"bad authenticate url no host", "https://", "https://authorize.example", nil, true}, + {"bad authorize url no scheme", "https://authenticate.example", "authorize.example", nil, true}, + {"bad authorize url no host", "https://authenticate.example", "https://", nil, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := NewOptions(tt.authenticateURL, tt.authorizeURL) + if (err != nil) != tt.wantErr { + t.Errorf("NewOptions() error = %v, wantErr %v", err, tt.wantErr) + return + } + }) + } +} + +func TestOptionsFromViper(t *testing.T) { + opts := []cmp.Option{ + cmpopts.IgnoreFields(Options{}, "AuthenticateInternalAddr", "DefaultUpstreamTimeout", "CookieRefresh", "CookieExpire", "Services", "Addr", "RefreshCooldown", "LogLevel", "KeyFile", "CertFile", "SharedKey", "ReadTimeout", "ReadHeaderTimeout", "IdleTimeout"), + cmpopts.IgnoreFields(Policy{}, "Source", "Destination"), + } + + tests := []struct { + name string + configBytes []byte + want *Options + wantErr bool + }{ + {"good", + []byte(`{"policy":[{"from": "https://from.example","to":"https://to.example"}]}`), + &Options{ + Policies: []Policy{{From: "https://from.example", To: "https://to.example"}}, + CookieName: "_pomerium", + CookieSecure: true, + CookieHTTPOnly: true, + Headers: map[string]string{ + "Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload", + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "SAMEORIGIN", + "X-XSS-Protection": "1; mode=block", + }}, + false}, + {"good with authenticate internal url", + []byte(`{"authenticate_internal_url": "https://internal.example","policy":[{"from": "https://from.example","to":"https://to.example"}]}`), + &Options{ + AuthenticateInternalAddrString: "https://internal.example", + Policies: []Policy{{From: "https://from.example", To: "https://to.example"}}, + CookieName: "_pomerium", + CookieSecure: true, + CookieHTTPOnly: true, + Headers: map[string]string{ + "Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload", + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "SAMEORIGIN", + "X-XSS-Protection": "1; mode=block", + }}, + false}, + {"good disable header", + []byte(`{"headers": {"disable":"true"},"policy":[{"from": "https://from.example","to":"https://to.example"}]}`), + &Options{ + Policies: []Policy{{From: "https://from.example", To: "https://to.example"}}, + CookieName: "_pomerium", + CookieSecure: true, + CookieHTTPOnly: true, + Headers: map[string]string{}}, + false}, + {"bad authenticate internal url", []byte(`{"authenticate_internal_url": "internal.example","policy":[{"from": "https://from.example","to":"https://to.example"}]}`), nil, true}, + {"bad url", []byte(`{"policy":[{"from": "https://","to":"https://to.example"}]}`), nil, true}, + {"bad policy", []byte(`{"policy":[{"allow_public_unauthenticated_access": "dog","to":"https://to.example"}]}`), nil, true}, + + {"bad file", []byte(`{''''}`), nil, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tempFile, _ := ioutil.TempFile("", "*.json") + defer tempFile.Close() + defer os.Remove(tempFile.Name()) + tempFile.Write(tt.configBytes) + got, err := OptionsFromViper(tempFile.Name()) + if (err != nil) != tt.wantErr { + t.Errorf("OptionsFromViper() error = %v, wantErr %v", err, tt.wantErr) + return + } + if diff := cmp.Diff(got, tt.want, opts...); diff != "" { + t.Errorf("NewOptions() = %s", diff) + } + }) + } +} diff --git a/internal/config/policy.go b/internal/config/policy.go new file mode 100644 index 000000000..f6cebf739 --- /dev/null +++ b/internal/config/policy.go @@ -0,0 +1,71 @@ +package config // import "github.com/pomerium/pomerium/internal/config" + +import ( + "errors" + "fmt" + "net/url" + "time" + + "github.com/pomerium/pomerium/internal/urlutil" +) + +// Policy contains route specific configuration and access settings. +type Policy struct { + From string `mapstructure:"from" yaml:"from"` + To string `mapstructure:"to" yaml:"to"` + // Identity related policy + AllowedEmails []string `mapstructure:"allowed_users" yaml:"allowed_users"` + AllowedGroups []string `mapstructure:"allowed_groups" yaml:"allowed_groups"` + AllowedDomains []string `mapstructure:"allowed_domains" yaml:"allowed_domains"` + + Source *url.URL + Destination *url.URL + + // Allow unauthenticated HTTP OPTIONS requests as per the CORS spec + // https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#Preflighted_requests + CORSAllowPreflight bool `mapstructure:"cors_allow_preflight" yaml:"cors_allow_preflight"` + + // Allow any public request to access this route. **Bypasses authentication** + AllowPublicUnauthenticatedAccess bool `mapstructure:"allow_public_unauthenticated_access" yaml:"allow_public_unauthenticated_access"` + + // UpstreamTimeout is the route specific timeout. Must be less than the global + // timeout. If unset, route will fallback to the proxy's DefaultUpstreamTimeout. + UpstreamTimeout time.Duration `mapstructure:"timeout" yaml:"timeout"` + + // Enable proxying of websocket connections by removing the default timeout handler. + // Caution: Enabling this feature could result in abuse via DOS attacks. + AllowWebsockets bool `mapstructure:"allow_websockets" yaml:"allow_websockets"` + + // TLSSkipVerify controls whether a client verifies the server's certificate + // chain and host name. + // If TLSSkipVerify is true, TLS accepts any certificate presented by the + // server and any host name in that certificate. + // In this mode, TLS is susceptible to man-in-the-middle attacks. + // This should be used only for testing. + TLSSkipVerify bool `mapstructure:"tls_skip_verify" yaml:"tls_skip_verify"` + + // TLSCustomCA defines the root certificate to use with a given + // route when verifying server certificates. + TLSCustomCA string `mapstructure:"tls_custom_ca" yaml:"tls_custom_ca"` +} + +// Validate checks the validity of a policy. +func (p *Policy) Validate() error { + var err error + p.Source, err = urlutil.ParseAndValidateURL(p.From) + if err != nil { + return fmt.Errorf("internal/config: bad source url %s", err) + } + + p.Destination, err = urlutil.ParseAndValidateURL(p.To) + if err != nil { + return fmt.Errorf("internal/config: bad destination url %s", err) + } + + // Only allow public access if no other whitelists are in place + if p.AllowPublicUnauthenticatedAccess && (p.AllowedDomains != nil || p.AllowedGroups != nil || p.AllowedEmails != nil) { + return errors.New("internal/config: route marked as public but contains whitelists") + } + + return nil +} diff --git a/internal/config/policy_test.go b/internal/config/policy_test.go new file mode 100644 index 000000000..f580d1f06 --- /dev/null +++ b/internal/config/policy_test.go @@ -0,0 +1,43 @@ +package config // import "github.com/pomerium/pomerium/internal/config" + +import ( + "testing" +) + +func Test_Validate(t *testing.T) { + t.Parallel() + basePolicy := Policy{From: "https://httpbin.corp.example", To: "https://httpbin.corp.notatld"} + + corsPolicy := basePolicy + corsPolicy.CORSAllowPreflight = true + + publicPolicy := basePolicy + publicPolicy.AllowPublicUnauthenticatedAccess = true + + publicAndWhitelistPolicy := publicPolicy + publicAndWhitelistPolicy.AllowedEmails = []string{"test@gmail.com"} + + tests := []struct { + name string + policy Policy + wantErr bool + }{ + {"good", basePolicy, false}, + {"empty to host", Policy{From: "https://httpbin.corp.example", To: "https://"}, true}, + {"empty from host", Policy{From: "https://", To: "https://httpbin.corp.example"}, true}, + {"empty from scheme", Policy{From: "httpbin.corp.example", To: "https://httpbin.corp.example"}, true}, + {"empty to scheme", Policy{From: "https://httpbin.corp.example", To: "//httpbin.corp.example"}, true}, + {"cors policy", corsPolicy, false}, + {"public policy", publicPolicy, false}, + {"public and whitelist", publicAndWhitelistPolicy, true}, + {"route must have", publicAndWhitelistPolicy, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.policy.Validate() + if (err != nil) != tt.wantErr { + t.Errorf("Validate() error = %v, want %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/policy/testdata/basic.json b/internal/config/testdata/basic.json similarity index 100% rename from internal/policy/testdata/basic.json rename to internal/config/testdata/basic.json diff --git a/internal/policy/testdata/basic.yaml b/internal/config/testdata/basic.yaml similarity index 100% rename from internal/policy/testdata/basic.yaml rename to internal/config/testdata/basic.yaml diff --git a/internal/cryptutil/encrypt.go b/internal/cryptutil/encrypt.go index 2fc28b414..29826dd4a 100644 --- a/internal/cryptutil/encrypt.go +++ b/internal/cryptutil/encrypt.go @@ -13,14 +13,32 @@ import ( "golang.org/x/crypto/chacha20poly1305" ) +const DefaultKeySize = 32 + // GenerateKey generates a random 32-byte key. +// // Panics if source of randomness fails. func GenerateKey() []byte { - key := make([]byte, 32) - if _, err := rand.Read(key); err != nil { + return randomBytes(DefaultKeySize) +} + +// GenerateRandomString returns base64 encoded securely generated random string +// of a given set of bytes. +// +// Panics if source of randomness fails. +func GenerateRandomString(c int) string { + return base64.StdEncoding.EncodeToString(randomBytes(c)) +} + +func randomBytes(c int) []byte { + if c < 0 { + c = DefaultKeySize + } + b := make([]byte, c) + if _, err := rand.Read(b); err != nil { panic(err) } - return key + return b } // Cipher provides methods to encrypt and decrypt values. diff --git a/internal/cryptutil/encrypt_test.go b/internal/cryptutil/encrypt_test.go index 1c0a70383..14e02a60e 100644 --- a/internal/cryptutil/encrypt_test.go +++ b/internal/cryptutil/encrypt_test.go @@ -1,8 +1,9 @@ -package cryptutil // import "github.com/pomerium/pomerium/internal/cryptutil" +package cryptutil import ( "crypto/rand" "crypto/sha1" + "encoding/base64" "fmt" "reflect" "sync" @@ -162,3 +163,29 @@ func TestCipherDataRace(t *testing.T) { } wg.Wait() } + +func TestGenerateRandomString(t *testing.T) { + t.Parallel() + tests := []struct { + name string + c int + want int + }{ + {"simple", 32, 32}, + {"zero", 0, 0}, + {"negative", -1, 32}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + o := GenerateRandomString(tt.c) + b, err := base64.StdEncoding.DecodeString(o) + if err != nil { + t.Error(err) + } + got := len(b) + if got != tt.want { + t.Errorf("GenerateRandomString() = %d, want %d", got, tt.want) + } + }) + } +} diff --git a/internal/https/https.go b/internal/httputil/https.go similarity index 98% rename from internal/https/https.go rename to internal/httputil/https.go index ff459f3f3..4a9180c57 100644 --- a/internal/https/https.go +++ b/internal/httputil/https.go @@ -1,4 +1,4 @@ -package https // import "github.com/pomerium/pomerium/internal/https" +package httputil // import "github.com/pomerium/pomerium/internal/httputil" import ( "crypto/tls" @@ -14,6 +14,7 @@ import ( "github.com/pomerium/pomerium/internal/fileutil" "github.com/pomerium/pomerium/internal/log" + "google.golang.org/grpc" ) diff --git a/internal/metrics/exporter.go b/internal/metrics/exporter.go index 1295ef613..d32f799c4 100644 --- a/internal/metrics/exporter.go +++ b/internal/metrics/exporter.go @@ -18,8 +18,7 @@ func newPromHTTPHandler() http.Handler { // TODO this is a cheap way to get thorough go process // stats. It will not work with additional exporters. // It should turn into an FR to the OC framework - var reg *prom.Registry - reg = prom.DefaultRegisterer.(*prom.Registry) + reg := prom.DefaultRegisterer.(*prom.Registry) pe, _ := ocProm.NewExporter(ocProm.Options{ Namespace: "pomerium", Registry: reg, diff --git a/internal/metrics/middleware_test.go b/internal/metrics/middleware_test.go index 562e28e99..5898473eb 100644 --- a/internal/metrics/middleware_test.go +++ b/internal/metrics/middleware_test.go @@ -13,12 +13,6 @@ import ( "go.opencensus.io/stats/view" ) -type measure struct { - Name string - Tags map[string]string - Measure int -} - func newTestMux() http.Handler { mux := http.NewServeMux() mux.HandleFunc("/good", func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/middleware/reverse_proxy.go b/internal/middleware/reverse_proxy.go new file mode 100644 index 000000000..bd4e6e780 --- /dev/null +++ b/internal/middleware/reverse_proxy.go @@ -0,0 +1,42 @@ +package middleware // import "github.com/pomerium/pomerium/internal/middleware" + +import ( + "net/http" + "strings" + + "github.com/pomerium/pomerium/internal/cryptutil" + "github.com/pomerium/pomerium/internal/log" +) + +func SignRequest(signer cryptutil.JWTSigner, id, email, groups, header string) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + jwt, err := signer.SignJWT( + r.Header.Get(id), + r.Header.Get(email), + r.Header.Get(groups)) + if err != nil { + log.Warn().Err(err).Msg("internal/middleware: failed signing request") + } else { + r.Header.Set(header, jwt) + } + next.ServeHTTP(w, r) + }) + } +} + +// StripPomeriumCookie ensures that every response includes some basic security headers +func StripPomeriumCookie(cookieName string) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + headers := make([]string, len(r.Cookies())) + for _, cookie := range r.Cookies() { + if cookie.Name != cookieName { + headers = append(headers, cookie.String()) + } + } + r.Header.Set("Cookie", strings.Join(headers, ";")) + next.ServeHTTP(w, r) + }) + } +} diff --git a/internal/middleware/reverse_proxy_test.go b/internal/middleware/reverse_proxy_test.go new file mode 100644 index 000000000..f41b57916 --- /dev/null +++ b/internal/middleware/reverse_proxy_test.go @@ -0,0 +1,94 @@ +package middleware // import "github.com/pomerium/pomerium/internal/middleware" + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/pomerium/pomerium/internal/cryptutil" +) + +const exampleKey = `-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIM3mpZIWXCX9yEgxU6s57CbtbUNDBSCEAtQF5fUWHpcQoAoGCCqGSM49 +AwEHoUQDQgAEhPQv+LACPVNmBTK0xSTzbpEPkRrk1eUt1BOa32SEfUPzNi4IWeZ/ +KKITt2q1IqpV2KMSbVDyr9ijv/Xh98iyEw== +-----END EC PRIVATE KEY----- +` + +func TestSignRequest(t *testing.T) { + tests := []struct { + name string + + id string + email string + groups string + header string + }{ + {"good", "id", "email", "group", "Jwt"}, + } + req, err := http.NewRequest(http.MethodGet, "/", nil) + if err != nil { + t.Fatal(err) + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.Header.Set(fmt.Sprintf("%s-header", tt.id), tt.id) + r.Header.Set(fmt.Sprintf("%s-header", tt.email), tt.email) + r.Header.Set(fmt.Sprintf("%s-header", tt.groups), tt.groups) + + }) + rr := httptest.NewRecorder() + signer, err := cryptutil.NewES256Signer([]byte(exampleKey), "audience") + if err != nil { + t.Fatal(err) + } + + handler := SignRequest(signer, tt.id, tt.email, tt.groups, tt.header)(testHandler) + handler.ServeHTTP(rr, req) + jwt := req.Header["Jwt"] + if len(jwt) != 1 { + t.Errorf("no jwt found %v", req.Header) + } + }) + } +} + +func TestStripPomeriumCookie(t *testing.T) { + tests := []struct { + name string + pomeriumCookie string + otherCookies []string + }{ + {"good", "pomerium", []string{"x", "y", "z"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + for _, cookie := range r.Cookies() { + if cookie.Name == tt.pomeriumCookie { + t.Errorf("cookie not stripped %s", r.Cookies()) + } + } + }) + rr := httptest.NewRecorder() + for _, cn := range tt.otherCookies { + http.SetCookie(rr, &http.Cookie{ + Name: cn, + Value: "some other cookie", + }) + } + + http.SetCookie(rr, &http.Cookie{ + Name: tt.pomeriumCookie, + Value: "pomerium cookie!", + }) + req := &http.Request{Header: http.Header{"Cookie": rr.HeaderMap["Set-Cookie"]}} + + handler := StripPomeriumCookie(tt.pomeriumCookie)(testHandler) + handler.ServeHTTP(rr, req) + + }) + } +} diff --git a/internal/policy/policy.go b/internal/policy/policy.go deleted file mode 100644 index 7db19dcff..000000000 --- a/internal/policy/policy.go +++ /dev/null @@ -1,64 +0,0 @@ -package policy // import "github.com/pomerium/pomerium/internal/policy" - -import ( - "errors" - "fmt" - "net/url" - "strings" - "time" -) - -// Policy contains authorization policy information. -// todo(bdd) : add upstream timeout and configuration settings -type Policy struct { - // - From string `mapstructure:"from" yaml:"from"` - To string `mapstructure:"to" yaml:"to"` - // Identity related policy - AllowedEmails []string `mapstructure:"allowed_users" yaml:"allowed_users"` - AllowedGroups []string `mapstructure:"allowed_groups" yaml:"allowed_groups"` - AllowedDomains []string `mapstructure:"allowed_domains" yaml:"allowed_domains"` - - Source *url.URL - Destination *url.URL - - // Allow unauthenticated HTTP OPTIONS requests as per the CORS spec - // https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#Preflighted_requests - CORSAllowPreflight bool `mapstructure:"cors_allow_preflight" yaml:"cors_allow_preflight"` - - // Allow any public request to access this route. **Bypasses authentication** - AllowPublicUnauthenticatedAccess bool `mapstructure:"allow_public_unauthenticated_access" yaml:"allow_public_unauthenticated_access"` - - // UpstreamTimeout is the route specific timeout. Must be less than the global - // timeout. If unset, route will fallback to the proxy's DefaultUpstreamTimeout. - UpstreamTimeout time.Duration `mapstructure:"timeout" yaml:"timeout"` -} - -// Validate parses the source and destination URLs in the Policy -func (p *Policy) Validate() (err error) { - p.Source, err = urlParse(p.From) - if err != nil { - return err - } - - p.Destination, err = urlParse(p.To) - if err != nil { - return err - } - - // Only allow public access if no other whitelists are in place - if p.AllowPublicUnauthenticatedAccess && (p.AllowedDomains != nil || p.AllowedGroups != nil || p.AllowedEmails != nil) { - return errors.New("route marked as public but contains whitelists") - } - - return nil -} - -// URLParse wraps url.Parse to add a scheme if none-exists. -// https://github.com/golang/go/issues/12585 -func urlParse(uri string) (*url.URL, error) { - if !strings.Contains(uri, "://") { - uri = fmt.Sprintf("https://%s", uri) - } - return url.ParseRequestURI(uri) -} diff --git a/internal/policy/policy_test.go b/internal/policy/policy_test.go deleted file mode 100644 index 3c296dff0..000000000 --- a/internal/policy/policy_test.go +++ /dev/null @@ -1,66 +0,0 @@ -package policy - -import ( - "net/url" - "reflect" - "testing" -) - -func Test_urlParse(t *testing.T) { - t.Parallel() - tests := []struct { - name string - uri string - want *url.URL - wantErr bool - }{ - {"good url without schema", "accounts.google.com", &url.URL{Scheme: "https", Host: "accounts.google.com"}, false}, - {"good url with schema", "https://accounts.google.com", &url.URL{Scheme: "https", Host: "accounts.google.com"}, false}, - {"bad url, malformed", "https://accounts.google.^", nil, true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := urlParse(tt.uri) - if (err != nil) != tt.wantErr { - t.Errorf("urlParse() error = %v, wantErr %v", err, tt.wantErr) - return - } - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("urlParse() = %v, want %v", got, tt.want) - } - }) - } -} - -func Test_Validate(t *testing.T) { - t.Parallel() - basePolicy := Policy{From: "httpbin.corp.example", To: "httpbin.corp.notatld"} - - corsPolicy := basePolicy - corsPolicy.CORSAllowPreflight = true - - publicPolicy := basePolicy - publicPolicy.AllowPublicUnauthenticatedAccess = true - - publicAndWhitelistPolicy := publicPolicy - publicAndWhitelistPolicy.AllowedEmails = []string{"test@gmail.com"} - - tests := []struct { - name string - policy Policy - wantErr bool - }{ - {"good", basePolicy, false}, - {"cors policy", corsPolicy, false}, - {"public policy", publicPolicy, false}, - {"public and whitelist", publicAndWhitelistPolicy, true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := tt.policy.Validate() - if (err != nil) != tt.wantErr { - t.Errorf("Validate() error = %v, want %v", err, tt.wantErr) - } - }) - } -} diff --git a/internal/sessions/rest_store.go b/internal/sessions/rest_store.go index 8bd3effcd..f2eadd3ff 100644 --- a/internal/sessions/rest_store.go +++ b/internal/sessions/rest_store.go @@ -57,7 +57,6 @@ func (s *RestStore) ClearSession(w http.ResponseWriter, r *http.Request) { "error_description": "The token has expired." }` w.Write([]byte(errMsg)) - return } // LoadSession attempts to load a pomerium session from a Bearer Token set diff --git a/internal/urlutil/url.go b/internal/urlutil/url.go index 7f5d8caa9..cc90c893e 100644 --- a/internal/urlutil/url.go +++ b/internal/urlutil/url.go @@ -1,6 +1,10 @@ package urlutil // import "github.com/pomerium/pomerium/internal/urlutil" -import "strings" +import ( + "fmt" + "net/url" + "strings" +) // StripPort returns a host, without any port number. // @@ -17,3 +21,19 @@ func StripPort(hostport string) string { } return hostport[:colon] } + +// ParseAndValidateURL wraps standard library's default url.Parse because +// it's much more lenient about what type of urls it accepts than pomerium. +func ParseAndValidateURL(rawurl string) (*url.URL, error) { + u, err := url.Parse(rawurl) + if err != nil { + return nil, err + } + if u.Scheme == "" { + return nil, fmt.Errorf("%s url does contain a valid scheme. Did you mean https://%s?", rawurl, rawurl) + } + if u.Host == "" { + return nil, fmt.Errorf("%s url does contain a valid hostname", rawurl) + } + return u, nil +} diff --git a/internal/urlutil/url_test.go b/internal/urlutil/url_test.go index 40dd1c4eb..707ce4346 100644 --- a/internal/urlutil/url_test.go +++ b/internal/urlutil/url_test.go @@ -1,6 +1,11 @@ -package urlutil // import "github.com/pomerium/pomerium/internal/urlutil" +package urlutil -import "testing" +import ( + "net/url" + "testing" + + "github.com/google/go-cmp/cmp" +) func Test_StripPort(t *testing.T) { t.Parallel() @@ -27,3 +32,30 @@ func Test_StripPort(t *testing.T) { }) } } + +func TestParseAndValidateURL(t *testing.T) { + + tests := []struct { + name string + rawurl string + want *url.URL + wantErr bool + }{ + {"good", "https://some.example", &url.URL{Scheme: "https", Host: "some.example"}, false}, + {"bad schema", "//some.example", nil, true}, + {"bad hostname", "https://", nil, true}, + {"bad parse", "https://^", nil, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseAndValidateURL(tt.rawurl) + if (err != nil) != tt.wantErr { + t.Errorf("ParseAndValidateURL() error = %v, wantErr %v", err, tt.wantErr) + return + } + if diff := cmp.Diff(got, tt.want); diff != "" { + t.Errorf("TestParseAndValidateURL() = %s", diff) + } + }) + } +} diff --git a/proxy/clients/authenticate_client_test.go b/proxy/clients/authenticate_client_test.go index 451eef1d1..d28b6e8f4 100644 --- a/proxy/clients/authenticate_client_test.go +++ b/proxy/clients/authenticate_client_test.go @@ -3,6 +3,7 @@ package clients // import "github.com/pomerium/pomerium/proxy/clients" import ( "context" "fmt" + "net/url" "reflect" "strings" "testing" @@ -23,8 +24,8 @@ func TestNew(t *testing.T) { opts *Options wantErr bool }{ - {"grpc good", "grpc", &Options{Addr: "test", InternalAddr: "intranet.local", SharedSecret: "secret"}, false}, - {"grpc missing shared secret", "grpc", &Options{Addr: "test", InternalAddr: "intranet.local", SharedSecret: ""}, true}, + {"grpc good", "grpc", &Options{Addr: &url.URL{Scheme: "https", Host: "localhost.example"}, InternalAddr: &url.URL{Scheme: "https", Host: "localhost.example"}, SharedSecret: "secret"}, false}, + {"grpc missing shared secret", "grpc", &Options{Addr: &url.URL{Scheme: "https", Host: "localhost.example"}, InternalAddr: &url.URL{Scheme: "https", Host: "localhost.example"}, SharedSecret: ""}, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -211,15 +212,17 @@ func TestNewGRPC(t *testing.T) { wantTarget string }{ {"no shared secret", &Options{}, true, "proxy/authenticator: grpc client requires shared secret", ""}, - {"empty connection", &Options{Addr: "", SharedSecret: "shh"}, true, "proxy/authenticator: connection address required", ""}, - {"both internal and addr empty", &Options{Addr: "", InternalAddr: "", SharedSecret: "shh"}, true, "proxy/authenticator: connection address required", ""}, - {"internal addr with port", &Options{Addr: "", InternalAddr: "intranet.local:8443", SharedSecret: "shh"}, false, "", "intranet.local:8443"}, - {"internal addr without port", &Options{Addr: "", InternalAddr: "intranet.local", SharedSecret: "shh"}, false, "", "intranet.local:443"}, - {"cert override", &Options{Addr: "", InternalAddr: "intranet.local", OverrideCertificateName: "*.local", SharedSecret: "shh"}, false, "", "intranet.local:443"}, - {"custom ca", &Options{Addr: "", InternalAddr: "intranet.local", OverrideCertificateName: "*.local", SharedSecret: "shh", CA: "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURFVENDQWZrQ0ZBWHhneFg5K0hjWlBVVVBEK0laV0NGNUEvVTdNQTBHQ1NxR1NJYjNEUUVCQ3dVQU1FVXgKQ3pBSkJnTlZCQVlUQWtGVk1STXdFUVlEVlFRSURBcFRiMjFsTFZOMFlYUmxNU0V3SHdZRFZRUUtEQmhKYm5SbApjbTVsZENCWGFXUm5hWFJ6SUZCMGVTQk1kR1F3SGhjTk1Ua3dNakk0TVRnMU1EQTNXaGNOTWprd01qSTFNVGcxCk1EQTNXakJGTVFzd0NRWURWUVFHRXdKQlZURVRNQkVHQTFVRUNBd0tVMjl0WlMxVGRHRjBaVEVoTUI4R0ExVUUKQ2d3WVNXNTBaWEp1WlhRZ1YybGtaMmwwY3lCUWRIa2dUSFJrTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQwpBUThBTUlJQkNnS0NBUUVBOVRFMEFiaTdnMHhYeURkVUtEbDViNTBCT05ZVVVSc3F2THQrSWkwdlpjMzRRTHhOClJrT0hrOFZEVUgzcUt1N2UrNGVubUdLVVNUdzRPNFlkQktiSWRJTFpnb3o0YitNL3FVOG5adVpiN2pBVTdOYWkKajMzVDVrbXB3L2d4WHNNUzNzdUpXUE1EUDB3Z1BUZUVRK2J1bUxVWmpLdUVIaWNTL0l5dmtaVlBzRlE4NWlaUwpkNXE2a0ZGUUdjWnFXeFg0dlhDV25Sd3E3cHY3TThJd1RYc1pYSVRuNXB5Z3VTczNKb29GQkg5U3ZNTjRKU25GCmJMK0t6ekduMy9ScXFrTXpMN3FUdkMrNWxVT3UxUmNES21mZXBuVGVaN1IyVnJUQm42NndWMjVHRnBkSDIzN00KOXhJVkJrWEd1U2NvWHVPN1lDcWFrZkt6aXdoRTV4UmRaa3gweXdJREFRQUJNQTBHQ1NxR1NJYjNEUUVCQ3dVQQpBNElCQVFCaHRWUEI0OCs4eFZyVmRxM1BIY3k5QkxtVEtrRFl6N2Q0ODJzTG1HczBuVUdGSTFZUDdmaFJPV3ZxCktCTlpkNEI5MUpwU1NoRGUrMHpoNno4WG5Ha01mYnRSYWx0NHEwZ3lKdk9hUWhqQ3ZCcSswTFk5d2NLbXpFdnMKcTRiNUZ5NXNpRUZSekJLTmZtTGwxTTF2cW1hNmFCVnNYUUhPREdzYS83dE5MalZ2ay9PYm52cFg3UFhLa0E3cQpLMTQvV0tBRFBJWm9mb00xMzB4Q1RTYXVpeXROajlnWkx1WU9leEZhblVwNCt2MHBYWS81OFFSNTk2U0ROVTlKClJaeDhwTzBTaUYvZXkxVUZXbmpzdHBjbTQzTFVQKzFwU1hFeVhZOFJrRTI2QzNvdjNaTFNKc2pMbC90aXVqUlgKZUJPOWorWDdzS0R4amdtajBPbWdpVkpIM0YrUAotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg=="}, false, "", "intranet.local:443"}, - {"bad ca encoding", &Options{Addr: "", InternalAddr: "intranet.local", OverrideCertificateName: "*.local", SharedSecret: "shh", CA: "^"}, true, "", "intranet.local:443"}, - {"custom ca file", &Options{Addr: "", InternalAddr: "intranet.local", OverrideCertificateName: "*.local", SharedSecret: "shh", CAFile: "testdata/example.crt"}, false, "", "intranet.local:443"}, - {"bad custom ca file", &Options{Addr: "", InternalAddr: "intranet.local", OverrideCertificateName: "*.local", SharedSecret: "shh", CAFile: "testdata/example.crt2"}, true, "", "intranet.local:443"}, + {"empty connection", &Options{Addr: nil, SharedSecret: "shh"}, true, "proxy/authenticator: connection address required", ""}, + {"both internal and addr empty", &Options{Addr: nil, InternalAddr: nil, SharedSecret: "shh"}, true, "proxy/authenticator: connection address required", ""}, + {"addr with port", &Options{Addr: &url.URL{Scheme: "https", Host: "localhost.example:8443"}, SharedSecret: "shh"}, false, "", "localhost.example:8443"}, + {"addr without port", &Options{Addr: &url.URL{Scheme: "https", Host: "localhost.example"}, SharedSecret: "shh"}, false, "", "localhost.example:443"}, + {"internal addr with port", &Options{Addr: nil, InternalAddr: &url.URL{Scheme: "https", Host: "localhost.example:8443"}, SharedSecret: "shh"}, false, "", "localhost.example:8443"}, + {"internal addr without port", &Options{Addr: nil, InternalAddr: &url.URL{Scheme: "https", Host: "localhost.example"}, SharedSecret: "shh"}, false, "", "localhost.example:443"}, + {"cert override", &Options{Addr: nil, InternalAddr: &url.URL{Scheme: "https", Host: "localhost.example"}, OverrideCertificateName: "*.local", SharedSecret: "shh"}, false, "", "localhost.example:443"}, + {"custom ca", &Options{Addr: nil, InternalAddr: &url.URL{Scheme: "https", Host: "localhost.example"}, OverrideCertificateName: "*.local", SharedSecret: "shh", CA: "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURFVENDQWZrQ0ZBWHhneFg5K0hjWlBVVVBEK0laV0NGNUEvVTdNQTBHQ1NxR1NJYjNEUUVCQ3dVQU1FVXgKQ3pBSkJnTlZCQVlUQWtGVk1STXdFUVlEVlFRSURBcFRiMjFsTFZOMFlYUmxNU0V3SHdZRFZRUUtEQmhKYm5SbApjbTVsZENCWGFXUm5hWFJ6SUZCMGVTQk1kR1F3SGhjTk1Ua3dNakk0TVRnMU1EQTNXaGNOTWprd01qSTFNVGcxCk1EQTNXakJGTVFzd0NRWURWUVFHRXdKQlZURVRNQkVHQTFVRUNBd0tVMjl0WlMxVGRHRjBaVEVoTUI4R0ExVUUKQ2d3WVNXNTBaWEp1WlhRZ1YybGtaMmwwY3lCUWRIa2dUSFJrTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQwpBUThBTUlJQkNnS0NBUUVBOVRFMEFiaTdnMHhYeURkVUtEbDViNTBCT05ZVVVSc3F2THQrSWkwdlpjMzRRTHhOClJrT0hrOFZEVUgzcUt1N2UrNGVubUdLVVNUdzRPNFlkQktiSWRJTFpnb3o0YitNL3FVOG5adVpiN2pBVTdOYWkKajMzVDVrbXB3L2d4WHNNUzNzdUpXUE1EUDB3Z1BUZUVRK2J1bUxVWmpLdUVIaWNTL0l5dmtaVlBzRlE4NWlaUwpkNXE2a0ZGUUdjWnFXeFg0dlhDV25Sd3E3cHY3TThJd1RYc1pYSVRuNXB5Z3VTczNKb29GQkg5U3ZNTjRKU25GCmJMK0t6ekduMy9ScXFrTXpMN3FUdkMrNWxVT3UxUmNES21mZXBuVGVaN1IyVnJUQm42NndWMjVHRnBkSDIzN00KOXhJVkJrWEd1U2NvWHVPN1lDcWFrZkt6aXdoRTV4UmRaa3gweXdJREFRQUJNQTBHQ1NxR1NJYjNEUUVCQ3dVQQpBNElCQVFCaHRWUEI0OCs4eFZyVmRxM1BIY3k5QkxtVEtrRFl6N2Q0ODJzTG1HczBuVUdGSTFZUDdmaFJPV3ZxCktCTlpkNEI5MUpwU1NoRGUrMHpoNno4WG5Ha01mYnRSYWx0NHEwZ3lKdk9hUWhqQ3ZCcSswTFk5d2NLbXpFdnMKcTRiNUZ5NXNpRUZSekJLTmZtTGwxTTF2cW1hNmFCVnNYUUhPREdzYS83dE5MalZ2ay9PYm52cFg3UFhLa0E3cQpLMTQvV0tBRFBJWm9mb00xMzB4Q1RTYXVpeXROajlnWkx1WU9leEZhblVwNCt2MHBYWS81OFFSNTk2U0ROVTlKClJaeDhwTzBTaUYvZXkxVUZXbmpzdHBjbTQzTFVQKzFwU1hFeVhZOFJrRTI2QzNvdjNaTFNKc2pMbC90aXVqUlgKZUJPOWorWDdzS0R4amdtajBPbWdpVkpIM0YrUAotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg=="}, false, "", "localhost.example:443"}, + {"bad ca encoding", &Options{Addr: nil, InternalAddr: &url.URL{Scheme: "https", Host: "localhost.example"}, OverrideCertificateName: "*.local", SharedSecret: "shh", CA: "^"}, true, "", "localhost.example:443"}, + {"custom ca file", &Options{Addr: nil, InternalAddr: &url.URL{Scheme: "https", Host: "localhost.example"}, OverrideCertificateName: "*.local", SharedSecret: "shh", CAFile: "testdata/example.crt"}, false, "", "localhost.example:443"}, + {"bad custom ca file", &Options{Addr: nil, InternalAddr: &url.URL{Scheme: "https", Host: "localhost.example"}, OverrideCertificateName: "*.local", SharedSecret: "shh", CAFile: "testdata/example.crt2"}, true, "", "localhost.example:443"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/proxy/clients/authorize_client_test.go b/proxy/clients/authorize_client_test.go index fb7198d27..66b6ca349 100644 --- a/proxy/clients/authorize_client_test.go +++ b/proxy/clients/authorize_client_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/golang/mock/gomock" + "github.com/pomerium/pomerium/internal/sessions" "github.com/pomerium/pomerium/proto/authorize" mock "github.com/pomerium/pomerium/proto/authorize/mock_authorize" diff --git a/proxy/clients/clients.go b/proxy/clients/clients.go index ec86d1c37..d0b6367ec 100644 --- a/proxy/clients/clients.go +++ b/proxy/clients/clients.go @@ -7,15 +7,15 @@ import ( "errors" "fmt" "io/ioutil" + "net/url" "strings" + "github.com/pomerium/pomerium/internal/log" "github.com/pomerium/pomerium/internal/metrics" + "github.com/pomerium/pomerium/internal/middleware" "google.golang.org/grpc" "google.golang.org/grpc/credentials" - - "github.com/pomerium/pomerium/internal/log" - "github.com/pomerium/pomerium/internal/middleware" ) const defaultGRPCPort = 443 @@ -23,10 +23,10 @@ const defaultGRPCPort = 443 // Options contains options for connecting to a pomerium rpc service. type Options struct { // Addr is the location of the authenticate service. e.g. "service.corp.example:8443" - Addr string + Addr *url.URL // InternalAddr is the internal (behind the ingress) address to use when // making a connection. If empty, Addr is used. - InternalAddr string + InternalAddr *url.URL // OverrideCertificateName overrides the server name used to verify the hostname on the // returned certificates from the server. gRPC internals also use it to override the virtual // hosting name if it is set. @@ -45,16 +45,17 @@ func NewGRPCClientConn(opts *Options) (*grpc.ClientConn, error) { if opts.SharedSecret == "" { return nil, errors.New("proxy/clients: grpc client requires shared secret") } + if opts.InternalAddr == nil && opts.Addr == nil { + return nil, errors.New("proxy/clients: connection address required") + + } grpcAuth := middleware.NewSharedSecretCred(opts.SharedSecret) var connAddr string - if opts.InternalAddr != "" { - connAddr = opts.InternalAddr + if opts.InternalAddr != nil { + connAddr = opts.InternalAddr.Host } else { - connAddr = opts.Addr - } - if connAddr == "" { - return nil, errors.New("proxy/clients: connection address required") + connAddr = opts.Addr.Host } // no colon exists in the connection string, assume one must be added manually if !strings.Contains(connAddr, ":") { diff --git a/proxy/handlers.go b/proxy/handlers.go index 95793136f..33413c8af 100644 --- a/proxy/handlers.go +++ b/proxy/handlers.go @@ -9,12 +9,10 @@ import ( "time" "github.com/pomerium/pomerium/internal/config" - "github.com/pomerium/pomerium/internal/cryptutil" "github.com/pomerium/pomerium/internal/httputil" "github.com/pomerium/pomerium/internal/log" "github.com/pomerium/pomerium/internal/middleware" - "github.com/pomerium/pomerium/internal/policy" "github.com/pomerium/pomerium/internal/sessions" "github.com/pomerium/pomerium/internal/templates" ) @@ -345,7 +343,6 @@ func (p *Proxy) UserDashboard(w http.ResponseWriter, r *http.Request) { CSRF: csrf.SessionID, } templates.New().ExecuteTemplate(w, "dashboard.html", t) - return } // Refresh redeems and extends an existing authenticated oidc session with @@ -366,8 +363,7 @@ func (p *Proxy) Refresh(w http.ResponseWriter, r *http.Request) { return } - // reject a refresh if it's been less than 5 minutes to prevent a bad actor - // trying to DOS the identity provider. + // reject a refresh if it's been less than the refresh cooldown to prevent abuse if time.Since(iss) < p.refreshCooldown { log.FromRequest(r).Error().Dur("cooldown", p.refreshCooldown).Err(err).Msg("proxy: refresh cooldown") httpErr := &httputil.Error{ @@ -467,22 +463,21 @@ func (p *Proxy) authenticate(w http.ResponseWriter, r *http.Request, s *sessions if err != nil { return fmt.Errorf("proxy: session refresh failed : %v", err) } - err = p.sessionStore.SaveSession(w, r, s) - if err != nil { + if err := p.sessionStore.SaveSession(w, r, s); err != nil { return fmt.Errorf("proxy: refresh failed : %v", err) } } else { valid, err := p.AuthenticateClient.Validate(r.Context(), s.IDToken) if err != nil || !valid { - return fmt.Errorf("proxy: session valid: %v : %v", valid, err) + return fmt.Errorf("proxy: session validate failed: %v : %v", valid, err) } } return nil } // router attempts to find a route for a request. If a route is successfully matched, -// it returns the route information and a bool value of `true`. If a route can not be matched, -// a nil value for the route and false bool value is returned. +// it returns the route information and a bool value of `true`. If a route can +// not be matched, a nil value for the route and false bool value is returned. func (p *Proxy) router(r *http.Request) (http.Handler, bool) { config, ok := p.routeConfigs[r.Host] if ok { @@ -494,7 +489,7 @@ func (p *Proxy) router(r *http.Request) (http.Handler, bool) { // policy attempts to find a policy for a request. If a policy is successfully matched, // it returns the policy information and a bool value of `true`. If a policy can not be matched, // a nil value for the policy and false bool value is returned. -func (p *Proxy) policy(r *http.Request) (*policy.Policy, bool) { +func (p *Proxy) policy(r *http.Request) (*config.Policy, bool) { config, ok := p.routeConfigs[r.Host] if ok { return &config.policy, true @@ -546,32 +541,3 @@ func (p *Proxy) GetSignOutURL(authenticateURL, redirectURL *url.URL) *url.URL { a.RawQuery = params.Encode() return a } - -func extendDeadline(ttl time.Duration) time.Time { - return time.Now().Add(ttl).Truncate(time.Second) -} - -// websocketHandlerFunc splits request serving with timeouts depending on the protocol -func websocketHandlerFunc(baseHandler http.Handler, timeoutHandler http.Handler, o config.Options) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - - // Do not use timeouts for websockets because they are long-lived connections. - if r.ProtoMajor == 1 && - strings.EqualFold(r.Header.Get("Connection"), "upgrade") && - strings.EqualFold(r.Header.Get("Upgrade"), "websocket") { - - if o.AllowWebsockets { - baseHandler.ServeHTTP(w, r) - return - } - - log.FromRequest(r).Warn().Msg("proxy: attempt to proxy a websocket connection, but websocket support is disabled in the configuration") - httpErr := &httputil.Error{Message: "websockets not supported by proxy", Code: http.StatusBadRequest} - httputil.ErrorResponse(w, r, httpErr) - return - } - - // All other non-websocket requests are served with timeouts to prevent abuse - timeoutHandler.ServeHTTP(w, r) - }) -} diff --git a/proxy/handlers_test.go b/proxy/handlers_test.go index 13e6926b3..96f5ada6b 100644 --- a/proxy/handlers_test.go +++ b/proxy/handlers_test.go @@ -14,7 +14,6 @@ import ( "github.com/pomerium/pomerium/internal/config" "github.com/pomerium/pomerium/internal/cryptutil" - "github.com/pomerium/pomerium/internal/policy" "github.com/pomerium/pomerium/internal/sessions" "github.com/pomerium/pomerium/proxy/clients" ) @@ -108,13 +107,12 @@ func TestProxy_GetSignOutURL(t *testing.T) { redirect string wantPrefix string }{ - {"without scheme", "auth.corp.pomerium.io", "hello.corp.pomerium.io", "https://auth.corp.pomerium.io/sign_out?redirect_uri=https%3A%2F%2Fhello.corp.pomerium.io"}, - {"with scheme", "https://auth.corp.pomerium.io", "https://hello.corp.pomerium.io", "https://auth.corp.pomerium.io/sign_out?redirect_uri=https%3A%2F%2Fhello.corp.pomerium.io"}, + {"good", "https://auth.corp.pomerium.io", "https://hello.corp.pomerium.io", "https://auth.corp.pomerium.io/sign_out?redirect_uri=https%3A%2F%2Fhello.corp.pomerium.io"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - authenticateURL, _ := urlParse(tt.authenticate) - redirectURL, _ := urlParse(tt.redirect) + authenticateURL, _ := url.Parse(tt.authenticate) + redirectURL, _ := url.Parse(tt.redirect) p := &Proxy{} // signature is ignored as it is tested above. Avoids testing time.Now @@ -135,14 +133,13 @@ func TestProxy_GetSignInURL(t *testing.T) { wantPrefix string }{ - {"without scheme", "auth.corp.pomerium.io", "hello.corp.pomerium.io", "example_state", "https://auth.corp.pomerium.io/sign_in?redirect_uri=https%3A%2F%2Fhello.corp.pomerium.io&response_type=code&shared_secret=shared-secret"}, - {"with scheme", "https://auth.corp.pomerium.io", "https://hello.corp.pomerium.io", "example_state", "https://auth.corp.pomerium.io/sign_in?redirect_uri=https%3A%2F%2Fhello.corp.pomerium.io&response_type=code&shared_secret=shared-secret"}, + {"good", "https://auth.corp.pomerium.io", "https://hello.corp.pomerium.io", "example_state", "https://auth.corp.pomerium.io/sign_in?redirect_uri=https%3A%2F%2Fhello.corp.pomerium.io&response_type=code&shared_secret=shared-secret"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { p := &Proxy{SharedKey: "shared-secret"} - authenticateURL, _ := urlParse(tt.authenticate) - redirectURL, _ := urlParse(tt.redirect) + authenticateURL, _ := url.Parse(tt.authenticate) + redirectURL, _ := url.Parse(tt.redirect) if got := p.GetSignInURL(authenticateURL, redirectURL, tt.state); !strings.HasPrefix(got.String(), tt.wantPrefix) { t.Errorf("Proxy.GetSignOutURL() = %v, wantPrefix %v", got.String(), tt.wantPrefix) @@ -153,7 +150,12 @@ func TestProxy_GetSignInURL(t *testing.T) { } func TestProxy_Signout(t *testing.T) { - proxy, err := New(testOptions()) + opts := testOptions(t) + err := ValidateOptions(opts) + if err != nil { + t.Fatal(err) + } + proxy, err := New(opts) if err != nil { t.Fatal(err) } @@ -171,7 +173,7 @@ func TestProxy_Signout(t *testing.T) { } func TestProxy_OAuthStart(t *testing.T) { - proxy, err := New(testOptions()) + proxy, err := New(testOptions(t)) if err != nil { t.Fatal(err) } @@ -184,14 +186,14 @@ func TestProxy_OAuthStart(t *testing.T) { t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusFound) } // expected url - expected := `> 1)) tests := []struct { @@ -455,7 +440,7 @@ func TestProxy_Refresh(t *testing.T) { } func TestProxy_Impersonate(t *testing.T) { - opts := testOptions() + opts := testOptions(t) tests := []struct { name string @@ -535,7 +520,7 @@ func TestProxy_OAuthCallback(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - proxy, err := New(testOptions()) + proxy, err := New(testOptions(t)) if err != nil { t.Fatal(err) } @@ -576,7 +561,7 @@ func TestProxy_SignOut(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - opts := testOptions() + opts := testOptions(t) p, err := New(opts) if err != nil { t.Fatal(err) diff --git a/proxy/proxy.go b/proxy/proxy.go index 5e8e36bf6..75ed590e2 100755 --- a/proxy/proxy.go +++ b/proxy/proxy.go @@ -1,6 +1,8 @@ package proxy // import "github.com/pomerium/pomerium/proxy" import ( + "crypto/tls" + "crypto/x509" "encoding/base64" "errors" "fmt" @@ -9,14 +11,13 @@ import ( "net/http" "net/http/httputil" "net/url" - "strings" "time" "github.com/pomerium/pomerium/internal/config" "github.com/pomerium/pomerium/internal/cryptutil" "github.com/pomerium/pomerium/internal/log" "github.com/pomerium/pomerium/internal/metrics" - "github.com/pomerium/pomerium/internal/policy" + "github.com/pomerium/pomerium/internal/middleware" "github.com/pomerium/pomerium/internal/sessions" "github.com/pomerium/pomerium/internal/templates" "github.com/pomerium/pomerium/internal/tripper" @@ -44,13 +45,13 @@ func ValidateOptions(o config.Options) error { if len(decoded) != 32 { return fmt.Errorf("`SHARED_SECRET` want 32 but got %d bytes", len(decoded)) } - if o.AuthenticateURL.String() == "" { + if o.AuthenticateURL == nil || o.AuthenticateURL.String() == "" { return errors.New("missing setting: authenticate-service-url") } if o.AuthenticateURL.Scheme != "https" { return errors.New("authenticate-service-url must be a valid https url") } - if o.AuthorizeURL.String() == "" { + if o.AuthorizeURL == nil || o.AuthorizeURL.String() == "" { return errors.New("missing setting: authorize-service-url") } if o.AuthorizeURL.Scheme != "https" { @@ -67,40 +68,42 @@ func ValidateOptions(o config.Options) error { return fmt.Errorf("cookie secret expects 32 bytes but got %d", len(decodedCookieSecret)) } if len(o.SigningKey) != 0 { - _, err := base64.StdEncoding.DecodeString(o.SigningKey) + decodedSigningKey, err := base64.StdEncoding.DecodeString(o.SigningKey) if err != nil { return fmt.Errorf("signing key is invalid base64: %v", err) } + _, err = cryptutil.NewES256Signer(decodedSigningKey, "localhost") + if err != nil { + return fmt.Errorf("invalid signing key is : %v", err) + } } return nil } // Proxy stores all the information associated with proxying a request. type Proxy struct { - SharedKey string - - // authenticate service + // SharedKey used to mutually authenticate service communication + SharedKey string AuthenticateURL *url.URL AuthenticateClient clients.Authenticator + AuthorizeClient clients.Authorizer - // authorize service - AuthorizeClient clients.Authorizer - - // session - cipher cryptutil.Cipher - csrfStore sessions.CSRFStore - sessionStore sessions.SessionStore - restStore sessions.SessionStore - - redirectURL *url.URL - templates *template.Template - routeConfigs map[string]*routeConfig - refreshCooldown time.Duration + cipher cryptutil.Cipher + cookieName string + csrfStore sessions.CSRFStore + defaultUpstreamTimeout time.Duration + redirectURL *url.URL + refreshCooldown time.Duration + restStore sessions.SessionStore + routeConfigs map[string]*routeConfig + sessionStore sessions.SessionStore + signingKey string + templates *template.Template } type routeConfig struct { mux http.Handler - policy policy.Policy + policy config.Policy } // New takes a Proxy service from options and a validation function. @@ -134,29 +137,32 @@ func New(opts config.Options) (*Proxy, error) { return nil, err } p := &Proxy{ + SharedKey: opts.SharedKey, + routeConfigs: make(map[string]*routeConfig), // services - AuthenticateURL: &opts.AuthenticateURL, - // session state - cipher: cipher, - csrfStore: cookieStore, - sessionStore: cookieStore, - restStore: restStore, - SharedKey: opts.SharedKey, - redirectURL: &url.URL{Path: "/.pomerium/callback"}, - templates: templates.New(), - refreshCooldown: opts.RefreshCooldown, + AuthenticateURL: opts.AuthenticateURL, + + cipher: cipher, + cookieName: opts.CookieName, + csrfStore: cookieStore, + defaultUpstreamTimeout: opts.DefaultUpstreamTimeout, + redirectURL: &url.URL{Path: "/.pomerium/callback"}, + refreshCooldown: opts.RefreshCooldown, + restStore: restStore, + sessionStore: cookieStore, + signingKey: opts.SigningKey, + templates: templates.New(), } - err = p.UpdatePolicies(opts) - if err != nil { + if err := p.UpdatePolicies(&opts); err != nil { return nil, err } p.AuthenticateClient, err = clients.NewAuthenticateClient("grpc", &clients.Options{ - Addr: opts.AuthenticateURL.Host, - InternalAddr: opts.AuthenticateInternalAddr.Host, + Addr: opts.AuthenticateURL, + InternalAddr: opts.AuthenticateInternalAddr, OverrideCertificateName: opts.OverrideCertificateName, SharedSecret: opts.SharedKey, CA: opts.CA, @@ -167,7 +173,7 @@ func New(opts config.Options) (*Proxy, error) { } p.AuthorizeClient, err = clients.NewAuthorizeClient("grpc", &clients.Options{ - Addr: opts.AuthorizeURL.Host, + Addr: opts.AuthorizeURL, OverrideCertificateName: opts.OverrideCertificateName, SharedSecret: opts.SharedKey, CA: opts.CA, @@ -177,26 +183,44 @@ func New(opts config.Options) (*Proxy, error) { } // UpdatePolicies updates the handlers based on the configured policies -func (p *Proxy) UpdatePolicies(opts config.Options) error { - routeConfigs := make(map[string]*routeConfig) - - policyCount := len(opts.Policies) - if policyCount == 0 { - log.Warn().Msg("proxy: loaded configuration with no policies specified") +func (p *Proxy) UpdatePolicies(opts *config.Options) error { + routeConfigs := make(map[string]*routeConfig, len(opts.Policies)) + if len(opts.Policies) == 0 { + log.Warn().Msg("proxy: configuration has no policies") } - log.Info().Int("policy-count", policyCount).Msg("proxy: updated policies") + for _, policy := range opts.Policies { + if err := policy.Validate(); err != nil { + return fmt.Errorf("proxy: couldn't update policies %s", err) + } + proxy := NewReverseProxy(policy.Destination) + // build http transport (roundtripper) middleware chain + // todo(bdd): this will make vet complain, it is safe + // and can be replaced with transport.Clone() in go 1.13 + // https://go-review.googlesource.com/c/go/+/174597/ + // https://github.com/golang/go/issues/26013#issuecomment-399481302 + transport := *(http.DefaultTransport.(*http.Transport)) + c := tripper.NewChain() + c = c.Append(metrics.HTTPMetricsRoundTripper("proxy")) + if policy.TLSSkipVerify { + transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} + } + if policy.TLSCustomCA != "" { + rootCA, err := p.customCAPool(policy.TLSCustomCA) + if err != nil { + return fmt.Errorf("proxy: couldn't add custom ca to policy %s", policy.From) + } + transport.TLSClientConfig = &tls.Config{RootCAs: rootCA} + } + proxy.Transport = c.Then(&transport) - for _, route := range opts.Policies { - proxy := NewReverseProxy(route.Destination) - handler, err := NewReverseProxyHandler(opts, proxy, &route) + handler, err := p.newReverseProxyHandler(proxy, &policy) if err != nil { return err } - routeConfigs[route.Source.Host] = &routeConfig{ + routeConfigs[policy.Source.Host] = &routeConfig{ mux: handler, - policy: route, + policy: policy, } - log.Info().Str("src", route.Source.Host).Str("dst", route.Destination.Host).Msg("proxy: new route") } p.routeConfigs = routeConfigs return nil @@ -204,40 +228,12 @@ func (p *Proxy) UpdatePolicies(opts config.Options) error { // UpstreamProxy stores information for proxying the request to the upstream. type UpstreamProxy struct { - name string - cookieName string - handler http.Handler - signer cryptutil.JWTSigner + name string + handler http.Handler } -// deleteUpstreamCookies deletes the session cookie from the request header string. -func deleteUpstreamCookies(req *http.Request, cookieName string) { - headers := []string{} - for _, cookie := range req.Cookies() { - if cookie.Name != cookieName { - headers = append(headers, cookie.String()) - } - } - req.Header.Set("Cookie", strings.Join(headers, ";")) -} - -func (u *UpstreamProxy) signRequest(r *http.Request) { - if u.signer != nil { - jwt, err := u.signer.SignJWT( - r.Header.Get(HeaderUserID), - r.Header.Get(HeaderEmail), - r.Header.Get(HeaderGroups)) - if err == nil { - r.Header.Set(HeaderJWT, jwt) - } - } -} - -// ServeHTTP signs the http request and deletes cookie headers -// before calling the upstream's ServeHTTP function. +// ServeHTTP handles the second (reverse-proxying) leg of pomerium's request flow func (u *UpstreamProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { - deleteUpstreamCookies(r, u.cookieName) - u.signRequest(r) u.handler.ServeHTTP(w, r) } @@ -247,8 +243,6 @@ func NewReverseProxy(to *url.URL) *httputil.ReverseProxy { proxy := httputil.NewSingleHostReverseProxy(to) sublogger := log.With().Str("proxy", to.Host).Logger() proxy.ErrorLog = stdlog.New(&log.StdLogWrapper{Logger: &sublogger}, "", 0) - // todo(bdd): default is already http.DefaultTransport) - // proxy.Transport = defaultUpstreamTransport director := proxy.Director proxy.Director = func(req *http.Request) { // Identifies the originating IP addresses of a client connecting to @@ -257,51 +251,62 @@ func NewReverseProxy(to *url.URL) *httputil.ReverseProxy { director(req) req.Host = to.Host } - - chain := tripper.NewChain().Append(metrics.HTTPMetricsRoundTripper("proxy")) - proxy.Transport = chain.Then(nil) return proxy } -// NewReverseProxyHandler applies handler specific options to a given route. -func NewReverseProxyHandler(o config.Options, proxy *httputil.ReverseProxy, route *policy.Policy) (http.Handler, error) { - up := &UpstreamProxy{ - name: route.Destination.Host, - handler: proxy, - cookieName: o.CookieName, +// newRouteSigner creates a route specific signer. +func (p *Proxy) newRouteSigner(audience string) (cryptutil.JWTSigner, error) { + decodedSigningKey, err := base64.StdEncoding.DecodeString(p.signingKey) + if err != nil { + return nil, err } - if len(o.SigningKey) != 0 { - decodedSigningKey, _ := base64.StdEncoding.DecodeString(o.SigningKey) - signer, err := cryptutil.NewES256Signer(decodedSigningKey, route.Source.Host) + return cryptutil.NewES256Signer(decodedSigningKey, audience) +} + +func (p *Proxy) customCAPool(cert string) (*x509.CertPool, error) { + certPool := x509.NewCertPool() + decodedCert, err := base64.StdEncoding.DecodeString(cert) + if err != nil { + return nil, fmt.Errorf("failed to decode cert: %s", err) + } + if ok := certPool.AppendCertsFromPEM(decodedCert); !ok { + return nil, fmt.Errorf("could not append cert: %s", decodedCert) + } + return certPool, nil +} + +// newReverseProxyHandler applies handler specific options to a given route. +func (p *Proxy) newReverseProxyHandler(rp *httputil.ReverseProxy, route *config.Policy) (http.Handler, error) { + var handler http.Handler + handler = &UpstreamProxy{ + name: route.Destination.Host, + handler: rp, + } + c := middleware.NewChain() + c = c.Append(middleware.StripPomeriumCookie(p.cookieName)) + + // if signing key is set, add signer to middleware + if len(p.signingKey) != 0 { + signer, err := p.newRouteSigner(route.Source.Host) if err != nil { return nil, err } - up.signer = signer + c = c.Append(middleware.SignRequest(signer, HeaderUserID, HeaderEmail, HeaderGroups, HeaderJWT)) } - timeout := o.DefaultUpstreamTimeout - if route.UpstreamTimeout != 0 { - timeout = route.UpstreamTimeout + // websockets cannot use the non-hijackable timeout-handler + if !route.AllowWebsockets { + timeout := p.defaultUpstreamTimeout + if route.UpstreamTimeout != 0 { + timeout = route.UpstreamTimeout + } + timeoutMsg := fmt.Sprintf("%s failed to respond within the %s timeout period", route.Destination.Host, timeout) + handler = http.TimeoutHandler(handler, timeout, timeoutMsg) } - timeoutMsg := fmt.Sprintf("%s failed to respond within the %s timeout period", route.Destination.Host, timeout) - timeoutHandler := http.TimeoutHandler(up, timeout, timeoutMsg) - return websocketHandlerFunc(up, timeoutHandler, o), nil -} -// urlParse wraps url.Parse to add a scheme if none-exists. -// https://github.com/golang/go/issues/12585 -func urlParse(uri string) (*url.URL, error) { - if !strings.Contains(uri, "://") { - uri = fmt.Sprintf("https://%s", uri) - } - return url.ParseRequestURI(uri) + return c.Then(handler), nil } // UpdateOptions updates internal structures based on config.Options func (p *Proxy) UpdateOptions(o config.Options) error { - log.Info().Msg("proxy: updating options") - err := p.UpdatePolicies(o) - if err != nil { - return fmt.Errorf("Could not update policies: %s", err) - } - return nil + return p.UpdatePolicies(&o) } diff --git a/proxy/proxy_test.go b/proxy/proxy_test.go index 2d87a57aa..2e4707a3f 100644 --- a/proxy/proxy_test.go +++ b/proxy/proxy_test.go @@ -10,12 +10,19 @@ import ( "time" "github.com/pomerium/pomerium/internal/config" - - "github.com/pomerium/pomerium/internal/policy" ) var fixedDate = time.Date(2009, 11, 17, 20, 34, 58, 651387237, time.UTC) +func newTestOptions(t *testing.T) *config.Options { + opts, err := config.NewOptions("https://authenticate.example", "https://authorize.example") + if err != nil { + t.Fatal(err) + } + opts.CookieSecret = "OromP1gurwGWjQPYb1nNgSxtbVB5NnLzX6z5WOKr0Yw=" + return opts +} + func TestNewReverseProxy(t *testing.T) { backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) @@ -54,14 +61,19 @@ func TestNewReverseProxyHandler(t *testing.T) { backendHost := net.JoinHostPort(backendHostname, backendPort) proxyURL, _ := url.Parse(backendURL.Scheme + "://" + backendHost + "/") proxyHandler := NewReverseProxy(proxyURL) - opts := config.NewOptions() + opts := newTestOptions(t) opts.SigningKey = "LS0tLS1CRUdJTiBFQyBQUklWQVRFIEtFWS0tLS0tCk1IY0NBUUVFSU0zbXBaSVdYQ1g5eUVneFU2czU3Q2J0YlVOREJTQ0VBdFFGNWZVV0hwY1FvQW9HQ0NxR1NNNDkKQXdFSG9VUURRZ0FFaFBRditMQUNQVk5tQlRLMHhTVHpicEVQa1JyazFlVXQxQk9hMzJTRWZVUHpOaTRJV2VaLwpLS0lUdDJxMUlxcFYyS01TYlZEeXI5aWp2L1hoOThpeUV3PT0KLS0tLS1FTkQgRUMgUFJJVkFURSBLRVktLS0tLQo=" - testPolicy := policy.Policy{From: "corp.example.com", To: "example.com", UpstreamTimeout: 1 * time.Second} - testPolicy.Validate() - - handle, err := NewReverseProxyHandler(opts, proxyHandler, &testPolicy) + testPolicy := config.Policy{From: "https://corp.example.com", To: "https://example.com", UpstreamTimeout: 1 * time.Second} + if err := testPolicy.Validate(); err != nil { + t.Fatal(err) + } + p, err := New(*opts) if err != nil { - t.Errorf("got %q", err) + t.Fatal(err) + } + handle, err := p.newReverseProxyHandler(proxyHandler, &testPolicy) + if err != nil { + t.Fatal(err) } frontend := httptest.NewServer(handle) @@ -77,109 +89,104 @@ func TestNewReverseProxyHandler(t *testing.T) { } } -func testOptions() config.Options { +func testOptions(t *testing.T) config.Options { authenticateService, _ := url.Parse("https://authenticate.corp.beyondperimeter.com") authorizeService, _ := url.Parse("https://authorize.corp.beyondperimeter.com") - opts := config.NewOptions() - testPolicy := policy.Policy{From: "corp.example.notatld", To: "example.notatld"} - testPolicy.Validate() - opts.Policies = []policy.Policy{testPolicy} - opts.AuthenticateURL = *authenticateService - opts.AuthorizeURL = *authorizeService + opts := newTestOptions(t) + testPolicy := config.Policy{From: "https://corp.example.example", To: "https://example.example"} + opts.Policies = []config.Policy{testPolicy} + opts.AuthenticateURL = authenticateService + opts.AuthorizeURL = authorizeService opts.SharedKey = "80ldlrU2d7w+wVpKNfevk6fmb8otEx6CqOfshj2LwhQ=" opts.CookieSecret = "OromP1gurwGWjQPYb1nNgSxtbVB5NnLzX6z5WOKr0Yw=" opts.CookieName = "pomerium" - return opts + err := opts.Validate() + if err != nil { + t.Fatal(err) + } + return *opts } -func testOptionsTestServer(uri string) config.Options { +func testOptionsTestServer(t *testing.T, uri string) config.Options { authenticateService, _ := url.Parse("https://authenticate.corp.beyondperimeter.com") authorizeService, _ := url.Parse("https://authorize.corp.beyondperimeter.com") - // RFC 2606 - testPolicy := policy.Policy{ - From: "httpbin.corp.example", + testPolicy := config.Policy{ + From: "https://httpbin.corp.example", To: uri, } - testPolicy.Validate() - opts := config.NewOptions() - opts.Policies = []policy.Policy{testPolicy} - opts.AuthenticateURL = *authenticateService - opts.AuthorizeURL = *authorizeService + if err := testPolicy.Validate(); err != nil { + t.Fatal(err) + } + opts := newTestOptions(t) + opts.Policies = []config.Policy{testPolicy} + opts.AuthenticateURL = authenticateService + opts.AuthorizeURL = authorizeService opts.SharedKey = "80ldlrU2d7w+wVpKNfevk6fmb8otEx6CqOfshj2LwhQ=" opts.CookieSecret = "OromP1gurwGWjQPYb1nNgSxtbVB5NnLzX6z5WOKr0Yw=" opts.CookieName = "pomerium" - return opts + return *opts } -func testOptionsWithCORS(uri string) config.Options { - testPolicy := policy.Policy{ - From: "httpbin.corp.example", +func testOptionsWithCORS(t *testing.T, uri string) config.Options { + testPolicy := config.Policy{ + From: "https://httpbin.corp.example", To: uri, CORSAllowPreflight: true, } - testPolicy.Validate() - opts := testOptionsTestServer(uri) - opts.Policies = []policy.Policy{testPolicy} + if err := testPolicy.Validate(); err != nil { + t.Fatal(err) + } + opts := testOptionsTestServer(t, uri) + opts.Policies = []config.Policy{testPolicy} return opts } -func testOptionsWithPublicAccess(uri string) config.Options { - testPolicy := policy.Policy{ - From: "httpbin.corp.example", +func testOptionsWithPublicAccess(t *testing.T, uri string) config.Options { + testPolicy := config.Policy{ + From: "https://httpbin.corp.example", To: uri, AllowPublicUnauthenticatedAccess: true, } - testPolicy.Validate() - opts := testOptions() - opts.Policies = []policy.Policy{testPolicy} - return opts -} - -func testOptionsWithPublicAccessAndWhitelist(uri string) config.Options { - testPolicy := policy.Policy{ - From: "httpbin.corp.example", - To: uri, - AllowPublicUnauthenticatedAccess: true, - AllowedEmails: []string{"test@gmail.com"}, + if err := testPolicy.Validate(); err != nil { + t.Fatal(err) } - testPolicy.Validate() - opts := testOptions() - opts.Policies = []policy.Policy{testPolicy} + opts := testOptions(t) + opts.Policies = []config.Policy{testPolicy} return opts } -func testOptionsWithEmptyPolicies(uri string) config.Options { - opts := testOptionsTestServer(uri) - opts.Policies = []policy.Policy{} +func testOptionsWithEmptyPolicies(t *testing.T, uri string) config.Options { + opts := testOptionsTestServer(t, uri) + opts.Policies = []config.Policy{} return opts } func TestOptions_Validate(t *testing.T) { - good := testOptions() - badAuthURL := testOptions() - badAuthURL.AuthenticateURL = url.URL{} + good := testOptions(t) + badAuthURL := testOptions(t) + badAuthURL.AuthenticateURL = nil authurl, _ := url.Parse("http://authenticate.corp.beyondperimeter.com") - authenticateBadScheme := testOptions() - authenticateBadScheme.AuthenticateURL = *authurl - authorizeBadSCheme := testOptions() - authorizeBadSCheme.AuthorizeURL = *authurl - authorizeNil := testOptions() - authorizeNil.AuthorizeURL = url.URL{} - emptyCookieSecret := testOptions() + authenticateBadScheme := testOptions(t) + authenticateBadScheme.AuthenticateURL = authurl + authorizeBadSCheme := testOptions(t) + authorizeBadSCheme.AuthorizeURL = authurl + authorizeNil := testOptions(t) + authorizeNil.AuthorizeURL = nil + emptyCookieSecret := testOptions(t) emptyCookieSecret.CookieSecret = "" - invalidCookieSecret := testOptions() + invalidCookieSecret := testOptions(t) invalidCookieSecret.CookieSecret = "OromP1gurwGWjQPYb1nNgSxtbVB5NnLzX6z5WOKr0Yw^" - shortCookieLength := testOptions() + shortCookieLength := testOptions(t) shortCookieLength.CookieSecret = "gN3xnvfsAwfCXxnJorGLKUG4l2wC8sS8nfLMhcStPg==" - invalidSignKey := testOptions() + invalidSignKey := testOptions(t) invalidSignKey.SigningKey = "OromP1gurwGWjQPYb1nNgSxtbVB5NnLzX6z5WOKr0Yw^" - badSharedKey := testOptions() + badSharedKey := testOptions(t) badSharedKey.SharedKey = "" - sharedKeyBadBas64 := testOptions() + sharedKeyBadBas64 := testOptions(t) sharedKeyBadBas64.SharedKey = "%(*@389" - missingPolicy := testOptions() - missingPolicy.Policies = []policy.Policy{} + missingPolicy := testOptions(t) + missingPolicy.Policies = []config.Policy{} tests := []struct { name string @@ -197,7 +204,6 @@ func TestOptions_Validate(t *testing.T) { {"short cookie secret", shortCookieLength, true}, {"no shared secret", badSharedKey, true}, {"invalid signing key", invalidSignKey, true}, - {"missing policy", missingPolicy, false}, {"shared secret bad base64", sharedKeyBadBas64, true}, } for _, tt := range tests { @@ -212,10 +218,10 @@ func TestOptions_Validate(t *testing.T) { func TestNew(t *testing.T) { - good := testOptions() - shortCookieLength := testOptions() + good := testOptions(t) + shortCookieLength := testOptions(t) shortCookieLength.CookieSecret = "gN3xnvfsAwfCXxnJorGLKUG4l2wC8sS8nfLMhcStPg==" - badRoutedProxy := testOptions() + badRoutedProxy := testOptions(t) badRoutedProxy.SigningKey = "YmFkIGtleQo=" tests := []struct { name string @@ -240,7 +246,7 @@ func TestNew(t *testing.T) { t.Errorf("New() expected valid proxy struct") } if got != nil && len(got.routeConfigs) != tt.numRoutes { - t.Errorf("New() = num routeConfigs \n%+v, want \n%+v", got, tt.numRoutes) + t.Errorf("New() = num routeConfigs \n%+v, want \n%+v \nfrom %+v", got, tt.numRoutes, tt.opts) } }) } @@ -248,34 +254,65 @@ func TestNew(t *testing.T) { func Test_UpdateOptions(t *testing.T) { - good := testOptions() - bad := testOptions() - bad.SigningKey = "f" - newPolicy := policy.Policy{To: "foo.notatld", From: "bar.notatld"} - newPolicy.Validate() - newPolicies := []policy.Policy{ + good := testOptions(t) + newPolicy := config.Policy{To: "http://foo.example", From: "http://bar.example"} + newPolicies := testOptions(t) + newPolicies.Policies = []config.Policy{ newPolicy, } + err := newPolicy.Validate() + if err != nil { + t.Fatal(err) + } + badPolicyURL := config.Policy{To: "http://", From: "http://bar.example"} + badNewPolicy := testOptions(t) + badNewPolicy.Policies = []config.Policy{ + badPolicyURL, + } + disableTLSPolicy := config.Policy{To: "http://foo.example", From: "http://bar.example", TLSSkipVerify: true} + disableTLSPolicies := testOptions(t) + disableTLSPolicies.Policies = []config.Policy{ + disableTLSPolicy, + } + customCAPolicy := config.Policy{To: "http://foo.example", From: "http://bar.example", TLSCustomCA: "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURlVENDQW1HZ0F3SUJBZ0lKQUszMmhoR0JIcmFtTUEwR0NTcUdTSWIzRFFFQkN3VUFNR0l4Q3pBSkJnTlYKQkFZVEFsVlRNUk13RVFZRFZRUUlEQXBEWVd4cFptOXlibWxoTVJZd0ZBWURWUVFIREExVFlXNGdSbkpoYm1OcApjMk52TVE4d0RRWURWUVFLREFaQ1lXUlRVMHd4RlRBVEJnTlZCQU1NRENvdVltRmtjM05zTG1OdmJUQWVGdzB4Ck9UQTJNVEl4TlRNeE5UbGFGdzB5TVRBMk1URXhOVE14TlRsYU1HSXhDekFKQmdOVkJBWVRBbFZUTVJNd0VRWUQKVlFRSURBcERZV3hwWm05eWJtbGhNUll3RkFZRFZRUUhEQTFUWVc0Z1JuSmhibU5wYzJOdk1ROHdEUVlEVlFRSwpEQVpDWVdSVFUwd3hGVEFUQmdOVkJBTU1EQ291WW1Ga2MzTnNMbU52YlRDQ0FTSXdEUVlKS29aSWh2Y05BUUVCCkJRQURnZ0VQQURDQ0FRb0NnZ0VCQU1JRTdQaU03Z1RDczloUTFYQll6Sk1ZNjF5b2FFbXdJclg1bFo2eEt5eDIKUG16QVMyQk1UT3F5dE1BUGdMYXcrWExKaGdMNVhFRmRFeXQvY2NSTHZPbVVMbEEzcG1jY1lZejJRVUxGUnRNVwpoeWVmZE9zS25SRlNKaUZ6YklSTWVWWGswV3ZvQmoxSUZWS3RzeWpicXY5dS8yQ1ZTbmRyT2ZFazBURzIzVTNBCnhQeFR1VzFDcmJWOC9xNzFGZEl6U09jaWNjZkNGSHBzS09vM1N0L3FiTFZ5dEg1YW9oYmNhYkZYUk5zS0VxdmUKd3c5SGRGeEJJdUdhK1J1VDVxMGlCaWt1c2JwSkhBd25ucVA3aS9kQWNnQ3NrZ2paakZlRVU0RUZ5K2IrYTFTWQpRQ2VGeHhDN2MzRHZhUmhCQjBWVmZQbGtQejBzdzZsODY1TWFUSWJSeW9VQ0F3RUFBYU15TURBd0NRWURWUjBUCkJBSXdBREFqQmdOVkhSRUVIREFhZ2d3cUxtSmhaSE56YkM1amIyMkNDbUpoWkhOemJDNWpiMjB3RFFZSktvWkkKaHZjTkFRRUxCUUFEZ2dFQkFJaTV1OXc4bWdUNnBwQ2M3eHNHK0E5ZkkzVzR6K3FTS2FwaHI1bHM3MEdCS2JpWQpZTEpVWVpoUGZXcGgxcXRra1UwTEhGUG04M1ZhNTJlSUhyalhUMFZlNEt0TzFuMElBZkl0RmFXNjJDSmdoR1luCmp6dzByeXpnQzRQeUZwTk1uTnRCcm9QdS9iUGdXaU1nTE9OcEVaaGlneDRROHdmMVkvVTlzK3pDQ3hvSmxhS1IKTVhidVE4N1g3bS85VlJueHhvNk56NVpmN09USFRwTk9JNlZqYTBCeGJtSUFVNnlyaXc5VXJnaWJYZk9qM2o2bgpNVExCdWdVVklCMGJCYWFzSnNBTUsrdzRMQU52YXBlWjBET1NuT1I0S0syNEowT3lvRjVmSG1wNTllTTE3SW9GClFxQmh6cG1RVWd1bmVjRVc4QlRxck5wRzc5UjF1K1YrNHd3Y2tQYz0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo="} + customCAPolicies := testOptions(t) + customCAPolicies.Policies = []config.Policy{ + customCAPolicy, + } + badCustomCAPolicy := config.Policy{To: "http://foo.example", From: "http://bar.example", TLSCustomCA: "=@@"} + badCustomCAPolicies := testOptions(t) + badCustomCAPolicies.Policies = []config.Policy{ + badCustomCAPolicy, + } tests := []struct { - name string - opts config.Options - newPolicy []policy.Policy - host string - wantErr bool - wantRoute bool + name string + originalOptions config.Options + updatedOptions config.Options + signingKey string + host string + wantErr bool + wantRoute bool }{ - {"good", good, good.Policies, "https://corp.example.notatld", false, true}, - {"changed", good, newPolicies, "https://bar.notatld", false, true}, - {"changed and missing", good, newPolicies, "https://corp.example.notatld", false, false}, - {"bad options", bad, good.Policies, "https://corp.example.notatld", true, false}, + {"good no change", good, good, "", "https://corp.example.example", false, true}, + {"changed", good, newPolicies, "", "https://bar.example", false, true}, + {"changed and missing", good, newPolicies, "", "https://corp.example.example", false, false}, + // todo(bdd): not sure what intent of this test is? + {"bad signing key", good, newPolicies, "^bad base 64", "https://corp.example.example", true, false}, + {"bad change bad policy url", good, badNewPolicy, "", "https://bar.example", true, false}, + // todo: stand up a test server using self signed certificates + {"disable tls verification", good, disableTLSPolicies, "", "https://bar.example", false, true}, + {"custom root ca", good, customCAPolicies, "", "https://bar.example", false, true}, + {"bad custom root ca base64", good, badCustomCAPolicies, "", "https://bar.example", true, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - o := tt.opts - p, _ := New(o) + p, err := New(tt.originalOptions) + if err != nil { + t.Fatal(err) + } - o.Policies = tt.newPolicy - err := p.UpdateOptions(o) + p.signingKey = tt.signingKey + err = p.UpdateOptions(tt.updatedOptions) if (err != nil) != tt.wantErr { t.Errorf("UpdateOptions: err = %v, wantErr = %v", err, tt.wantErr) return