diff --git a/config/config.go b/config/config.go index 97dea45..f228032 100755 --- a/config/config.go +++ b/config/config.go @@ -146,6 +146,9 @@ type Proxy struct { // Tips for performance: define your health check endpoint with a different length from the most frequently used endpoint, for example, use `/healthcheck` (len: 12) when `/most_used` (len: 10), instead of `/healthccc` (len: 10) OriginHealthCheckPaths []string `yaml:"originHealthCheckPaths"` + // NoAuthPaths represents endpoints that requires NO authorization. Wildcard characters supported in Athenz policy are supported too. + NoAuthPaths []string `yaml:"noAuthPaths"` + // PreserveHost represents whether to preserve the host header from the request. PreserveHost bool `yaml:"preserveHost"` diff --git a/config/config_test.go b/config/config_test.go index 065b5be..8627890 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -111,7 +111,12 @@ func TestNew(t *testing.T) { Port: 80, BufferSize: 4096, OriginHealthCheckPaths: []string{}, - PreserveHost: true, + NoAuthPaths: []string{ + "/no-auth/any/*", + "/no-auth/single/a?c", + "/no-auth/no-regex/^$|([{", + }, + PreserveHost: true, Transport: Transport{ TLSHandshakeTimeout: 10 * time.Second, DisableKeepAlives: false, diff --git a/handler/error.go b/handler/error.go index 4edf8be..6c06fd7 100644 --- a/handler/error.go +++ b/handler/error.go @@ -48,4 +48,7 @@ const ( // ErrRoleTokenNotFound "role token not found" ErrRoleTokenNotFound = "role token not found" + + // ErrInvalidProxyConfig "invalid proxy config". + ErrInvalidProxyConfig = "invalid proxy config" ) diff --git a/handler/handler.go b/handler/handler.go index 8418fd6..82a6011 100644 --- a/handler/handler.go +++ b/handler/handler.go @@ -29,6 +29,7 @@ import ( "github.com/kpango/glg" "github.com/pkg/errors" + "github.com/AthenZ/athenz-authorizer/v5/policy" "github.com/AthenZ/authorization-proxy/v4/config" "github.com/AthenZ/authorization-proxy/v4/service" ) @@ -89,6 +90,7 @@ func New(cfg config.Proxy, bp httputil.BufferPool, prov service.Authorizationd) prov: prov, RoundTripper: transportFromCfg(cfg.Transport), cfg: cfg, + noAuthPaths: mapPathToAssertion(cfg.NoAuthPaths), }, ErrorHandler: handleError, } @@ -156,6 +158,20 @@ func transportFromCfg(cfg config.Transport) *http.Transport { return t } +func mapPathToAssertion(paths []string) []*policy.Assertion { + as := make([]*policy.Assertion, len(paths)) + for i, p := range paths { + var err error + as[i], err = policy.NewAssertion("", ":"+p, "") + if err != nil { + // NewAssertion() escapes all regex characters and should NOT return ANY errors. + glg.Errorf("Invalid proxy.noAuthPaths: %s", p) + panic(ErrInvalidProxyConfig) + } + } + return as +} + func handleError(rw http.ResponseWriter, r *http.Request, err error) { if r != nil && r.Body != nil { io.Copy(ioutil.Discard, r.Body) diff --git a/handler/handler_test.go b/handler/handler_test.go index f30815f..e16ad17 100644 --- a/handler/handler_test.go +++ b/handler/handler_test.go @@ -15,6 +15,7 @@ import ( "time" authorizerd "github.com/AthenZ/athenz-authorizer/v5" + "github.com/AthenZ/athenz-authorizer/v5/policy" "github.com/AthenZ/authorization-proxy/v4/config" "github.com/AthenZ/authorization-proxy/v4/infra" "github.com/AthenZ/authorization-proxy/v4/service" @@ -625,6 +626,93 @@ func Test_transportFromCfg(t *testing.T) { } } +func Test_mapPathToAssertion(t *testing.T) { + type args struct { + paths []string + } + tests := []struct { + name string + args args + want []*policy.Assertion + wantPanic any + }{ + { + name: "nil list", + args: args{ + paths: nil, + }, + want: []*policy.Assertion{}, + }, + { + name: "empty list", + args: args{ + paths: []string{}, + }, + want: []*policy.Assertion{}, + }, + { + name: "single assertion", + args: args{ + paths: []string{ + "/path/656", + }, + }, + want: func() (as []*policy.Assertion) { + a, err := policy.NewAssertion("", ":/path/656", "") + if err != nil { + panic(err) + } + as = append(as, a) + return as + }(), + }, + { + name: "multiple assertion", + args: args{ + paths: []string{ + "/path/672", + "/path/673", + }, + }, + want: func() (as []*policy.Assertion) { + a1, err := policy.NewAssertion("", ":/path/672", "") + if err != nil { + panic(err) + } + a2, err := policy.NewAssertion("", ":/path/673", "") + if err != nil { + panic(err) + } + as = append(as, a1, a2) + return as + }(), + }, + // { + // name: "invalid assertion", + // args: args{ + // paths: []string{ + // "no invalid value", + // }, + // }, + // want: nil, + // wantPanic: ErrInvalidProxyConfig, + // }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + defer func() { + err := recover() + if err != tt.wantPanic { + t.Errorf("mapPathToAssertion() panic = %v, want panic %v", err, tt.wantPanic) + } + }() + if got := mapPathToAssertion(tt.args.paths); !reflect.DeepEqual(got, tt.want) { + t.Errorf("mapPathToAssertion() = %v, want %v", got, tt.want) + } + }) + } +} + func Test_handleError(t *testing.T) { type args struct { rw http.ResponseWriter diff --git a/handler/transport.go b/handler/transport.go index 99ab0dd..62f3a50 100644 --- a/handler/transport.go +++ b/handler/transport.go @@ -22,6 +22,7 @@ import ( "strings" authorizerd "github.com/AthenZ/athenz-authorizer/v5" + "github.com/AthenZ/athenz-authorizer/v5/policy" "github.com/AthenZ/authorization-proxy/v4/config" "github.com/AthenZ/authorization-proxy/v4/service" @@ -32,18 +33,29 @@ import ( type transport struct { http.RoundTripper - prov service.Authorizationd - cfg config.Proxy + prov service.Authorizationd + cfg config.Proxy + noAuthPaths []*policy.Assertion } // Based on the following. // https://github.com/golang/oauth2/blob/bf48bf16ab8d622ce64ec6ce98d2c98f916b6303/transport.go func (t *transport) RoundTrip(r *http.Request) (*http.Response, error) { - for _, urlPath := range t.cfg.OriginHealthCheckPaths { - if urlPath == r.URL.Path { - glg.Info("Authorization checking skipped on: " + r.URL.Path) - r.TLS = nil - return t.RoundTripper.RoundTrip(r) + // bypass authoriztion + if len(r.URL.Path) != 0 { // prevent bypassing empty path on default config + for _, urlPath := range t.cfg.OriginHealthCheckPaths { + if urlPath == r.URL.Path { + glg.Info("Authorization checking skipped on: " + r.URL.Path) + r.TLS = nil + return t.RoundTripper.RoundTrip(r) + } + } + for _, ass := range t.noAuthPaths { + if ass.ResourceRegexp.MatchString(strings.ToLower(r.URL.Path)) { + glg.Infof("Authorization checking skipped by %s on: %s", ass.ResourceRegexpString, r.URL.Path) + r.TLS = nil + return t.RoundTripper.RoundTrip(r) + } } } diff --git a/handler/transport_test.go b/handler/transport_test.go index 68eb7dc..afcfc53 100644 --- a/handler/transport_test.go +++ b/handler/transport_test.go @@ -2,11 +2,13 @@ package handler import ( "errors" + "io" "net/http" "reflect" "testing" authorizerd "github.com/AthenZ/athenz-authorizer/v5" + "github.com/AthenZ/athenz-authorizer/v5/policy" "github.com/AthenZ/authorization-proxy/v4/config" "github.com/AthenZ/authorization-proxy/v4/service" ) @@ -26,10 +28,18 @@ func (r *readCloseCounter) Close() error { } func Test_transport_RoundTrip(t *testing.T) { + wrapAssertion := func(s string) *policy.Assertion { + a, err := policy.NewAssertion("", ":"+s, "") + if err != nil { + panic(err) + } + return a + } type fields struct { RoundTripper http.RoundTripper prov service.Authorizationd cfg config.Proxy + noAuthPaths []*policy.Assertion } type args struct { r *http.Request @@ -287,6 +297,90 @@ func Test_transport_RoundTrip(t *testing.T) { wantErr: true, wantCloseCount: 1, }, + { + name: "NoAuthPaths match, bypass role token verification", + fields: fields{ + RoundTripper: &RoundTripperMock{ + RoundTripFunc: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 200, + }, nil + }, + }, + prov: &service.AuthorizerdMock{ + VerifyFunc: func(r *http.Request, act, res string) (authorizerd.Principal, error) { + return nil, errors.New("role token error") + }, + }, + cfg: config.Proxy{}, + noAuthPaths: []*policy.Assertion{ + wrapAssertion("/no-auth"), + }, + }, + args: args{ + r: func() *http.Request { + r, _ := http.NewRequest("GET", "http://athenz.io/no-auth", nil) + return r + }(), + body: &readCloseCounter{ + ReadErr: errors.New("readCloseCounter.Read not implemented"), + }, + }, + want: &http.Response{ + StatusCode: 200, + }, + wantErr: false, + wantCloseCount: 0, + }, + { + name: "NoAuthPaths NONE match, verify role token", + fields: fields{ + RoundTripper: nil, + prov: &service.AuthorizerdMock{ + VerifyFunc: func(r *http.Request, act, res string) (authorizerd.Principal, error) { + return nil, errors.New("role token error") + }, + }, + cfg: config.Proxy{}, + noAuthPaths: []*policy.Assertion{ + wrapAssertion("/no-auth"), + }, + }, + args: args{ + r: func() *http.Request { + r, _ := http.NewRequest("GET", "http://athenz.io/no-auth/", nil) + return r + }(), + body: &readCloseCounter{ + ReadErr: errors.New("readCloseCounter.Read not implemented"), + }, + }, + wantErr: true, + wantCloseCount: 1, + }, + { + name: "NoAuthPaths NOT set, verify role token", + fields: fields{ + RoundTripper: nil, + prov: &service.AuthorizerdMock{ + VerifyFunc: func(r *http.Request, act, res string) (authorizerd.Principal, error) { + return nil, errors.New("role token error") + }, + }, + cfg: config.Proxy{}, + }, + args: args{ + r: func() *http.Request { + r, _ := http.NewRequest("GET", "http://athenz.io/no-auth", nil) + return r + }(), + body: &readCloseCounter{ + ReadErr: errors.New("readCloseCounter.Read not implemented"), + }, + }, + wantErr: true, + wantCloseCount: 1, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -294,6 +388,7 @@ func Test_transport_RoundTrip(t *testing.T) { RoundTripper: tt.fields.RoundTripper, prov: tt.fields.prov, cfg: tt.fields.cfg, + noAuthPaths: tt.fields.noAuthPaths, } if tt.args.body != nil { tt.args.r.Body = tt.args.body @@ -314,3 +409,291 @@ func Test_transport_RoundTrip(t *testing.T) { }) } } + +func Test_transport_RoundTrip_WildcardBypass(t *testing.T) { + wrapAssertion := func(s string) *policy.Assertion { + a, err := policy.NewAssertion("", ":"+s, "") + if err != nil { + panic(err) + } + return a + } + wrapRequest := func(method, url string, body io.Reader) *http.Request { + r, err := http.NewRequest(method, url, body) + if err != nil { + panic(err) + } + return r + } + type fields struct { + RoundTripper http.RoundTripper + prov service.Authorizationd + cfg config.Proxy + noAuthPaths []*policy.Assertion + } + type args struct { + r *http.Request + body *readCloseCounter + } + tests := []struct { + name string + fields fields + argss []args + want *http.Response + wantErr bool + wantCloseCount int + }{ + { + name: "NoAuthPaths '*' match, bypass role token verification", + fields: fields{ + RoundTripper: &RoundTripperMock{ + RoundTripFunc: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 200, + }, nil + }, + }, + prov: &service.AuthorizerdMock{ + VerifyFunc: func(r *http.Request, act, res string) (authorizerd.Principal, error) { + return nil, errors.New("role token error") + }, + }, + cfg: config.Proxy{}, + noAuthPaths: []*policy.Assertion{ + wrapAssertion("/no-auth*"), + }, + }, + argss: []args{ + { + r: wrapRequest("GET", "http://athenz.io/no-auth", nil), + body: &readCloseCounter{ + ReadErr: errors.New("readCloseCounter.Read not implemented"), + }, + }, + { + r: wrapRequest("GET", "http://athenz.io/no-authhhhhh", nil), + body: &readCloseCounter{ + ReadErr: errors.New("readCloseCounter.Read not implemented"), + }, + }, + { + r: wrapRequest("GET", "http://athenz.io/no-auth/", nil), + body: &readCloseCounter{ + ReadErr: errors.New("readCloseCounter.Read not implemented"), + }, + }, + { + r: wrapRequest("GET", "http://athenz.io/no-auth/abc", nil), + body: &readCloseCounter{ + ReadErr: errors.New("readCloseCounter.Read not implemented"), + }, + }, + { + r: wrapRequest("GET", "http://athenz.io/no-auth/abc/483", nil), + body: &readCloseCounter{ + ReadErr: errors.New("readCloseCounter.Read not implemented"), + }, + }, + }, + want: &http.Response{ + StatusCode: 200, + }, + wantErr: false, + wantCloseCount: 0, + }, + { + name: "NoAuthPaths '?' match, bypass role token verification", + fields: fields{ + RoundTripper: &RoundTripperMock{ + RoundTripFunc: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 200, + }, nil + }, + }, + prov: &service.AuthorizerdMock{ + VerifyFunc: func(r *http.Request, act, res string) (authorizerd.Principal, error) { + return nil, errors.New("role token error") + }, + }, + cfg: config.Proxy{}, + noAuthPaths: []*policy.Assertion{ + wrapAssertion("/no-auth/a??c"), + }, + }, + argss: []args{ + { + r: wrapRequest("GET", "http://athenz.io/no-auth/aaac", nil), + body: &readCloseCounter{ + ReadErr: errors.New("readCloseCounter.Read not implemented"), + }, + }, + { + r: wrapRequest("GET", "http://athenz.io/no-auth/accc", nil), + body: &readCloseCounter{ + ReadErr: errors.New("readCloseCounter.Read not implemented"), + }, + }, + { + r: wrapRequest("GET", "http://athenz.io/no-auth/abbc", nil), + body: &readCloseCounter{ + ReadErr: errors.New("readCloseCounter.Read not implemented"), + }, + }, + }, + want: &http.Response{ + StatusCode: 200, + }, + wantErr: false, + wantCloseCount: 0, + }, + { + name: "NoAuthPaths '?' NOT match, verify role token", + fields: fields{ + RoundTripper: nil, + prov: &service.AuthorizerdMock{ + VerifyFunc: func(r *http.Request, act, res string) (authorizerd.Principal, error) { + return nil, errors.New("role token error") + }, + }, + cfg: config.Proxy{}, + noAuthPaths: []*policy.Assertion{ + wrapAssertion("/no-auth/a??c"), + }, + }, + argss: []args{ + { + r: wrapRequest("GET", "http://athenz.io/no-auth/aaaa", nil), + body: &readCloseCounter{ + ReadErr: errors.New("readCloseCounter.Read not implemented"), + }, + }, + { + r: wrapRequest("GET", "http://athenz.io/no-auth/cccc", nil), + body: &readCloseCounter{ + ReadErr: errors.New("readCloseCounter.Read not implemented"), + }, + }, + { + r: wrapRequest("GET", "http://athenz.io/no-auth/abbbc", nil), + body: &readCloseCounter{ + ReadErr: errors.New("readCloseCounter.Read not implemented"), + }, + }, + { + r: wrapRequest("GET", "http://athenz.io/no-auth/123456", nil), + body: &readCloseCounter{ + ReadErr: errors.New("readCloseCounter.Read not implemented"), + }, + }, + }, + wantErr: true, + wantCloseCount: 1, + }, + { + name: "NoAuthPaths empty string NOT match, verify role token", + fields: fields{ + RoundTripper: nil, + prov: &service.AuthorizerdMock{ + VerifyFunc: func(r *http.Request, act, res string) (authorizerd.Principal, error) { + return nil, errors.New("role token error") + }, + }, + cfg: config.Proxy{}, + noAuthPaths: []*policy.Assertion{ + wrapAssertion(""), + }, + }, + argss: []args{ + { + r: wrapRequest("GET", "http://athenz.io", nil), + body: &readCloseCounter{ + ReadErr: errors.New("readCloseCounter.Read not implemented"), + }, + }, + { + r: wrapRequest("GET", "http://athenz.io/", nil), + body: &readCloseCounter{ + ReadErr: errors.New("readCloseCounter.Read not implemented"), + }, + }, + }, + wantErr: true, + wantCloseCount: 1, + }, + { + name: "NoAuthPaths NO escape, verify role token", + fields: fields{ + RoundTripper: &RoundTripperMock{ + RoundTripFunc: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 200, + }, nil + }, + }, + prov: &service.AuthorizerdMock{ + VerifyFunc: func(r *http.Request, act, res string) (authorizerd.Principal, error) { + return nil, errors.New("role token error") + }, + }, + cfg: config.Proxy{}, + noAuthPaths: []*policy.Assertion{ + wrapAssertion("/no-auth/wildcard/\\*"), + wrapAssertion("/no-auth/single/\\?"), + wrapAssertion("/no-auth/escape/\\\\"), + }, + }, + argss: []args{ + { + r: wrapRequest("GET", "http://athenz.io/no-auth/wildcard/*", nil), + body: &readCloseCounter{ + ReadErr: errors.New("readCloseCounter.Read not implemented"), + }, + }, + { + r: wrapRequest("GET", "http://athenz.io/no-auth/single/?", nil), + body: &readCloseCounter{ + ReadErr: errors.New("readCloseCounter.Read not implemented"), + }, + }, + { + r: wrapRequest("GET", "http://athenz.io/no-auth/escape/\\", nil), + body: &readCloseCounter{ + ReadErr: errors.New("readCloseCounter.Read not implemented"), + }, + }, + }, + wantErr: true, + wantCloseCount: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tr := &transport{ + RoundTripper: tt.fields.RoundTripper, + prov: tt.fields.prov, + cfg: tt.fields.cfg, + noAuthPaths: tt.fields.noAuthPaths, + } + for _, args := range tt.argss { + if args.body != nil { + args.r.Body = args.body + } + got, err := tr.RoundTrip(args.r) + if (err != nil) != tt.wantErr { + t.Errorf("transport.RoundTrip() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("transport.RoundTrip() = %v, want %v", got, tt.want) + } + if args.body != nil { + if args.body.CloseCount != tt.wantCloseCount { + t.Errorf("Body was closed %d times, expected %d", args.body.CloseCount, tt.wantCloseCount) + } + } + } + }) + } +} diff --git a/test/data/example_config.yaml b/test/data/example_config.yaml index eabb86a..a3a30e5 100644 --- a/test/data/example_config.yaml +++ b/test/data/example_config.yaml @@ -29,6 +29,10 @@ proxy: port: 80 bufferSize: 4096 originHealthCheckPaths: [] + noAuthPaths: + - "/no-auth/any/*" + - "/no-auth/single/a?c" + - "/no-auth/no-regex/^$|([{" preserveHost: true transport: tlsHandshakeTimeout: "10s"