mirror of
https://github.com/coder/coder.git
synced 2025-07-15 22:20:27 +00:00
feat: Add option to enable hsts header (#6147)
* feat: Add option to enable hsts header * Update golden files
This commit is contained in:
6
coderd/apidoc/docs.go
generated
6
coderd/apidoc/docs.go
generated
@ -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"
|
||||
},
|
||||
|
6
coderd/apidoc/swagger.json
generated
6
coderd/apidoc/swagger.json
generated
@ -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"
|
||||
},
|
||||
|
@ -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
72
coderd/httpmw/hsts.go
Normal 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
103
coderd/httpmw/hsts_test.go
Normal 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")
|
||||
})
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user