feat: Add option to enable hsts header (#6147)

* feat: Add option to enable hsts header
* Update golden files
This commit is contained in:
Steven Masley
2023-02-10 10:52:49 -06:00
committed by GitHub
parent 77afdf71dc
commit 6189035e98
13 changed files with 287 additions and 1 deletions

6
coderd/apidoc/docs.go generated
View File

@ -6146,6 +6146,12 @@ const docTemplate = `{
"ssh_keygen_algorithm": {
"$ref": "#/definitions/codersdk.DeploymentConfigField-string"
},
"strict_transport_security": {
"$ref": "#/definitions/codersdk.DeploymentConfigField-int"
},
"strict_transport_security_options": {
"$ref": "#/definitions/codersdk.DeploymentConfigField-array_string"
},
"swagger": {
"$ref": "#/definitions/codersdk.SwaggerConfig"
},

View File

@ -5470,6 +5470,12 @@
"ssh_keygen_algorithm": {
"$ref": "#/definitions/codersdk.DeploymentConfigField-string"
},
"strict_transport_security": {
"$ref": "#/definitions/codersdk.DeploymentConfigField-int"
},
"strict_transport_security_options": {
"$ref": "#/definitions/codersdk.DeploymentConfigField-array_string"
},
"swagger": {
"$ref": "#/definitions/codersdk.SwaggerConfig"
},

View File

@ -103,6 +103,7 @@ type Options struct {
OIDCConfig *OIDCConfig
PrometheusRegistry *prometheus.Registry
SecureAuthCookie bool
StrictTransportSecurityCfg httpmw.HSTSConfig
SSHKeygenAlgorithm gitsshkey.Algorithm
Telemetry telemetry.Reporter
TracerProvider trace.TracerProvider
@ -222,12 +223,18 @@ func New(options *Options) *API {
options.MetricsCacheRefreshInterval,
)
staticHandler := site.Handler(site.FS(), binFS, binHashes)
// Static file handler must be wrapped with HSTS handler if the
// StrictTransportSecurityAge is set. We only need to set this header on
// static files since it only affects browsers.
staticHandler = httpmw.HSTS(staticHandler, options.StrictTransportSecurityCfg)
r := chi.NewRouter()
api := &API{
ID: uuid.New(),
Options: options,
RootHandler: r,
siteHandler: site.Handler(site.FS(), binFS, binHashes),
siteHandler: staticHandler,
HTTPAuth: &HTTPAuthorizer{
Authorizer: options.Authorizer,
Logger: options.Logger,

72
coderd/httpmw/hsts.go Normal file
View File

@ -0,0 +1,72 @@
package httpmw
import (
"fmt"
"net/http"
"strings"
"golang.org/x/xerrors"
)
const (
hstsHeader = "Strict-Transport-Security"
)
type HSTSConfig struct {
// HeaderValue is an empty string if hsts header is disabled.
HeaderValue string
}
func HSTSConfigOptions(maxAge int, options []string) (HSTSConfig, error) {
if maxAge <= 0 {
// No header, so no need to build the header string.
return HSTSConfig{}, nil
}
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security
var str strings.Builder
_, err := str.WriteString(fmt.Sprintf("max-age=%d", maxAge))
if err != nil {
return HSTSConfig{}, xerrors.Errorf("hsts: write max-age: %w", err)
}
for _, option := range options {
switch {
// Only allow valid options and fix any casing mistakes
case strings.EqualFold(option, "includeSubDomains"):
option = "includeSubDomains"
case strings.EqualFold(option, "preload"):
option = "preload"
default:
return HSTSConfig{}, xerrors.Errorf("hsts: invalid option: %q. Must be 'preload' and/or 'includeSubDomains'", option)
}
_, err = str.WriteString("; " + option)
if err != nil {
return HSTSConfig{}, xerrors.Errorf("hsts: write option: %w", err)
}
}
return HSTSConfig{
HeaderValue: str.String(),
}, nil
}
// HSTS will add the strict-transport-security header if enabled. This header
// forces a browser to always use https for the domain after it loads https once.
// Meaning: On first load of product.coder.com, they are redirected to https. On
// all subsequent loads, the client's local browser forces https. This prevents
// man in the middle.
//
// This header only makes sense if the app is using tls.
//
// Full header example:
// Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
func HSTS(next http.Handler, cfg HSTSConfig) http.Handler {
if cfg.HeaderValue == "" {
// No header, so no need to wrap the handler.
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set(hstsHeader, cfg.HeaderValue)
next.ServeHTTP(w, r)
})
}

103
coderd/httpmw/hsts_test.go Normal file
View File

@ -0,0 +1,103 @@
package httpmw_test
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/coderd/httpmw"
)
func TestHSTS(t *testing.T) {
t.Parallel()
tests := []struct {
Name string
MaxAge int
Options []string
wantErr bool
expectHeader string
}{
{
Name: "Empty",
MaxAge: 0,
Options: nil,
},
{
Name: "NoAge",
MaxAge: 0,
Options: []string{"includeSubDomains"},
},
{
Name: "NegativeAge",
MaxAge: -100,
Options: []string{"includeSubDomains"},
},
{
Name: "Age",
MaxAge: 1000,
Options: []string{},
expectHeader: "max-age=1000",
},
{
Name: "AgeSubDomains",
MaxAge: 1000,
// Mess with casing
Options: []string{"INCLUDESUBDOMAINS"},
expectHeader: "max-age=1000; includeSubDomains",
},
{
Name: "AgePreload",
MaxAge: 1000,
Options: []string{"Preload"},
expectHeader: "max-age=1000; preload",
},
{
Name: "AllOptions",
MaxAge: 1000,
Options: []string{"preload", "includeSubDomains"},
expectHeader: "max-age=1000; preload; includeSubDomains",
},
// Error values
{
Name: "BadOption",
MaxAge: 100,
Options: []string{"not-valid"},
wantErr: true,
},
{
Name: "BadOptions",
MaxAge: 100,
Options: []string{"includeSubDomains", "not-valid", "still-not-valid"},
wantErr: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.Name, func(t *testing.T) {
t.Parallel()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
cfg, err := httpmw.HSTSConfigOptions(tt.MaxAge, tt.Options)
if tt.wantErr {
require.Error(t, err, "Expect error, HSTS(%v, %v)", tt.MaxAge, tt.Options)
return
}
require.NoError(t, err, "Expect no error, HSTS(%v, %v)", tt.MaxAge, tt.Options)
got := httpmw.HSTS(handler, cfg)
req := httptest.NewRequest("GET", "/", nil)
res := httptest.NewRecorder()
got.ServeHTTP(res, req)
require.Equal(t, tt.expectHeader, res.Header().Get("Strict-Transport-Security"), "expected header value")
})
}
}