From 5f8d292ea15147d2579ccb4da251971aad25eb86 Mon Sep 17 00:00:00 2001 From: Danielle <29378233+DanielleMiu@users.noreply.github.com> Date: Thu, 1 Dec 2022 13:15:53 -0500 Subject: [PATCH] Inject Custom Response Headers By Status (#2587) * add custom ResponseWriter struct to inject custom response headers based on status code * docs * upgrade listenerutil * fix config tests * replace all instances of "data:*" with "data:" to reflect changes to listenerutil * swapping strings.HasPrefix in favor of mux.Handler for ui request matching * set ui path as const * add custom response headers to changelog --- CHANGELOG.md | 4 + go.mod | 6 +- go.sum | 12 +- internal/cmd/config/config_load_test.go | 50 ++++++ internal/cmd/config/config_test.go | 111 +++++++++++--- internal/daemon/controller/handler.go | 33 +++- .../docs/configuration/listener/tcp.mdx | 142 ++++++++++++++++++ 7 files changed, 322 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ff7d5f0f7..7792c3cfdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,10 @@ Canonical reference for changes, improvements, and bugfixes for Boundary. * `{{ truncateFrom .Account.Email "@" }}`: this would turn `foo@example.com` into `foo` +* Custom Response Headers: Adds ability to set api and ui response headers based + on status code. Includes default secure CSP and other headers. + ([PR](https://github.com/hashicorp/boundary/pull/2587)) + ### Bug Fixes * accounts: Deleted auth accounts would still show up as being associated with a diff --git a/go.mod b/go.mod index a95342c9e3..1affc7899a 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,7 @@ require ( github.com/golang-migrate/migrate/v4 v4.15.1 github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe github.com/golang/protobuf v1.5.2 - github.com/google/go-cmp v0.5.8 + github.com/google/go-cmp v0.5.9 github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.2 github.com/hashicorp/boundary/api v0.0.30 @@ -38,7 +38,7 @@ require ( github.com/hashicorp/go-secure-stdlib/configutil/v2 v2.0.5 github.com/hashicorp/go-secure-stdlib/gatedwriter v0.1.1 github.com/hashicorp/go-secure-stdlib/kv-builder v0.1.1 - github.com/hashicorp/go-secure-stdlib/listenerutil v0.1.4 + github.com/hashicorp/go-secure-stdlib/listenerutil v0.1.5-0.20221130175209-f7789ac19a1f github.com/hashicorp/go-secure-stdlib/mlock v0.1.1 github.com/hashicorp/go-secure-stdlib/parseutil v0.1.7 github.com/hashicorp/go-secure-stdlib/password v0.1.1 @@ -74,7 +74,7 @@ require ( go.uber.org/atomic v1.9.0 golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 - golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 + golang.org/x/sys v0.2.0 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 golang.org/x/tools v0.1.12 google.golang.org/genproto v0.0.0-20220805133916-01dd62135a58 diff --git a/go.sum b/go.sum index ad0142991b..b7cbdd4ca5 100644 --- a/go.sum +++ b/go.sum @@ -585,8 +585,8 @@ github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= -github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-github/v35 v35.2.0/go.mod h1:s0515YVTI+IMrDoy9Y4pHt9ShGpzHvHO8rZ7L7acgvs= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -711,6 +711,10 @@ github.com/hashicorp/go-secure-stdlib/kv-builder v0.1.1 h1:IJgULbAXuvWxzKFfu+Au1 github.com/hashicorp/go-secure-stdlib/kv-builder v0.1.1/go.mod h1:rf5JPE13wi+NwjgsmGkbg4b2CgHq8v7Htn/F0nDe/hg= github.com/hashicorp/go-secure-stdlib/listenerutil v0.1.4 h1:6ajbq64FhrIJZ6prrff3upVVDil4yfCrnSKwTH0HIPE= github.com/hashicorp/go-secure-stdlib/listenerutil v0.1.4/go.mod h1:myX7XYMJRIP4PLHtYJiKMTJcKOX0M5ZJNwP0iw+l3uw= +github.com/hashicorp/go-secure-stdlib/listenerutil v0.1.5-0.20221122211730-f52ba9532e2c h1:EtRfLZUtPGadI0LGiAHLO3ipQSdJ9J0+/8l8H8v3Znk= +github.com/hashicorp/go-secure-stdlib/listenerutil v0.1.5-0.20221122211730-f52ba9532e2c/go.mod h1:MSXg3Md+eg1hOJFSKuTELy6YnFhfMJBVYu7t07BdPc4= +github.com/hashicorp/go-secure-stdlib/listenerutil v0.1.5-0.20221130175209-f7789ac19a1f h1:FemjU7MTEVt5HVI59M1AQjzEaR8mvlXr5I985BUbLSg= +github.com/hashicorp/go-secure-stdlib/listenerutil v0.1.5-0.20221130175209-f7789ac19a1f/go.mod h1:MSXg3Md+eg1hOJFSKuTELy6YnFhfMJBVYu7t07BdPc4= github.com/hashicorp/go-secure-stdlib/mlock v0.1.1 h1:cCRo8gK7oq6A2L6LICkUZ+/a5rLiRXFMf1Qd4xSwxTc= github.com/hashicorp/go-secure-stdlib/mlock v0.1.1/go.mod h1:zq93CJChV6L9QTfGKtfBxKqD7BqqXx5O04A/ns2p5+I= github.com/hashicorp/go-secure-stdlib/parseutil v0.1.1/go.mod h1:QmrqtbKuxxSWTN3ETMPuB+VtEiBJ/A9XhoYGv8E1uD8= @@ -1622,8 +1626,8 @@ golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20211013075003-97ac67df715c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 h1:WIoqL4EROvwiPdUtaip4VcDdpZ4kha7wBWZrbVKCIZg= -golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0 h1:ljd4t30dBnAvMZaQCevtY0xLLD0A+bRZXbgLMLU1F/A= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= diff --git a/internal/cmd/config/config_load_test.go b/internal/cmd/config/config_load_test.go index 1c98ac79b3..529d6a2c12 100644 --- a/internal/cmd/config/config_load_test.go +++ b/internal/cmd/config/config_load_test.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "io/ioutil" + "net/http" "testing" "github.com/hashicorp/boundary/internal/cmd/config" @@ -14,6 +15,23 @@ import ( ) func TestLoad(t *testing.T) { + apiHeaders := map[int]http.Header{ + 0: { + "Content-Security-Policy": {"default-src 'none'"}, + "X-Content-Type-Options": {"nosniff"}, + "Strict-Transport-Security": {"max-age=31536000; includeSubDomains"}, + "Cache-Control": {"no-store"}, + }, + } + uiHeaders := map[int]http.Header{ + 0: { + "Content-Security-Policy": {"default-src 'none'; script-src 'self'; frame-src 'self'; font-src 'self'; connect-src 'self'; img-src 'self' data:; style-src 'self'; media-src 'self'; manifest-src 'self'; style-src-attr 'self'; frame-ancestors 'self'"}, + "X-Content-Type-Options": {"nosniff"}, + "Strict-Transport-Security": {"max-age=31536000; includeSubDomains"}, + "Cache-Control": {"no-store"}, + }, + } + cases := []struct { name string expected *config.Config @@ -91,6 +109,8 @@ func TestLoad(t *testing.T) { CorsAllowedOrigins: []string{"*"}, CorsAllowedHeaders: nil, CorsAllowedHeadersRaw: nil, + CustomApiResponseHeaders: apiHeaders, + CustomUiResponseHeaders: uiHeaders, }, { RawConfig: map[string]any{ @@ -158,6 +178,8 @@ func TestLoad(t *testing.T) { CorsAllowedOrigins: nil, CorsAllowedHeaders: nil, CorsAllowedHeadersRaw: nil, + CustomApiResponseHeaders: apiHeaders, + CustomUiResponseHeaders: uiHeaders, }, { RawConfig: map[string]any{ @@ -225,6 +247,8 @@ func TestLoad(t *testing.T) { CorsAllowedOrigins: nil, CorsAllowedHeaders: nil, CorsAllowedHeadersRaw: nil, + CustomApiResponseHeaders: apiHeaders, + CustomUiResponseHeaders: uiHeaders, }, { RawConfig: map[string]any{ @@ -292,6 +316,8 @@ func TestLoad(t *testing.T) { CorsAllowedOrigins: nil, CorsAllowedHeaders: nil, CorsAllowedHeadersRaw: nil, + CustomApiResponseHeaders: apiHeaders, + CustomUiResponseHeaders: uiHeaders, }, }, Seals: []*configutil.KMS{ @@ -497,6 +523,8 @@ func TestLoad(t *testing.T) { CorsAllowedOrigins: []string{"*"}, CorsAllowedHeaders: nil, CorsAllowedHeadersRaw: nil, + CustomApiResponseHeaders: apiHeaders, + CustomUiResponseHeaders: uiHeaders, }, { RawConfig: map[string]any{ @@ -564,6 +592,8 @@ func TestLoad(t *testing.T) { CorsAllowedOrigins: nil, CorsAllowedHeaders: nil, CorsAllowedHeadersRaw: nil, + CustomApiResponseHeaders: apiHeaders, + CustomUiResponseHeaders: uiHeaders, }, { RawConfig: map[string]any{ @@ -631,6 +661,8 @@ func TestLoad(t *testing.T) { CorsAllowedOrigins: nil, CorsAllowedHeaders: nil, CorsAllowedHeadersRaw: nil, + CustomApiResponseHeaders: apiHeaders, + CustomUiResponseHeaders: uiHeaders, }, { RawConfig: map[string]any{ @@ -698,6 +730,8 @@ func TestLoad(t *testing.T) { CorsAllowedOrigins: nil, CorsAllowedHeaders: nil, CorsAllowedHeadersRaw: nil, + CustomApiResponseHeaders: apiHeaders, + CustomUiResponseHeaders: uiHeaders, }, }, Seals: []*configutil.KMS{ @@ -903,6 +937,8 @@ func TestLoad(t *testing.T) { CorsAllowedOrigins: []string{"*"}, CorsAllowedHeaders: nil, CorsAllowedHeadersRaw: nil, + CustomApiResponseHeaders: apiHeaders, + CustomUiResponseHeaders: uiHeaders, }, { RawConfig: map[string]any{ @@ -970,6 +1006,8 @@ func TestLoad(t *testing.T) { CorsAllowedOrigins: nil, CorsAllowedHeaders: nil, CorsAllowedHeadersRaw: nil, + CustomApiResponseHeaders: apiHeaders, + CustomUiResponseHeaders: uiHeaders, }, { RawConfig: map[string]any{ @@ -1037,6 +1075,8 @@ func TestLoad(t *testing.T) { CorsAllowedOrigins: nil, CorsAllowedHeaders: nil, CorsAllowedHeadersRaw: nil, + CustomApiResponseHeaders: apiHeaders, + CustomUiResponseHeaders: uiHeaders, }, { RawConfig: map[string]any{ @@ -1104,6 +1144,8 @@ func TestLoad(t *testing.T) { CorsAllowedOrigins: nil, CorsAllowedHeaders: nil, CorsAllowedHeadersRaw: nil, + CustomApiResponseHeaders: apiHeaders, + CustomUiResponseHeaders: uiHeaders, }, }, Seals: []*configutil.KMS{ @@ -1309,6 +1351,8 @@ func TestLoad(t *testing.T) { CorsAllowedOrigins: []string{"*"}, CorsAllowedHeaders: nil, CorsAllowedHeadersRaw: nil, + CustomApiResponseHeaders: apiHeaders, + CustomUiResponseHeaders: uiHeaders, }, { RawConfig: map[string]any{ @@ -1376,6 +1420,8 @@ func TestLoad(t *testing.T) { CorsAllowedOrigins: nil, CorsAllowedHeaders: nil, CorsAllowedHeadersRaw: nil, + CustomApiResponseHeaders: apiHeaders, + CustomUiResponseHeaders: uiHeaders, }, { RawConfig: map[string]any{ @@ -1443,6 +1489,8 @@ func TestLoad(t *testing.T) { CorsAllowedOrigins: nil, CorsAllowedHeaders: nil, CorsAllowedHeadersRaw: nil, + CustomApiResponseHeaders: apiHeaders, + CustomUiResponseHeaders: uiHeaders, }, { RawConfig: map[string]any{ @@ -1510,6 +1558,8 @@ func TestLoad(t *testing.T) { CorsAllowedOrigins: nil, CorsAllowedHeaders: nil, CorsAllowedHeadersRaw: nil, + CustomApiResponseHeaders: apiHeaders, + CustomUiResponseHeaders: uiHeaders, }, }, Seals: []*configutil.KMS{ diff --git a/internal/cmd/config/config_test.go b/internal/cmd/config/config_test.go index 115e70b06b..8efed01adc 100644 --- a/internal/cmd/config/config_test.go +++ b/internal/cmd/config/config_test.go @@ -2,6 +2,7 @@ package config import ( "fmt" + "net/http" "os" "testing" "time" @@ -22,26 +23,50 @@ func TestDevController(t *testing.T) { truePointer := new(bool) *truePointer = true + + apiHeaders := map[int]http.Header{ + 0: { + "Content-Security-Policy": {"default-src 'none'"}, + "X-Content-Type-Options": {"nosniff"}, + "Strict-Transport-Security": {"max-age=31536000; includeSubDomains"}, + "Cache-Control": {"no-store"}, + }, + } + uiHeaders := map[int]http.Header{ + 0: { + "Content-Security-Policy": {"default-src 'none'; script-src 'self'; frame-src 'self'; font-src 'self'; connect-src 'self'; img-src 'self' data:; style-src 'self'; media-src 'self'; manifest-src 'self'; style-src-attr 'self'; frame-ancestors 'self'"}, + "X-Content-Type-Options": {"nosniff"}, + "Strict-Transport-Security": {"max-age=31536000; includeSubDomains"}, + "Cache-Control": {"no-store"}, + }, + } + exp := &Config{ Eventing: event.DefaultEventerConfig(), SharedConfig: &configutil.SharedConfig{ DisableMlock: true, Listeners: []*listenerutil.ListenerConfig{ { - Type: "tcp", - Purpose: []string{"api"}, - TLSDisable: true, - CorsEnabled: truePointer, - CorsAllowedOrigins: []string{"*"}, + Type: "tcp", + Purpose: []string{"api"}, + TLSDisable: true, + CorsEnabled: truePointer, + CorsAllowedOrigins: []string{"*"}, + CustomApiResponseHeaders: apiHeaders, + CustomUiResponseHeaders: uiHeaders, }, { - Type: "tcp", - Purpose: []string{"cluster"}, + Type: "tcp", + Purpose: []string{"cluster"}, + CustomApiResponseHeaders: apiHeaders, + CustomUiResponseHeaders: uiHeaders, }, { - Type: "tcp", - Purpose: []string{"ops"}, - TLSDisable: true, + Type: "tcp", + Purpose: []string{"ops"}, + TLSDisable: true, + CustomApiResponseHeaders: apiHeaders, + CustomUiResponseHeaders: uiHeaders, }, }, Seals: []*configutil.KMS{ @@ -176,6 +201,22 @@ func TestDevWorker(t *testing.T) { { Type: "tcp", Purpose: []string{"proxy"}, + CustomApiResponseHeaders: map[int]http.Header{ + 0: { + "Content-Security-Policy": {"default-src 'none'"}, + "X-Content-Type-Options": {"nosniff"}, + "Strict-Transport-Security": {"max-age=31536000; includeSubDomains"}, + "Cache-Control": {"no-store"}, + }, + }, + CustomUiResponseHeaders: map[int]http.Header{ + 0: { + "Content-Security-Policy": {"default-src 'none'; script-src 'self'; frame-src 'self'; font-src 'self'; connect-src 'self'; img-src 'self' data:; style-src 'self'; media-src 'self'; manifest-src 'self'; style-src-attr 'self'; frame-ancestors 'self'"}, + "X-Content-Type-Options": {"nosniff"}, + "Strict-Transport-Security": {"max-age=31536000; includeSubDomains"}, + "Cache-Control": {"no-store"}, + }, + }, }, }, Seals: []*configutil.KMS{ @@ -329,30 +370,56 @@ func TestDevCombined(t *testing.T) { truePointer := new(bool) *truePointer = true + + apiHeaders := map[int]http.Header{ + 0: { + "Content-Security-Policy": {"default-src 'none'"}, + "X-Content-Type-Options": {"nosniff"}, + "Strict-Transport-Security": {"max-age=31536000; includeSubDomains"}, + "Cache-Control": {"no-store"}, + }, + } + uiHeaders := map[int]http.Header{ + 0: { + "Content-Security-Policy": {"default-src 'none'; script-src 'self'; frame-src 'self'; font-src 'self'; connect-src 'self'; img-src 'self' data:; style-src 'self'; media-src 'self'; manifest-src 'self'; style-src-attr 'self'; frame-ancestors 'self'"}, + "X-Content-Type-Options": {"nosniff"}, + "Strict-Transport-Security": {"max-age=31536000; includeSubDomains"}, + "Cache-Control": {"no-store"}, + }, + } + exp := &Config{ Eventing: event.DefaultEventerConfig(), SharedConfig: &configutil.SharedConfig{ DisableMlock: true, Listeners: []*listenerutil.ListenerConfig{ { - Type: "tcp", - Purpose: []string{"api"}, - TLSDisable: true, - CorsEnabled: truePointer, - CorsAllowedOrigins: []string{"*"}, + Type: "tcp", + Purpose: []string{"api"}, + TLSDisable: true, + CorsEnabled: truePointer, + CorsAllowedOrigins: []string{"*"}, + CustomApiResponseHeaders: apiHeaders, + CustomUiResponseHeaders: uiHeaders, }, { - Type: "tcp", - Purpose: []string{"cluster"}, + Type: "tcp", + Purpose: []string{"cluster"}, + CustomApiResponseHeaders: apiHeaders, + CustomUiResponseHeaders: uiHeaders, }, { - Type: "tcp", - Purpose: []string{"ops"}, - TLSDisable: true, + Type: "tcp", + Purpose: []string{"ops"}, + TLSDisable: true, + CustomApiResponseHeaders: apiHeaders, + CustomUiResponseHeaders: uiHeaders, }, { - Type: "tcp", - Purpose: []string{"proxy"}, + Type: "tcp", + Purpose: []string{"proxy"}, + CustomApiResponseHeaders: apiHeaders, + CustomUiResponseHeaders: uiHeaders, }, }, Seals: []*configutil.KMS{ diff --git a/internal/daemon/controller/handler.go b/internal/daemon/controller/handler.go index d07d20f01e..d595d8ff74 100644 --- a/internal/daemon/controller/handler.go +++ b/internal/daemon/controller/handler.go @@ -57,18 +57,36 @@ type HandlerProperties struct { CancelCtx context.Context } +const uiPath = "/" + +// createMuxWithEndpoints performs all response logic for boundary, using isUiRequest +// for unified logic between responses and headers. +func createMuxWithEndpoints(c *Controller, props HandlerProperties) (http.Handler, func(req *http.Request) bool, error) { + grpcGwMux := newGrpcGatewayMux() + if err := registerGrpcGatewayEndpoints(props.CancelCtx, grpcGwMux, gatewayDialOptions(c.apiGrpcServerListener)...); err != nil { + return nil, nil, err + } + + mux := http.NewServeMux() + mux.Handle("/v1/", grpcGwMux) + mux.Handle(uiPath, handleUi(c)) + + isUiRequest := func(req *http.Request) bool { + _, p := mux.Handler(req) + // check to see if the matched pattern is for the ui + return p == uiPath + } + + return http.HandlerFunc(mux.ServeHTTP), isUiRequest, nil +} + // apiHandler returns an http.Handler for the services. This can be used on // its own to mount the Controller API within another web server. func (c *Controller) apiHandler(props HandlerProperties) (http.Handler, error) { - mux := http.NewServeMux() - - grpcGwMux := newGrpcGatewayMux() - err := registerGrpcGatewayEndpoints(props.CancelCtx, grpcGwMux, gatewayDialOptions(c.apiGrpcServerListener)...) + mux, isUiRequest, err := createMuxWithEndpoints(c, props) if err != nil { return nil, err } - mux.Handle("/v1/", grpcGwMux) - mux.Handle("/", handleUi(c)) corsWrappedHandler := wrapHandlerWithCors(mux, props) commonWrappedHandler := wrapHandlerWithCommonFuncs(corsWrappedHandler, c, props) @@ -80,7 +98,8 @@ func (c *Controller) apiHandler(props HandlerProperties) (http.Handler, error) { } metricsHandler := metric.InstrumentApiHandler(eventsHandler) - return metricsHandler, nil + // This wrap MUST be performed last. If you add a new wrapper, do so above. + return listenerutil.WrapCustomHeadersHandler(metricsHandler, props.ListenerConfig, isUiRequest), nil } // GetHealthHandler returns a gRPC Gateway mux that is registered against the diff --git a/website/content/docs/configuration/listener/tcp.mdx b/website/content/docs/configuration/listener/tcp.mdx index aedff41cc3..3ffcf565e1 100644 --- a/website/content/docs/configuration/listener/tcp.mdx +++ b/website/content/docs/configuration/listener/tcp.mdx @@ -21,6 +21,22 @@ The `listener` stanza may be specified more than once to make Boundary listen on multiple interfaces; however, only one listener marked for `cluster` purpose is allowed. +## Listener's custom response headers + +Boundary supports defining custom HTTP response headers for all requests on any Boundary controller. +Headers are defined based on the returned status code. For example, you can define a list of +custom response headers for the `200` status code, and another list of custom response headers for +the `307` status code, and so on. You can also define headers based on the hundred-level status +code. For example, a list of headers applied to the `4xx` code will be applied to 400, 401, 404, and +all other 400-level status codes. The more specific the status, the higher priority it has. +Default headers are overwritten by hundred-level headers, which are overwritten by status-specific +headers. + +There are two different config parameters that define headers: `custom_api_response_headers` and +`custom_ui_response_headers`. API headers apply to API endpoints, currently all paths starting +with `/v1/`. UI headers apply to all other paths. This allows for configuring headers specifically +for serving content to a web browser, such as CSP headers. + ## `tcp` Listener Parameters ### General @@ -84,6 +100,48 @@ allowed. default, such as `"Content-Type"`, `"X-Requested-With"`, and `"Authorization"`. +### `custom_api_response_headers` Parameters + +- `default` `(key-value-map: {})` - A map of string header names to an array of + string values. The default headers are set on all endpoints regardless of + the status code value. For an example, refer to the + [Configuring custom http response headers](#configuring-custom-http-response-headers) + section. + +- `` `(key-value-map: {})` - A map of string header names + to an array of string values. These headers are set only when the response status + code falls under the collective status code. + For example, `"2xx" = {"Header-A": ["Value1", "Value2"]}`, `"Header-A"` + is set when the http response status code is `"200"`, `"204"`, etc. This overrides + headers defined at the `default` level. + +- `` `(key-value-map: {})` - A map of string header names + to an array of string values. These headers are set only when the specific status + code is returned. For example, `"200" = {"Header-A": ["Value1", "Value2"]}`, `"Header-A"` + is set when the http response status code is `"200"`. This overrides headers defined at + the `default` and `collective status code` level. + +### `custom_ui_response_headers` Parameters + +- `default` `(key-value-map: {})` - A map of string header names to an array of + string values. The default headers are set on all endpoints regardless of + the status code value. For an example, refer to the + [Configuring custom http response headers](#configuring-custom-http-response-headers) + section. + +- `` `(key-value-map: {})` - A map of string header names + to an array of string values. These headers are set only when the response status + code falls under the collective status code. + For example, `"2xx" = {"Header-A": ["Value1", "Value2"]}`, `"Header-A"` + is set when the http response status code is `"200"`, `"204"`, etc. This overrides + headers defined at the `default` level. + +- `` `(key-value-map: {})` - A map of string header names + to an array of string values. These headers are set only when the specific status + code is returned. For example, `"200" = {"Header-A": ["Value1", "Value2"]}`, `"Header-A"` + is set when the http response status code is `"200"`. This overrides headers defined at + the `default` and `collective status code` level. + ### TLS ~> `tls` parameters are valid for `api` and `ops` listeners. `cluster` @@ -177,6 +235,90 @@ listener "tcp" { } ``` +### Configuring custom http response headers + +This example shows configuring custom http response headers. Operators can configure +`"custom_api_response_headers"` and `"custom_ui_response_headers"` sub-stanzas in the listener stanza to +set custom http headers that are appropriate to their applications. Examples of such headers are +`"Strict-Transport-Security"` and `"Content-Security-Policy"` which are known HTTP headers, and could be +configured to harden the security of an application communicating with the Boundary endpoints. Note that +vulnerability scans often examine such security related HTTP headers. In addition, you can configure +application-specific custom headers. For example, `"X-Custom-Header"` has been configured in the example +below. + +```hcl +listener "tcp" { + custom_api_response_headers { + "default" = { + "Strict-Transport-Security" = ["max-age=31536000; includeSubDomains"], + "X-Custom-Header" = ["Custom Header Default Value"], + }, + "2xx" = { + "X-Custom-Header" = ["Custom Header Value 1", "Custom Header Value 2"], + }, + "301" = { + "Strict-Transport-Security" = ["max-age=31536000"], + }, + } + custom_ui_response_headers { + "default" = { + "Strict-Transport-Security" = ["max-age=31536000; includeSubDomains"], + "Content-Security-Policy" = ["default-src 'none'; script-src 'self'; frame-src 'self'; font-src 'self'; connect-src 'self'; img-src 'self' data:; style-src 'self'; media-src 'self'; manifest-src 'self'; style-src-attr 'self'; frame-ancestors 'self'"], + "X-Custom-Header" = ["Custom Header Default Value"], + }, + "2xx" = { + "X-Custom-Header" = ["Custom Header Value 1", "Custom Header Value 2"], + }, + "301" = { + "Strict-Transport-Security" = ["max-age=31536000"], + "Content-Security-Policy" = ["default-src 'none'; script-src 'none'; connect-src 'none'"], + }, + } +} +``` + +In situations where a header is defined under several status code subsections, +Boundary returns the header matching the most specific response code. For example, +with the config example below, a `307` response would return `307 Custom header value`, +while a `306` would return `3xx Custom header value`. + +```hcl +listener "tcp" { + custom_api_response_headers { + "default" = { + "X-Custom-Header" = ["default Custom header value"] + }, + "3xx" = { + "X-Custom-Header" = ["3xx Custom header value"] + }, + "307" = { + "X-Custom-Header" = ["307 Custom header value"] + } + } +} +``` + +There may also be situations where default or collective status headers have been defined, +but you do not want them returned for a specific status code. You can unset headers at any +level by defining the headers as an empty list. + +```hcl +listener "tcp" { + custom_api_response_headers { + "default" = { + "X-Custom-Header" = ["default Custom header value"] + }, + "3xx" = { + "X-Custom-Header" = ["3xx Custom header value"] + }, + "307" = { + // Do not return this header for 307 responses + "X-Custom-Header" = [] + } + } +} +``` + ### Listening on Multiple Interfaces This example shows Boundary listening on a private interface, as well as localhost.