diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 21d6e9247..24672648b 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -33,8 +33,12 @@ jobs: go-version: ${{ matrix.go-version }} - name: Checkout code uses: actions/checkout@v2 + - name: Build dependencies + run: make build-deps - name: Test - run: make test + run: | + export PATH=$PATH:$(go env GOPATH)/bin + make test cover: strategy: diff --git a/Makefile b/Makefile index d78fbcb53..c48c1e81e 100644 --- a/Makefile +++ b/Makefile @@ -27,8 +27,10 @@ CTIMEVAR=-X $(PKG)/internal/version.GitCommit=$(GITCOMMIT) \ -X $(PKG)/internal/version.ProjectURL=$(PKG) GO_LDFLAGS=-ldflags "-s -w $(CTIMEVAR)" GOOSARCHES = linux/amd64 darwin/amd64 windows/amd64 -GOLANGCI_VERSION = v1.21.0 - +MISSPELL_VERSION = v0.3.4 +GOLANGCI_VERSION = v1.21.0 +OPA_VERSION = v0.19.1 + .PHONY: all all: clean build-deps test lint spellcheck build ## Runs a clean, build, fmt, lint, test, and vet. @@ -36,8 +38,9 @@ all: clean build-deps test lint spellcheck build ## Runs a clean, build, fmt, li .PHONY: build-deps build-deps: ## Install build dependencies @echo "==> $@" - @cd /tmp; GO111MODULE=on go get -u github.com/client9/misspell/cmd/misspell + @cd /tmp; GO111MODULE=on go get github.com/client9/misspell/cmd/misspell@${MISSPELL_VERSION} @cd /tmp; GO111MODULE=on go get github.com/golangci/golangci-lint/cmd/golangci-lint@${GOLANGCI_VERSION} + @cd /tmp; GO111MODULE=on go get github.com/open-policy-agent/opa@${OPA_VERSION} .PHONY: docs docs: ## Start the vuepress docs development server @@ -68,6 +71,7 @@ lint: ## Verifies `golint` passes. test: ## Runs the go tests. @echo "==> $@" @go test -tags "$(BUILDTAGS)" $(shell go list ./... | grep -v vendor) + @opa test ./authorize/evaluator/opa/policy .PHONY: spellcheck spellcheck: # Spellcheck docs diff --git a/authorize/evaluator/opa/opa_test.go b/authorize/evaluator/opa/opa_test.go index 7bf7566a0..5667dcb23 100644 --- a/authorize/evaluator/opa/opa_test.go +++ b/authorize/evaluator/opa/opa_test.go @@ -91,9 +91,11 @@ func Test_Eval(t *testing.T) { } req := struct { Host string `json:"host,omitempty"` + URL string `json:"url,omitempty"` User string `json:"user,omitempty"` }{ Host: tt.route, + URL: "https://" + tt.route, User: rawJWT, } got, err := pe.IsAuthorized(context.TODO(), req) diff --git a/authorize/evaluator/opa/policy/authz.rego b/authorize/evaluator/opa/policy/authz.rego index 490a68bab..707bfe2ef 100644 --- a/authorize/evaluator/opa/policy/authz.rego +++ b/authorize/evaluator/opa/policy/authz.rego @@ -8,7 +8,7 @@ default allow = false # allow by email allow { some route - input.host = route_policies[route].source + allowed_route(input.url, route_policies[route]) token.payload.email = route_policies[route].allowed_users[_] token.valid count(deny)==0 @@ -17,8 +17,9 @@ allow { # allow group allow { some route - input.host = route_policies[route].source - token.payload.groups[_] = route_policies[route].allowed_groups[_] + allowed_route(input.url, route_policies[route]) + some group + token.payload.groups[group] == route_policies[route].allowed_groups[_] token.valid count(deny)==0 } @@ -26,7 +27,7 @@ allow { # allow by impersonate email allow { some route - input.host = route_policies[route].source + allowed_route(input.url, route_policies[route]) token.payload.impersonate_email = route_policies[route].allowed_users[_] token.valid count(deny)==0 @@ -35,8 +36,9 @@ allow { # allow by impersonate group allow { some route - input.host = route_policies[route].source - token.payload.impersonate_groups[_] = route_policies[route].allowed_groups[_] + allowed_route(input.url, route_policies[route]) + some group + token.payload.impersonate_groups[group] == route_policies[route].allowed_groups[_] token.valid count(deny)==0 } @@ -44,7 +46,7 @@ allow { # allow by domain allow { some route - input.host = route_policies[route].source + allowed_route(input.url, route_policies[route]) some domain email_in_domain(token.payload.email, route_policies[route].allowed_domains[domain]) token.valid @@ -54,13 +56,68 @@ allow { # allow by impersonate domain allow { some route - input.host = route_policies[route].source + allowed_route(input.url, route_policies[route]) some domain email_in_domain(token.payload.impersonate_email, route_policies[route].allowed_domains[domain]) token.valid count(deny)==0 } +allowed_route(input_url, policy){ + input_url_obj := parse_url(input_url) + allowed_route_source(input_url_obj, policy) + allowed_route_prefix(input_url_obj, policy) + allowed_route_path(input_url_obj, policy) + allowed_route_regex(input_url_obj, policy) +} + +allowed_route_source(input_url_obj, policy) { + object.get(policy, "source", "") == "" +} +allowed_route_source(input_url_obj, policy) { + object.get(policy, "source", "") != "" + source_url_obj := parse_url(policy.source) + input_url_obj.host == source_url_obj.host +} + +allowed_route_prefix(input_url_obj, policy) { + object.get(policy, "prefix", "") == "" +} +allowed_route_prefix(input_url_obj, policy) { + object.get(policy, "prefix", "") != "" + startswith(input_url_obj.path, policy.prefix) +} + +allowed_route_path(input_url_obj, policy) { + object.get(policy, "path", "") == "" +} +allowed_route_path(input_url_obj, policy) { + object.get(policy, "path", "") != "" + policy.path == input_url_obj.path +} + +allowed_route_regex(input_url_obj, policy) { + object.get(policy, "regex", "") == "" +} +allowed_route_regex(input_url_obj, policy) { + object.get(policy, "regex", "") != "" + re_match(policy.regex, input_url_obj.path) +} + +parse_url(str) = { "scheme": scheme, "host": host, "path": path } { + [_, scheme, host, rawpath] = regex.find_all_string_submatch_n( + `(?:(http[s]?)://)?([^/]+)([^?#]*)`, + str, 1)[0] + path = normalize_url_path(rawpath) +} + +normalize_url_path(str) = "/" { + str == "" +} +normalize_url_path(str) = str { + str != "" +} + email_in_domain(email, domain) { x := split(email, "@") count(x) == 2 diff --git a/authorize/evaluator/opa/policy/authz_test.rego b/authorize/evaluator/opa/policy/authz_test.rego new file mode 100644 index 000000000..285cbc131 --- /dev/null +++ b/authorize/evaluator/opa/policy/authz_test.rego @@ -0,0 +1,102 @@ +package pomerium.authz + +jwt_header := { + "typ": "JWT", + "alg": "HS256" +} +signing_key := { + "kty": "oct", + "k": "OkFmqMK9U0dmPhMCW0VYy6D_raJKwEJsMdxqdnukThzko3D_XrsihwYE0pxrUSpm0JTrW2QpIz4rT1vdEvZw67WP4xrqjiwyd7PgpPTD5xvQBM7TIKiSW0X2R0pfq_OItszPQRtb7VirrSbGJiLNS-NJMMrYVKWWtUbVSTXEjL7VcFqML5PiSe7XDmyCZjpgEpfE5Q82zIeXM2sLrz6HW2A9IwGk7mWS0c57R_2JGyFO2tCA4zEIYhWvLE62Os2tZ6YrrwdB8n35jlPpgUE6poEvIU20lPLaocozXYMqAku-KJnloJlAzKg2Xa_0iSiSgSAumx44B3n7DQjg3jPhRg" +} +shared_key := base64url.decode(signing_key.k) + +test_email_allowed { + user := io.jwt.encode_sign(jwt_header, { + "aud": ["example.com"], + "email": "joe@example.com" + }, signing_key) + + allow with data.route_policies as [{ + "source": "example.com", + "allowed_users": ["joe@example.com"] + }] with data.signing_key as signing_key with data.shared_key as shared_key with input as { + "url": "http://example.com", + "host": "example.com", + "user": user + } +} + +test_example { + user := io.jwt.encode_sign(jwt_header, { + "aud": ["example.com"], + "email": "joe@example.com" + }, signing_key) + not allow with data.route_policies as [ + { + "source": "http://example.com", + "path": "/a", + "allowed_domains": ["example.com"] + }, + { + "source": "http://example.com", + "path": "/b", + "allowed_users": ["noone@pomerium.com"] + }, + ] with data.signing_key as signing_key with data.shared_key as shared_key with input as { + "url": "http://example.com/b", + "host": "example.com", + "user": user + } +} + +test_email_denied { + user := io.jwt.encode_sign(jwt_header, { + "aud": ["example.com"], + "email": "joe@example.com" + }, signing_key) + + not allow with data.route_policies as [{ + "source": "example.com", + "allowed_users": ["bob@example.com"] + }] with data.signing_key as signing_key with data.shared_key as shared_key with input as { + "url": "http://example.com", + "host": "example.com", + "user": user + } +} + +test_parse_url { + url := parse_url("http://example.com/some/path?qs") + url.scheme == "http" + url.host == "example.com" + url.path == "/some/path" +} + +test_allowed_route_source { + allowed_route("http://example.com", {"source": "example.com"}) + allowed_route("http://example.com", {"source": "http://example.com"}) + allowed_route("http://example.com", {"source": "https://example.com"}) + allowed_route("http://example.com/", {"source": "https://example.com"}) + allowed_route("http://example.com", {"source": "https://example.com/"}) + allowed_route("http://example.com/", {"source": "https://example.com/"}) + not allowed_route("http://example.org", {"source": "example.com"}) +} + +test_allowed_route_prefix { + allowed_route("http://example.com", {"prefix": "/"}) + allowed_route("http://example.com/admin/somepath", {"prefix": "/admin"}) + not allowed_route("http://example.com", {"prefix": "/admin"}) +} + +test_allowed_route_path { + allowed_route("http://example.com", {"path": "/"}) + allowed_route("http://example.com/", {"path": "/"}) + not allowed_route("http://example.com/admin/somepath", {"path": "/admin"}) + not allowed_route("http://example.com", {"path": "/admin"}) +} + +test_allowed_route_regex { + allowed_route("http://example.com", {"regex": ".*"}) + allowed_route("http://example.com/admin/somepath", {"regex": "/admin/.*"}) + not allowed_route("http://example.com", {"regex": "[xyz]"}) +} diff --git a/authorize/evaluator/opa/policy/statik.go b/authorize/evaluator/opa/policy/statik.go index acf920f56..ad90c3361 100644 --- a/authorize/evaluator/opa/policy/statik.go +++ b/authorize/evaluator/opa/policy/statik.go @@ -10,7 +10,7 @@ import ( const Rego = "rego" // static asset namespace func init() { - data := "PK\x03\x04\x14\x00\x08\x00\x08\x00o\xa5\x94P\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\n\x00 \x00authz.regoUT\x05\x00\x01s \x9e^\xbcV\xd1n\xac6\x10}\xb6\xbfb\xe4(\x12\xa8\x944\x91Z\xa9\xa8H}\xe9\x17\xf4\x11!\xe4\xe0\xd9]'`#l\xb2\xbb]\xed\xbfW\xb6a!$U\xd2{\xb3\xf7e\x17\xcf0g\xce\x99\x19\xdbt\xbc~\xe6[\x84N\xb7\xd8\xcb\xa1M\xf9`w\xffP*\xdbN\xf7\x16\x04\xb7<\xed\xf5`\xb1\xeat#k\x89\xe6\x95\xcb\xecx\x8f\xa2z\xc6#\xa5\x027|h,\xf0\xa6\xd1{\xc8a\xc3\x1b\x83\x94\xde\x8c\x86\xc7#`\xcbeC\xc3\xf2D\x89\xd1-\x82\x07\xa7D\xaan\xb0\xe9N\x1b\x0b9\xbcNX\xf8e\x99\x1a=\xf45Rb\xf53\xaa\xb4\xe3\xc7Fs\x91z\xcc\xff\x8c\xf1\xb9PT\x83\xc1\xde\x14U9E\xbf\xf0F\nJj=(\x1b T\xc78\xcf\x7f\xa1\xe7\x99\xed\xb6\xd7C\xf7\xc5T=\xa6c\xf1!\xdd\xcb\x9b\x9f\xe6\xfbx\x04\xd9v\xd8\x1b\xad\xb8\xc5\xabTz\x81_]\xa9\xea+\x15\xd7h\xc2R\xc5\x95\x1b\"t\xcb\xa5\xfan\xfe>l\xc4\"\xbe\xf0\x95TU0D\xefl\x86\xe4\x03-!\xd2\x14\xe1\xbf\x8c\xff\x8f\xa4ew~\xbc\xbc7\x13\xf8\xe5R\xd7\xf9\xc7,a\x15;\x95\x07\xc8r0]#\xed\xe4d\x7f\xb2x\xc2:\xc4\x90\xe7\xf0@\xc9\xa1\xb8/\xdd\xe3(\xec<\x1f\x8fx\xe8d\x8fb> '\xc3\x89\x12\xa5\xf7\x95\xc1Z+a\xb2\xdc\xca\x16SgQ&\x8a\xef\xee\xf1wJ\x8a\x1dr\x81}\x02cE\x12\xa8J\xc7G\xea\xf4ioS\x81\xb5\x16\x18\x85\xe2\xbb\xcd\x17Sr\x19\x8dC\x07\x7f\xc0\"A\xe0\xa4\x8e\x05\xf35\x01i.\xd4\"\x1f\xf4\x9bCd\xc0\xf6\xf8,\\\x9e\xde\xcdbW\x8f\x1f\xf5\xe6\xd5W-\xeb\xc0\xf1@\xa3}\x92\xb5~\x98\xda\xf1\xb7Nz\xe1\xb7C\xd3\xa8\xdb\xcd\xf6\xb5\xa5u/\x96\xe7\x97\x1a;=ndzg\xe2\x99O}\xbd\xa5]R\x8d\xdd\xb5&\x84\xa4\xce\xc9\xd7\xe8\xa09\x0b\x86\xb1{\xa3\xb7b\xac?un\xb4z0\xec\x03l\xe3l<\x19\xcc\x8f\xfd\xe4K\xaf\x1b\x05\xb8\x1b\x1cg=W\x1b\x03\xab\x8e\x0cd\xb8\xc6q\x12.\x1a\x8d\x93\x83\xa8}v5s\x0ffC\xef\xda\x15\x02x\x80@g\xb3\xff)\xa0\xb0\xd5HHPu\xa0\x8d\x1d\xb8\x97\xd3\xa7\xea\xef\xcb2\x83\x94Y0\x04(\xb0@\x10\xe0\x14:\\\xb3\x84\xae\x05G\xb8:KY\x15F\xbc\xd6\xe2\xb5{;GTx\xa6\xa4\x82\xc4Q\x0f\x95{\x15.@\x18\x07\xb0j\xe3P}\xa8\xf0\x88\x80\xe5j\xcf0\xfc\x9e\x0f\xcb\xd2\xaa\xa2\xe4\x98\xec\xcb\xb2$\xba+)b\x9e\xe2\x00\x06\xaa\x04'\x0cZ1\x0e\x90\x8d U\x00U\xeeE;\x8a\x13bC\x8e\x9aG\x14\xfd\x9e\x07\xb08{*8\xbdl\xfc K\xab\x87\\\x93\xbc[\x01\xcdS\xca'\xed6\xcasv'\x91\x82\xa28a< \xd8%D\x0c\xec1\x16\x1f\xd6j\xaf\x18z\x98\xb2B\xea\x9c\xb2z\xa8\xf0\x1fYZ\xc9\xab\x8d0\xeb\xbcO\x91D\x8a0SJ\xa8\"K\x12o\x9aW\xe6\x8d\xf1%5\x06\xcc\xe3 5\xf0|\xb1\x91\xcc\xc1!@\x11}\xcdZ\x96\xa4U\xe5C-\xa6/Z\xec\\\x11a\x1c\xc1\xef\xdb\xbf\xba|\x9f\xcf1\xc73\xd7\xf7\xdbC\xbc[\x07F\xe8\xd3\x9emI\x93\xbc\xff\xe9N\xf1\xf4\x7f\xfdtc@(\xb4\x12\x12\x08aH\xc0u\xd9\xde\xed\x15\x99\x80\xe2\x10\xd6\xb8}\xff\x9aSu_\x14U\xa9\xed\xc1\x10*GGkb\xea\xfa\x96s\x11w\xbf\xab\xc3C\xbc^\x84vp\xea\x96\xd3f\xcbk\x81\xd6rpz\xbf\xdd\x17qS+\xca\xaf7\xe4[\xed\xbf\xbf\xbe \xe1\xa30\xf4#8\xb5\xff\n\xe8\xdfqj\xe5\x80J0Z#m\xdf\xdb\x9bh\x98\xb8\xff,W\xb1\x1bb\x02\x1f\xd1\xa2\xbc\x1b\xd6\xf9\xe2\x9f\xb6\xe4\x88\xc0 Q$l)\\\xf9\x02DDK\x8eX\xc0a[\xfe\xc6t\xfcY\x94\x9fm\xf3\x0d)9YAQ\xa9!\nW\xb2\xfdD~d!\xaf\x8a\x8b\xd7A\xa0\x0b\xdf\xa1\xb5H\xe7\xb8\xd5?Jn\xa4`\xb0-\xc8s\xf0\x19\xab\xfcp[\x80\xfb\xc52{X\x0f\xf7w\x00\x00\x00\xff\xffPK\x07\x08\xf7ol\x88\x94\x03\x00\x00\xb8\x0c\x00\x00PK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00\x12\x03\x95P\xab\xe0F\x9a\x82\x04\x00\x00\xf4\x11\x00\x00\n\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\x00\x00\x00\x00authz.regoUT\x05\x00\x01D=\x9e^PK\x01\x02\x14\x03\x14\x00\x08\x00\x08\x00\x12\x03\x95P\xf7ol\x88\x94\x03\x00\x00\xb8\x0c\x00\x00\x0f\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xc3\x04\x00\x00authz_test.regoUT\x05\x00\x01D=\x9e^PK\x05\x06\x00\x00\x00\x00\x02\x00\x02\x00\x87\x00\x00\x00\x9d\x08\x00\x00\x00\x00" fs.RegisterWithNamespace("rego", data) } \ No newline at end of file diff --git a/authorize/grpc.go b/authorize/grpc.go index c56320bb0..d42aaaf5b 100644 --- a/authorize/grpc.go +++ b/authorize/grpc.go @@ -4,6 +4,7 @@ package authorize import ( "context" + "net/url" "github.com/pomerium/pomerium/authorize/evaluator" "github.com/pomerium/pomerium/internal/grpc/authorize" @@ -22,7 +23,7 @@ func (a *Authorize) IsAuthorized(ctx context.Context, in *authorize.IsAuthorized Method: in.GetRequestMethod(), RequestURI: in.GetRequestRequestUri(), RemoteAddr: in.GetRequestRemoteAddr(), - URL: in.GetRequestUrl(), + URL: getFullURL(in.GetRequestUrl(), in.GetRequestHost()), } return a.pe.IsAuthorized(ctx, req) } @@ -38,3 +39,17 @@ func cloneHeaders(in protoHeader) map[string][]string { } return out } + +func getFullURL(rawurl, host string) string { + u, err := url.Parse(rawurl) + if err != nil { + u = &url.URL{Path: rawurl} + } + if u.Host == "" { + u.Host = host + } + if u.Scheme == "" { + u.Scheme = "http" + } + return u.String() +} diff --git a/authorize/grpc_test.go b/authorize/grpc_test.go index 33245d15c..c4a765957 100644 --- a/authorize/grpc_test.go +++ b/authorize/grpc_test.go @@ -47,3 +47,19 @@ func TestAuthorize_IsAuthorized(t *testing.T) { }) } } + +func Test_getFullURL(t *testing.T) { + tests := []struct { + rawurl, host, expect string + }{ + {"https://www.example.com/admin", "", "https://www.example.com/admin"}, + {"https://www.example.com/admin", "example.com", "https://www.example.com/admin"}, + {"/admin", "example.com", "http://example.com/admin"}, + } + for _, tt := range tests { + actual := getFullURL(tt.rawurl, tt.host) + if actual != tt.expect { + t.Errorf("expected getFullURL(%s, %s) to be %s, but got %s", tt.rawurl, tt.host, tt.expect, actual) + } + } +} diff --git a/config/options_test.go b/config/options_test.go index e5f264a72..56c67139d 100644 --- a/config/options_test.go +++ b/config/options_test.go @@ -152,7 +152,7 @@ func Test_parsePolicyFile(t *testing.T) { want []Policy wantErr bool }{ - {"simple json", []byte(fmt.Sprintf(`{"policy":[{"from": "%s","to":"%s"}]}`, source, dest)), []Policy{{From: source, To: dest, Source: &HostnameURL{sourceURL}, Destination: destURL}}, false}, + {"simple json", []byte(fmt.Sprintf(`{"policy":[{"from": "%s","to":"%s"}]}`, source, dest)), []Policy{{From: source, To: dest, Source: &StringURL{sourceURL}, Destination: destURL}}, false}, {"bad from", []byte(`{"policy":[{"from": "%","to":"httpbin.org"}]}`), nil, true}, {"bad to", []byte(`{"policy":[{"from": "pomerium.io","to":"%"}]}`), nil, true}, } diff --git a/config/policy.go b/config/policy.go index 833a5b362..8705ae55b 100644 --- a/config/policy.go +++ b/config/policy.go @@ -21,8 +21,13 @@ type Policy struct { AllowedGroups []string `mapstructure:"allowed_groups" yaml:"allowed_groups,omitempty" json:"allowed_groups,omitempty"` AllowedDomains []string `mapstructure:"allowed_domains" yaml:"allowed_domains,omitempty" json:"allowed_domains,omitempty"` - Source *HostnameURL `yaml:",omitempty" json:"source,omitempty"` - Destination *url.URL `yaml:",omitempty" json:"destination,omitempty"` + Source *StringURL `yaml:",omitempty" json:"source,omitempty"` + Destination *url.URL `yaml:",omitempty" json:"destination,omitempty"` + + // Additional route matching options + Prefix string `mapstructure:"prefix" yaml:"prefix,omitempty" json:"prefix,omitempty"` + Path string `mapstructure:"path" yaml:"path,omitempty" json:"path,omitempty"` + Regex string `mapstructure:"regex" yaml:"regex,omitempty" json:"regex,omitempty"` // Allow unauthenticated HTTP OPTIONS requests as per the CORS spec // https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#Preflighted_requests @@ -85,7 +90,14 @@ func (p *Policy) Validate() error { if err != nil { return fmt.Errorf("config: policy bad source url %w", err) } - p.Source = &HostnameURL{source} + + // Make sure there's no path set on the from url + if !(source.Path == "" || source.Path == "/") { + return fmt.Errorf("config: policy source url (%s) contains a path, but it should be set using the path field instead", + source.String()) + } + + p.Source = &StringURL{source} p.Destination, err = urlutil.ParseAndValidateURL(p.To) if err != nil { @@ -135,13 +147,12 @@ func (p *Policy) String() string { return fmt.Sprintf("%s → %s", p.Source.String(), p.Destination.String()) } -// HostnameURL wraps url but marshals only the host representation of that -// url struct. -type HostnameURL struct { +// StringURL stores a URL as a string in json. +type StringURL struct { *url.URL } // MarshalJSON returns the URLs host as json. -func (j *HostnameURL) MarshalJSON() ([]byte, error) { - return json.Marshal(j.Host) +func (u *StringURL) MarshalJSON() ([]byte, error) { + return json.Marshal(u.String()) } diff --git a/config/policy_test.go b/config/policy_test.go index b64c6b9b1..fbe532744 100644 --- a/config/policy_test.go +++ b/config/policy_test.go @@ -20,6 +20,7 @@ func Test_PolicyValidate(t *testing.T) { {"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}, + {"path in from", Policy{From: "https://httpbin.corp.example/some/path", To: "https://httpbin.corp.example"}, true}, {"cors policy", Policy{From: "https://httpbin.corp.example", To: "https://httpbin.corp.notatld", CORSAllowPreflight: true}, false}, {"public policy", Policy{From: "https://httpbin.corp.example", To: "https://httpbin.corp.notatld", AllowPublicUnauthenticatedAccess: true}, false}, {"public and whitelist", Policy{From: "https://httpbin.corp.example", To: "https://httpbin.corp.notatld", AllowPublicUnauthenticatedAccess: true, AllowedUsers: []string{"test@domain.example"}}, true}, @@ -57,8 +58,8 @@ func TestPolicy_String(t *testing.T) { want string wantFrom string }{ - {"good", "https://pomerium.io", "https://localhost", "https://pomerium.io → https://localhost", `"pomerium.io"`}, - {"failed to validate", "https://pomerium.io", "localhost", "https://pomerium.io → localhost", `"pomerium.io"`}, + {"good", "https://pomerium.io", "https://localhost", "https://pomerium.io → https://localhost", `"https://pomerium.io"`}, + {"failed to validate", "https://pomerium.io", "localhost", "https://pomerium.io → localhost", `"https://pomerium.io"`}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/docs/configuration/readme.md b/docs/configuration/readme.md index e7e24cf7c..e2b29110d 100644 --- a/docs/configuration/readme.md +++ b/docs/configuration/readme.md @@ -753,12 +753,27 @@ Policy contains route specific settings, and access control details. If you are <<< @/docs/configuration/examples/config/policy.example.yaml +Policy routes are checked in the order they appear in the policy, so more specific routes should appear before less specific routes. For example: + +```yaml +policies: + - from: http://from.example.com + to: http://to.example.com + prefix: /admin + allowed_groups: ["superuser"] + - from: http://from.example.com + to: http://to.example.com + allow_public_unauthenticated_access: true +``` + +In this example an incoming request with a path prefix of `/admin` would be handled by the first route (which is restricted to superusers). All other requests for `from.example.com` would be handled by the second route (which is open to the public). + A list of policy configuration variables follows. ### From - `yaml`/`json` setting: `from` -- Type: `URL` (must contain a scheme and hostname) +- Type: `URL` (must contain a scheme and hostname, must not contain a path) - Required - Example: `https://httpbin.corp.example.com` @@ -773,6 +788,33 @@ A list of policy configuration variables follows. `To` is the destination of a proxied request. It can be an internal resource, or an external resource. +### Prefix + +- `yaml`/`json` setting: `prefix` +- Type: `string` +- Optional +- Example: `/admin` + +If set, the route will only match incoming requests with a path that begins with the specified prefix. + +### Path + +- `yaml`/`json` setting: `path` +- Type: `string` +- Optional +- Example: `/admin/some/exact/path` + +If set, the route will only match incoming requests with a path that is an exact match for the specified path. + +### Regex + +- `yaml`/`json` setting: `regex` +- Type: `string` (containing a regular expression) +- Optional +- Example: `^/(admin|superuser)/.*$` + +If set, the route will only match incoming requests with a path that matches the specified regular expression. The supported syntax is the same as the Go [regexp package](https://golang.org/pkg/regexp/) which is based on [re2](https://github.com/google/re2/wiki/Syntax). + ### Allowed Users - `yaml`/`json` setting: `allowed_users` diff --git a/docs/docs/upgrading.md b/docs/docs/upgrading.md index 17270c678..a838d9f24 100644 --- a/docs/docs/upgrading.md +++ b/docs/docs/upgrading.md @@ -5,6 +5,45 @@ description: >- for Pomerium. Please read it carefully. --- +# Since 0.8.0 + +## Breaking + +### Using paths in from URLs + +Although it's unlikely anyone ever used it, prior to 0.8.0 the policy configuration allowed you to specify a `from` field with a path component: + +```yaml +policy: + - from: "https://example.com/some/path" +``` +The proxy and authorization server would simply ignore the path and route/authorize based on the host name. + +With the introduction of `prefix`, `path` and `regex` fields to the policy route configuration, we decided not to support using a path in the `from` url, since the behavior was somewhat ambiguous and better handled by the explicit fields. + +To avoid future confusion, the application will now declare any configuration which contains a `from` field with a path as invalid, with this error message: + +``` +config: policy source url (%s) contains a path, but it should be set using the path field instead +``` + +If you see this error you can fix it by simply removing the path from the `from` field and moving it to a `prefix` field. + +In other words, this configuration: + +```yaml +policy: + - from: "http://example.com/some/path" +``` + +Should be written like this: + +```yaml +policy: + - from: "http://example.com" + prefix: "/some/path" +``` + # Since 0.6.0 ## Breaking diff --git a/proxy/forward_auth.go b/proxy/forward_auth.go index 6c362bb9f..133948154 100644 --- a/proxy/forward_auth.go +++ b/proxy/forward_auth.go @@ -115,6 +115,7 @@ func (p *Proxy) Verify(verifyOnly bool) http.Handler { if err != nil { return httputil.NewError(http.StatusBadRequest, err) } + originalRequest := p.getOriginalRequest(r, uri) if _, err := sessions.FromContext(r.Context()); err != nil { if verifyOnly { @@ -130,10 +131,7 @@ func (p *Proxy) Verify(verifyOnly bool) http.Handler { return nil } - r.Host = uri.Host - r.URL = uri - r.RequestURI = uri.String() - if err := p.authorize(w, r); err != nil { + if err := p.authorize(w, originalRequest); err != nil { return err } @@ -143,3 +141,10 @@ func (p *Proxy) Verify(verifyOnly bool) http.Handler { return nil }) } + +func (p *Proxy) getOriginalRequest(r *http.Request, originalURL *url.URL) *http.Request { + originalRequest := r.Clone(r.Context()) + originalRequest.Host = originalURL.Host + originalRequest.URL = originalURL + return originalRequest +} diff --git a/proxy/proxy.go b/proxy/proxy.go index 311f32f14..43339000a 100644 --- a/proxy/proxy.go +++ b/proxy/proxy.go @@ -14,6 +14,8 @@ import ( "net/http" stdhttputil "net/http/httputil" "net/url" + "regexp" + "strings" "time" "github.com/gorilla/mux" @@ -230,8 +232,8 @@ func (p *Proxy) reverseProxyHandler(r *mux.Router, policy config.Policy) *mux.Ro // 4. Override any custom transport settings (e.g. TLS settings, etc) proxy.Transport = p.roundTripperFromPolicy(&policy) - // 5. Create a sub-router for a given route's hostname (`httpbin.corp.example.com`) - rp := r.Host(policy.Source.Host).Subrouter() + // 5. Create a sub-router with a matcher derived from the policy (host, path, etc...) + rp := r.MatcherFunc(routeMatcherFuncFromPolicy(policy)).Subrouter() rp.PathPrefix("/").Handler(proxy) // Optional: If websockets are enabled, do not set a handler request timeout @@ -323,3 +325,42 @@ func (p *Proxy) roundTripperFromPolicy(policy *config.Policy) http.RoundTripper func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { p.Handler.ServeHTTP(w, r) } + +// routeMatcherFuncFromPolicy returns a mux matcher function which compares an http request with a policy. +// +// Routes can be filtered by the `source`, `prefix`, `path` and `regex` fields in the policy config. +func routeMatcherFuncFromPolicy(policy config.Policy) mux.MatcherFunc { + // match by source + sourceMatches := func(r *http.Request) bool { + return r.Host == policy.Source.Host + } + + // match by prefix + prefixMatches := func(r *http.Request) bool { + return policy.Prefix == "" || + strings.HasPrefix(r.URL.Path, policy.Prefix) + } + + // match by path + pathMatches := func(r *http.Request) bool { + return policy.Path == "" || + r.URL.Path == policy.Path + } + + // match by path regex + var regexMatches func(*http.Request) bool + if policy.Regex == "" { + regexMatches = func(r *http.Request) bool { return true } + } else if re, err := regexp.Compile(policy.Regex); err == nil { + regexMatches = func(r *http.Request) bool { + return re.MatchString(r.URL.Path) + } + } else { + log.Error().Err(err).Str("regex", policy.Regex).Msg("proxy: invalid regex in policy, ignoring route") + regexMatches = func(r *http.Request) bool { return false } + } + + return func(r *http.Request, rm *mux.RouteMatch) bool { + return sourceMatches(r) && prefixMatches(r) && pathMatches(r) && regexMatches(r) + } +} diff --git a/proxy/proxy_test.go b/proxy/proxy_test.go index 47298a2c5..b4b770f6c 100644 --- a/proxy/proxy_test.go +++ b/proxy/proxy_test.go @@ -1,6 +1,7 @@ package proxy import ( + "io" "io/ioutil" "net" "net/http" @@ -276,3 +277,138 @@ func TestNewReverseProxy(t *testing.T) { t.Errorf("got body %q; expected %q", g, e) } } + +func TestRouteMatcherFuncFromPolicy(t *testing.T) { + tests := []struct { + source, prefix, path, regex string + incomingURL string + expect bool + msg string + }{ + // host in source + {"https://www.example.com", "", "", "", + "https://www.example.com", true, + "should match when host is the same as source host"}, + {"https://www.example.com/", "", "", "", + "https://www.example.com", true, + "should match when host is the same as source host with trailing slash"}, + {"https://www.example.com", "", "", "", + "https://www.google.com", false, + "should not match when host is different from source host"}, + + // path prefix + {"https://www.example.com", "/admin", "", "", + "https://www.example.com/admin/someaction", true, + "should match when path begins with prefix"}, + {"https://www.example.com", "/admin", "", "", + "https://www.example.com/notadmin", false, + "should not match when path does not begin with prefix"}, + + // path + {"https://www.example.com", "", "/admin", "", + "https://www.example.com/admin", true, + "should match when path is the same as the policy path"}, + {"https://www.example.com", "", "/admin", "", + "https://www.example.com/admin/someaction", false, + "should not match when path merely begins with the policy path"}, + {"https://www.example.com", "", "/admin", "", + "https://www.example.com/notadmin", false, + "should not match when path is different from the policy path"}, + + // path regex + {"https://www.example.com", "", "", "^/admin/[a-z]+$", + "https://www.example.com/admin/someaction", true, + "should match when path matches policy path regex"}, + {"https://www.example.com", "", "", "^/admin/[a-z]+$", + "https://www.example.com/notadmin", false, + "should not match when path does not match policy path regex"}, + {"https://www.example.com", "", "", "invalid[", + "https://www.example.com/invalid", false, + "should not match on invalid policy path regex"}, + } + + for _, tt := range tests { + srcURL, err := url.Parse(tt.source) + if err != nil { + panic(err) + } + src := &config.StringURL{URL: srcURL} + matcher := routeMatcherFuncFromPolicy(config.Policy{ + Source: src, + Prefix: tt.prefix, + Path: tt.path, + Regex: tt.regex, + }) + req, err := http.NewRequest("GET", tt.incomingURL, nil) + if err != nil { + panic(err) + } + actual := matcher(req, nil) + if actual != tt.expect { + t.Errorf("%s (source=%s prefix=%s path=%s regex=%s incoming-url=%s)", + tt.msg, tt.source, tt.prefix, tt.path, tt.regex, tt.incomingURL) + } + } +} + +func TestPolicyPrefixRouting(t *testing.T) { + adminServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + io.WriteString(w, "admin: "+r.URL.Path) + })) + defer adminServer.Close() + + publicServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + io.WriteString(w, "public: "+r.URL.Path) + })) + defer publicServer.Close() + + opts := testOptions(t) + opts.Policies = []config.Policy{ + { + From: "https://from.example.com", + To: "http://" + adminServer.Listener.Addr().String(), + Prefix: "/admin", + AllowPublicUnauthenticatedAccess: true, + }, + { + From: "https://from.example.com", + To: "http://" + publicServer.Listener.Addr().String(), + AllowPublicUnauthenticatedAccess: true, + }, + } + + p, err := New(opts) + if err != nil { + t.Fatalf("error creating proxy: %v", err) + } + + t.Run("admin", func(t *testing.T) { + req, err := http.NewRequest("GET", "https://from.example.com/admin/path", nil) + if err != nil { + t.Fatalf("error creating http request: %v", err) + } + + rr := httptest.NewRecorder() + p.ServeHTTP(rr, req) + rr.Flush() + + if rr.Body.String() != "admin: /admin/path" { + t.Errorf("expected admin request to go to the admin backend") + } + }) + + t.Run("non-admin", func(t *testing.T) { + req, err := http.NewRequest("GET", "https://from.example.com/nonadmin/path", nil) + if err != nil { + t.Fatalf("error creating http request: %v", err) + } + + rr := httptest.NewRecorder() + p.ServeHTTP(rr, req) + rr.Flush() + + if rr.Body.String() != "public: /nonadmin/path" { + t.Errorf("expected non-admin request to go to the public backend") + } + }) +} diff --git a/scripts/build-dev-docker.bash b/scripts/build-dev-docker.bash new file mode 100755 index 000000000..39f146f33 --- /dev/null +++ b/scripts/build-dev-docker.bash @@ -0,0 +1,42 @@ +#!/bin/bash +set -euxo pipefail + +_dir=/tmp/pomerium-dev-docker +mkdir -p "$_dir" + +# build linux binary +env GOOS=linux \ + GOARCH=amd64 \ + CGO_ENABLED=0 \ + GO111MODULE=on \ + go build \ + -ldflags "-s -w" \ + -o "$_dir/pomerium" \ + ./cmd/pomerium + +# build docker image +( + + +cd $_dir +cat <config.yaml + +EOF +cat <Dockerfile +FROM gcr.io/distroless/base:debug +WORKDIR /pomerium +COPY pomerium /bin/pomerium +COPY config.yaml /pomerium/config.yaml +ENTRYPOINT [ "/bin/pomerium" ] +CMD ["-config","/pomerium/config.yaml"] +EOF +docker build --tag=pomerium/pomerium:dev . + +# build for minikube +if command -v minikube >/dev/null 2>&1 ; then + eval "$(minikube docker-env --shell=bash)" + docker build --tag=pomerium/pomerium:dev . +fi + + +)