mirror of
https://github.com/pomerium/pomerium.git
synced 2025-07-17 08:38:15 +02:00
Merge pull request #615 from pomerium/cdoxsey/policy-path
implement path-based route matching
This commit is contained in:
commit
45c706666c
17 changed files with 549 additions and 32 deletions
6
.github/workflows/test.yaml
vendored
6
.github/workflows/test.yaml
vendored
|
@ -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:
|
||||
|
|
10
Makefile
10
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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
102
authorize/evaluator/opa/policy/authz_test.rego
Normal file
102
authorize/evaluator/opa/policy/authz_test.rego
Normal file
|
@ -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]"})
|
||||
}
|
File diff suppressed because one or more lines are too long
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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},
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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`
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
42
scripts/build-dev-docker.bash
Executable file
42
scripts/build-dev-docker.bash
Executable file
|
@ -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 <<EOF >config.yaml
|
||||
|
||||
EOF
|
||||
cat <<EOF >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
|
||||
|
||||
|
||||
)
|
Loading…
Add table
Add a link
Reference in a new issue