diff --git a/pkg/apis/options/upstreams.go b/pkg/apis/options/upstreams.go new file mode 100644 index 00000000..5a8eebe5 --- /dev/null +++ b/pkg/apis/options/upstreams.go @@ -0,0 +1,60 @@ +package options + +import "time" + +// Upstreams is a collection of definitions for upstream servers. +type Upstreams []Upstream + +// Upstream represents the configuration for an upstream server. +// Requests will be proxied to this upstream if the path matches the request path. +type Upstream struct { + // ID should be a unique identifier for the upstream. + // This value is required for all upstreams. + ID string `json:"id"` + + // Path is used to map requests to the upstream server. + // The closest match will take precedence and all Paths must be unique. + Path string `json:"path"` + + // The URI of the upstream server. This may be an HTTP(S) server of a File + // based URL. It may include a path, in which case all requests will be served + // under that path. + // Eg: + // - http://localhost:8080 + // - https://service.localhost + // - https://service.localhost/path + // - file://host/path + // If the URI's path is "/base" and the incoming request was for "/dir", + // the upstream request will be for "/base/dir". + URI string `json:"uri"` + + // InsecureSkipTLSVerify will skip TLS verification of upstream HTTPS hosts. + // This option is insecure and will allow potential Man-In-The-Middle attacks + // betweem OAuth2 Proxy and the usptream server. + // Defaults to false. + InsecureSkipTLSVerify bool `json:"insecureSkipTLSVerify"` + + // Static will make all requests to this upstream have a static response. + // The response will have a body of "Authenticated" and a response code + // matching StaticCode. + // If StaticCode is not set, the response will return a 200 response. + Static bool `json:"static"` + + // StaticCode determines the response code for the Static response. + // This option can only be used with Static enabled. + StaticCode *int `json:"staticCode,omitempty"` + + // FlushInterval is the period between flushing the response buffer when + // streaming response from the upstream. + // Defaults to 1 second. + FlushInterval *time.Duration `json:"flushInterval,omitempty"` + + // PassHostHeader determines whether the request host header should be proxied + // to the upstream server. + // Defaults to true. + PassHostHeader bool `json:"passHostHeader"` + + // ProxyWebSockets enables proxying of websockets to upstream servers + // Defaults to true. + ProxyWebSockets bool `json:"proxyWebSockets"` +} diff --git a/pkg/validation/upstreams.go b/pkg/validation/upstreams.go new file mode 100644 index 00000000..be9c0ea7 --- /dev/null +++ b/pkg/validation/upstreams.go @@ -0,0 +1,113 @@ +package validation + +import ( + "fmt" + "net/url" + "time" + + "github.com/oauth2-proxy/oauth2-proxy/pkg/apis/options" +) + +func validateUpstreams(upstreams options.Upstreams) []string { + msgs := []string{} + ids := make(map[string]struct{}) + paths := make(map[string]struct{}) + + for _, upstream := range upstreams { + msgs = append(msgs, validateUpstream(upstream, ids, paths)...) + } + + return msgs +} + +// validateUpstream validates that the upstream has valid options and that +// the ids and paths are unique across all options +func validateUpstream(upstream options.Upstream, ids, paths map[string]struct{}) []string { + msgs := []string{} + + if upstream.ID == "" { + msgs = append(msgs, "upstream has empty id: ids are required for all upstreams") + } + if upstream.Path == "" { + msgs = append(msgs, fmt.Sprintf("upstream %q has empty path: paths are required for all upstreams", upstream.ID)) + } + + // Ensure upstream IDs are unique + if _, ok := ids[upstream.ID]; ok { + msgs = append(msgs, fmt.Sprintf("multiple upstreams found with id %q: upstream ids must be unique", upstream.ID)) + } + ids[upstream.ID] = struct{}{} + + // Ensure upstream Paths are unique + if _, ok := paths[upstream.Path]; ok { + msgs = append(msgs, fmt.Sprintf("multiple upstreams found with path %q: upstream paths must be unique", upstream.Path)) + } + paths[upstream.Path] = struct{}{} + + msgs = append(msgs, validateUpstreamURI(upstream)...) + msgs = append(msgs, validateStaticUpstream(upstream)...) + return msgs +} + +// validateStaticUpstream checks that the StaticCode is only set when Static +// is set, and that any options that do not make sense for a static upstream +// are not set. +func validateStaticUpstream(upstream options.Upstream) []string { + msgs := []string{} + + if !upstream.Static && upstream.StaticCode != nil { + msgs = append(msgs, fmt.Sprintf("upstream %q has staticCode (%d), but is not a static upstream, set 'static' for a static response", upstream.ID, *upstream.StaticCode)) + } + + // Checks after this only make sense when the upstream is static + if !upstream.Static { + return msgs + } + + if upstream.URI != "" { + msgs = append(msgs, fmt.Sprintf("upstream %q has uri, but is a static upstream, this will have no effect.", upstream.ID)) + } + if upstream.InsecureSkipTLSVerify { + msgs = append(msgs, fmt.Sprintf("upstream %q has insecureSkipTLSVerify, but is a static upstream, this will have no effect.", upstream.ID)) + } + if upstream.FlushInterval != nil && *upstream.FlushInterval != time.Second { + msgs = append(msgs, fmt.Sprintf("upstream %q has flushInterval, but is a static upstream, this will have no effect.", upstream.ID)) + } + if !upstream.PassHostHeader { + msgs = append(msgs, fmt.Sprintf("upstream %q has passHostHeader, but is a static upstream, this will have no effect.", upstream.ID)) + } + if !upstream.ProxyWebSockets { + msgs = append(msgs, fmt.Sprintf("upstream %q has proxyWebSockets, but is a static upstream, this will have no effect.", upstream.ID)) + } + + return msgs +} + +func validateUpstreamURI(upstream options.Upstream) []string { + msgs := []string{} + + if !upstream.Static && upstream.URI == "" { + msgs = append(msgs, fmt.Sprintf("upstream %q has empty uri: uris are required for all non-static upstreams", upstream.ID)) + return msgs + } + + // Checks after this only make sense the upstream is not static + if upstream.Static { + return msgs + } + + u, err := url.Parse(upstream.URI) + if err != nil { + msgs = append(msgs, fmt.Sprintf("upstream %q has invalid uri: %v", upstream.ID, err)) + return msgs + } + + switch u.Scheme { + case "http", "https", "file": + // Valid, do nothing + default: + msgs = append(msgs, fmt.Sprintf("upstream %q has invalid scheme: %q", upstream.ID, u.Scheme)) + } + + return msgs +} diff --git a/pkg/validation/upstreams_test.go b/pkg/validation/upstreams_test.go new file mode 100644 index 00000000..4995bf29 --- /dev/null +++ b/pkg/validation/upstreams_test.go @@ -0,0 +1,191 @@ +package validation + +import ( + "time" + + "github.com/oauth2-proxy/oauth2-proxy/pkg/apis/options" + . "github.com/onsi/ginkgo" + . "github.com/onsi/ginkgo/extensions/table" + . "github.com/onsi/gomega" +) + +var _ = Describe("Upstreams", func() { + type validateUpstreamTableInput struct { + upstreams options.Upstreams + errStrings []string + } + + flushInterval := 5 * time.Second + staticCode200 := 200 + + validHTTPUpstream := options.Upstream{ + ID: "validHTTPUpstream", + Path: "/validHTTPUpstream", + URI: "http://localhost:8080", + } + validStaticUpstream := options.Upstream{ + ID: "validStaticUpstream", + Path: "/validStaticUpstream", + Static: true, + PassHostHeader: true, // This would normally be defaulted + ProxyWebSockets: true, // this would normally be defaulted + } + validFileUpstream := options.Upstream{ + ID: "validFileUpstream", + Path: "/validFileUpstream", + URI: "file://var/lib/foo", + } + + emptyIDMsg := "upstream has empty id: ids are required for all upstreams" + emptyPathMsg := "upstream \"foo\" has empty path: paths are required for all upstreams" + emptyURIMsg := "upstream \"foo\" has empty uri: uris are required for all non-static upstreams" + invalidURIMsg := "upstream \"foo\" has invalid uri: parse \":\": missing protocol scheme" + invalidURISchemeMsg := "upstream \"foo\" has invalid scheme: \"ftp\"" + staticWithURIMsg := "upstream \"foo\" has uri, but is a static upstream, this will have no effect." + staticWithInsecureMsg := "upstream \"foo\" has insecureSkipTLSVerify, but is a static upstream, this will have no effect." + staticWithFlushIntervalMsg := "upstream \"foo\" has flushInterval, but is a static upstream, this will have no effect." + staticWithPassHostHeaderMsg := "upstream \"foo\" has passHostHeader, but is a static upstream, this will have no effect." + staticWithProxyWebSocketsMsg := "upstream \"foo\" has proxyWebSockets, but is a static upstream, this will have no effect." + multipleIDsMsg := "multiple upstreams found with id \"foo\": upstream ids must be unique" + multiplePathsMsg := "multiple upstreams found with path \"/foo\": upstream paths must be unique" + staticCodeMsg := "upstream \"foo\" has staticCode (200), but is not a static upstream, set 'static' for a static response" + + DescribeTable("validateUpstreams", + func(o *validateUpstreamTableInput) { + Expect(validateUpstreams(o.upstreams)).To(ConsistOf(o.errStrings)) + }, + Entry("with no upstreams", &validateUpstreamTableInput{ + upstreams: options.Upstreams{}, + errStrings: []string{}, + }), + Entry("with valid upstreams", &validateUpstreamTableInput{ + upstreams: options.Upstreams{ + validHTTPUpstream, + validStaticUpstream, + validFileUpstream, + }, + errStrings: []string{}, + }), + Entry("with an empty ID", &validateUpstreamTableInput{ + upstreams: options.Upstreams{ + { + ID: "", + Path: "/foo", + URI: "http://localhost:8080", + }, + }, + errStrings: []string{emptyIDMsg}, + }), + Entry("with an empty Path", &validateUpstreamTableInput{ + upstreams: options.Upstreams{ + { + ID: "foo", + Path: "", + URI: "http://localhost:8080", + }, + }, + errStrings: []string{emptyPathMsg}, + }), + Entry("with an empty Path", &validateUpstreamTableInput{ + upstreams: options.Upstreams{ + { + ID: "foo", + Path: "", + URI: "http://localhost:8080", + }, + }, + errStrings: []string{emptyPathMsg}, + }), + Entry("with an empty URI", &validateUpstreamTableInput{ + upstreams: options.Upstreams{ + { + ID: "foo", + Path: "/foo", + URI: "", + }, + }, + errStrings: []string{emptyURIMsg}, + }), + Entry("with an invalid URI", &validateUpstreamTableInput{ + upstreams: options.Upstreams{ + { + ID: "foo", + Path: "/foo", + URI: ":", + }, + }, + errStrings: []string{invalidURIMsg}, + }), + Entry("with an invalid URI scheme", &validateUpstreamTableInput{ + upstreams: options.Upstreams{ + { + ID: "foo", + Path: "/foo", + URI: "ftp://foo", + }, + }, + errStrings: []string{invalidURISchemeMsg}, + }), + Entry("with a static upstream and invalid optons", &validateUpstreamTableInput{ + upstreams: options.Upstreams{ + { + ID: "foo", + Path: "/foo", + URI: "ftp://foo", + Static: true, + FlushInterval: &flushInterval, + PassHostHeader: false, + ProxyWebSockets: false, + InsecureSkipTLSVerify: true, + }, + }, + errStrings: []string{ + staticWithURIMsg, + staticWithInsecureMsg, + staticWithFlushIntervalMsg, + staticWithPassHostHeaderMsg, + staticWithProxyWebSocketsMsg, + }, + }), + Entry("with duplicate IDs", &validateUpstreamTableInput{ + upstreams: options.Upstreams{ + { + ID: "foo", + Path: "/foo1", + URI: "http://foo", + }, + { + ID: "foo", + Path: "/foo2", + URI: "http://foo", + }, + }, + errStrings: []string{multipleIDsMsg}, + }), + Entry("with duplicate Paths", &validateUpstreamTableInput{ + upstreams: options.Upstreams{ + { + ID: "foo1", + Path: "/foo", + URI: "http://foo", + }, + { + ID: "foo2", + Path: "/foo", + URI: "http://foo", + }, + }, + errStrings: []string{multiplePathsMsg}, + }), + Entry("when a static code is supplied without static", &validateUpstreamTableInput{ + upstreams: options.Upstreams{ + { + ID: "foo", + Path: "/foo", + StaticCode: &staticCode200, + }, + }, + errStrings: []string{emptyURIMsg, staticCodeMsg}, + }), + ) +}) diff --git a/pkg/validation/validation_suite_test.go b/pkg/validation/validation_suite_test.go new file mode 100644 index 00000000..2c6458fe --- /dev/null +++ b/pkg/validation/validation_suite_test.go @@ -0,0 +1,16 @@ +package validation + +import ( + "testing" + + "github.com/oauth2-proxy/oauth2-proxy/pkg/logger" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +func TestValidationSuite(t *testing.T) { + logger.SetOutput(GinkgoWriter) + + RegisterFailHandler(Fail) + RunSpecs(t, "Validation Suite") +}