diff --git a/cmd/dex/config.go b/cmd/dex/config.go index 9c5c103977..33c0db4782 100644 --- a/cmd/dex/config.go +++ b/cmd/dex/config.go @@ -4,6 +4,7 @@ import ( "encoding/base64" "encoding/json" "fmt" + "net/http" "os" "strings" @@ -153,6 +154,7 @@ type OAuth2 struct { type Web struct { HTTP string `json:"http"` HTTPS string `json:"https"` + Headers Headers `json:"headers"` TLSCert string `json:"tlsCert"` TLSKey string `json:"tlsKey"` TLSMinVersion string `json:"tlsMinVersion"` @@ -161,6 +163,52 @@ type Web struct { AllowedHeaders []string `json:"allowedHeaders"` } +type Headers struct { + // Set the Content-Security-Policy header to HTTP responses. + // Unset if blank. + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy + ContentSecurityPolicy string `json:"Content-Security-Policy"` + // Set the X-Frame-Options header to HTTP responses. + // Unset if blank. Accepted values are deny and sameorigin. + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options + XFrameOptions string `json:"X-Frame-Options"` + // Set the X-Content-Type-Options header to HTTP responses. + // Unset if blank. Accepted value is nosniff. + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Content-Type-Options + XContentTypeOptions string `json:"X-Content-Type-Options"` + // Set the X-XSS-Protection header to all responses. + // Unset if blank. + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-XSS-Protection + XXSSProtection string `json:"X-XSS-Protection"` + // Set the Strict-Transport-Security header to HTTP responses. + // Unset if blank. + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security + StrictTransportSecurity string `json:"Strict-Transport-Security"` +} + +func (h *Headers) ToHTTPHeader() http.Header { + if h == nil { + return make(map[string][]string) + } + header := make(map[string][]string) + if h.ContentSecurityPolicy != "" { + header["Content-Security-Policy"] = []string{h.ContentSecurityPolicy} + } + if h.XFrameOptions != "" { + header["X-Frame-Options"] = []string{h.XFrameOptions} + } + if h.XContentTypeOptions != "" { + header["X-Content-Type-Options"] = []string{h.XContentTypeOptions} + } + if h.XXSSProtection != "" { + header["X-XSS-Protection"] = []string{h.XXSSProtection} + } + if h.StrictTransportSecurity != "" { + header["Strict-Transport-Security"] = []string{h.StrictTransportSecurity} + } + return header +} + // Telemetry is the config format for telemetry including the HTTP server config. type Telemetry struct { HTTP string `json:"http"` diff --git a/cmd/dex/config_test.go b/cmd/dex/config_test.go index 16803d6db7..e316965039 100644 --- a/cmd/dex/config_test.go +++ b/cmd/dex/config_test.go @@ -74,6 +74,8 @@ web: https: 127.0.0.1:5556 tlsMinVersion: 1.3 tlsMaxVersion: 1.2 + headers: + Strict-Transport-Security: "max-age=31536000; includeSubDomains" frontend: dir: ./web @@ -149,6 +151,9 @@ logger: HTTPS: "127.0.0.1:5556", TLSMinVersion: "1.3", TLSMaxVersion: "1.2", + Headers: Headers{ + StrictTransportSecurity: "max-age=31536000; includeSubDomains", + }, }, Frontend: server.WebConfig{ Dir: "./web", diff --git a/cmd/dex/serve.go b/cmd/dex/serve.go index 3494443eca..9461a6220a 100644 --- a/cmd/dex/serve.go +++ b/cmd/dex/serve.go @@ -278,6 +278,7 @@ func runServe(options serveOptions) error { SkipApprovalScreen: c.OAuth2.SkipApprovalScreen, AlwaysShowLoginScreen: c.OAuth2.AlwaysShowLoginScreen, PasswordConnector: c.OAuth2.PasswordConnector, + Headers: c.Web.Headers.ToHTTPHeader(), AllowedOrigins: c.Web.AllowedOrigins, AllowedHeaders: c.Web.AllowedHeaders, Issuer: c.Issuer, diff --git a/examples/config-dev.yaml b/examples/config-dev.yaml index 956aa84c7f..8f1018ffcc 100644 --- a/examples/config-dev.yaml +++ b/examples/config-dev.yaml @@ -52,6 +52,13 @@ web: # https: 127.0.0.1:5554 # tlsCert: /etc/dex/tls.crt # tlsKey: /etc/dex/tls.key + # headers: + # X-Frame-Options: "DENY" + # X-Content-Type-Options: "nosniff" + # X-XSS-Protection: "1; mode=block" + # Content-Security-Policy: "default-src 'self'" + # Strict-Transport-Security: "max-age=31536000; includeSubDomains" + # Configuration for dex appearance # frontend: diff --git a/server/server.go b/server/server.go index bb9da17b91..1eaf191543 100644 --- a/server/server.go +++ b/server/server.go @@ -72,6 +72,9 @@ type Config struct { // flow. If no response types are supplied this value defaults to "code". SupportedResponseTypes []string + // Headers is a map of headers to be added to the all responses. + Headers http.Header + // List of allowed origins for CORS requests on discovery, token and keys endpoint. // If none are indicated, CORS requests are disabled. Passing in "*" will allow any // domain. @@ -345,9 +348,18 @@ func newServer(ctx context.Context, c Config, rotationStrategy rotationStrategy) } } + handlerWithHeaders := func(handlerName string, handler http.Handler) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + for k, v := range c.Headers { + w.Header()[k] = v + } + instrumentHandlerCounter(handlerName, handler)(w, r) + } + } + r := mux.NewRouter().SkipClean(true).UseEncodedPath() handle := func(p string, h http.Handler) { - r.Handle(path.Join(issuerURL.Path, p), instrumentHandlerCounter(p, h)) + r.Handle(path.Join(issuerURL.Path, p), handlerWithHeaders(p, h)) } handleFunc := func(p string, h http.HandlerFunc) { handle(p, h) @@ -365,7 +377,7 @@ func newServer(ctx context.Context, c Config, rotationStrategy rotationStrategy) ) handler = cors(handler) } - r.Handle(path.Join(issuerURL.Path, p), instrumentHandlerCounter(p, handler)) + r.Handle(path.Join(issuerURL.Path, p), handlerWithHeaders(p, handler)) } r.NotFoundHandler = http.NotFoundHandler() @@ -388,7 +400,7 @@ func newServer(ctx context.Context, c Config, rotationStrategy rotationStrategy) // TODO(nabokihms): "/device/token" endpoint is deprecated, consider using /token endpoint instead handleFunc("/device/token", s.handleDeviceTokenDeprecated) handleFunc(deviceCallbackURI, s.handleDeviceCallback) - r.HandleFunc(path.Join(issuerURL.Path, "/callback"), func(w http.ResponseWriter, r *http.Request) { + handleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { // Strip the X-Remote-* headers to prevent security issues on // misconfigured authproxy connector setups. for key := range r.Header { diff --git a/server/server_test.go b/server/server_test.go index f9bfa4a3ba..fb616bc7da 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -1799,3 +1799,25 @@ func TestServerSupportedGrants(t *testing.T) { }) } } + +func TestHeaders(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + httpServer, _ := newTestServer(ctx, t, func(c *Config) { + c.Headers = map[string][]string{ + "Strict-Transport-Security": {"max-age=31536000; includeSubDomains"}, + } + }) + defer httpServer.Close() + + p, err := oidc.NewProvider(ctx, httpServer.URL) + if err != nil { + t.Fatalf("failed to get provider: %v", err) + } + + resp, err := http.Get(p.Endpoint().TokenURL) + require.NoError(t, err) + + require.Equal(t, "max-age=31536000; includeSubDomains", resp.Header.Get("Strict-Transport-Security")) +}