Skip to content

Commit 643f42a

Browse files
committed
feat: Add option to enable hsts header
1 parent e6da7af commit 643f42a

File tree

6 files changed

+209
-23
lines changed

6 files changed

+209
-23
lines changed

cli/deployment/config.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,19 @@ func newConfig() *codersdk.DeploymentConfig {
374374
Usage: "Controls if the 'Secure' property is set on browser session cookies.",
375375
Flag: "secure-auth-cookie",
376376
},
377+
StrictTransportSecurity: &codersdk.DeploymentConfigField[int]{
378+
Name: "Strict-Transport-Security",
379+
Usage: "Controls if the 'Strict-Transport-Security' header is set on all responses. " +
380+
"This header should only be set if the server is accessed via HTTPS. The value should be a whole number in seconds.",
381+
Default: 0,
382+
Flag: "strict-transport-security",
383+
},
384+
StrictTransportSecurityOptions: &codersdk.DeploymentConfigField[[]string]{
385+
Name: "Strict-Transport-Security Options",
386+
Usage: "Two optional fields can be set in the Strict-Transport-Security header; 'includeSubDomains' and 'preload'. " +
387+
"The 'strict-transport-security' flag must be set to a non-zero value for these options to be used.",
388+
Flag: "strict-transport-security-options",
389+
},
377390
SSHKeygenAlgorithm: &codersdk.DeploymentConfigField[string]{
378391
Name: "SSH Keygen Algorithm",
379392
Usage: "The algorithm to use for generating ssh keys. Accepted values are \"ed25519\", \"ecdsa\", or \"rsa4096\".",

cli/server.go

Lines changed: 25 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -457,29 +457,31 @@ func Server(vip *viper.Viper, newAPI func(context.Context, *coderd.Options) (*co
457457
}
458458

459459
options := &coderd.Options{
460-
AccessURL: accessURLParsed,
461-
AppHostname: appHostname,
462-
AppHostnameRegex: appHostnameRegex,
463-
Logger: logger.Named("coderd"),
464-
Database: dbfake.New(),
465-
DERPMap: derpMap,
466-
Pubsub: database.NewPubsubInMemory(),
467-
CacheDir: cacheDir,
468-
GoogleTokenValidator: googleTokenValidator,
469-
GitAuthConfigs: gitAuthConfigs,
470-
RealIPConfig: realIPConfig,
471-
SecureAuthCookie: cfg.SecureAuthCookie.Value,
472-
SSHKeygenAlgorithm: sshKeygenAlgorithm,
473-
TracerProvider: tracerProvider,
474-
Telemetry: telemetry.NewNoop(),
475-
MetricsCacheRefreshInterval: cfg.MetricsCacheRefreshInterval.Value,
476-
AgentStatsRefreshInterval: cfg.AgentStatRefreshInterval.Value,
477-
DeploymentConfig: cfg,
478-
PrometheusRegistry: prometheus.NewRegistry(),
479-
APIRateLimit: cfg.RateLimit.API.Value,
480-
LoginRateLimit: loginRateLimit,
481-
FilesRateLimit: filesRateLimit,
482-
HTTPClient: httpClient,
460+
AccessURL: accessURLParsed,
461+
AppHostname: appHostname,
462+
AppHostnameRegex: appHostnameRegex,
463+
Logger: logger.Named("coderd"),
464+
Database: dbfake.New(),
465+
DERPMap: derpMap,
466+
Pubsub: database.NewPubsubInMemory(),
467+
CacheDir: cacheDir,
468+
GoogleTokenValidator: googleTokenValidator,
469+
GitAuthConfigs: gitAuthConfigs,
470+
RealIPConfig: realIPConfig,
471+
SecureAuthCookie: cfg.SecureAuthCookie.Value,
472+
StrictTransportSecurityAge: cfg.StrictTransportSecurity.Value,
473+
StrictTransportSecurityOptions: cfg.StrictTransportSecurityOptions.Value,
474+
SSHKeygenAlgorithm: sshKeygenAlgorithm,
475+
TracerProvider: tracerProvider,
476+
Telemetry: telemetry.NewNoop(),
477+
MetricsCacheRefreshInterval: cfg.MetricsCacheRefreshInterval.Value,
478+
AgentStatsRefreshInterval: cfg.AgentStatRefreshInterval.Value,
479+
DeploymentConfig: cfg,
480+
PrometheusRegistry: prometheus.NewRegistry(),
481+
APIRateLimit: cfg.RateLimit.API.Value,
482+
LoginRateLimit: loginRateLimit,
483+
FilesRateLimit: filesRateLimit,
484+
HTTPClient: httpClient,
483485
}
484486
if tlsConfig != nil {
485487
options.TLSCertificates = tlsConfig.Certificates

coderd/coderd.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,8 @@ type Options struct {
103103
OIDCConfig *OIDCConfig
104104
PrometheusRegistry *prometheus.Registry
105105
SecureAuthCookie bool
106+
StrictTransportSecurityAge int
107+
StrictTransportSecurityOptions []string
106108
SSHKeygenAlgorithm gitsshkey.Algorithm
107109
Telemetry telemetry.Reporter
108110
TracerProvider trace.TracerProvider
@@ -222,6 +224,15 @@ func New(options *Options) *API {
222224
options.MetricsCacheRefreshInterval,
223225
)
224226

227+
staticHandler := site.Handler(site.FS(), binFS, binHashes)
228+
// Static file handler must be wrapped with HSTS handler if the
229+
// StrictTransportSecurityAge is set. We only need to set this header on
230+
// static files since it only affects browsers.
231+
staticHandler, err = httpmw.HSTS(staticHandler, options.StrictTransportSecurityAge, options.StrictTransportSecurityOptions)
232+
if err != nil {
233+
panic(xerrors.Errorf("coderd: setting hsts header failed (options: %v): %w", options.StrictTransportSecurityOptions, err))
234+
}
235+
225236
r := chi.NewRouter()
226237
api := &API{
227238
ID: uuid.New(),

coderd/httpmw/hsts.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package httpmw
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
"strings"
7+
8+
"golang.org/x/xerrors"
9+
)
10+
11+
const (
12+
hstsHeader = "Strict-Transport-Security"
13+
)
14+
15+
// HSTS will add the strict-transport-security header if enabled. This header
16+
// forces a browser to always use https for the domain after it loads https once.
17+
// Meaning: On first load of product.coder.com, they are redirected to https. On
18+
// all subsequent loads, the client's local browser forces https. This prevents
19+
// man in the middle.
20+
//
21+
// This header only makes sense if the app is using tls.
22+
//
23+
// Full header example:
24+
// Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
25+
func HSTS(next http.Handler, maxAge int, options []string) (http.Handler, error) {
26+
if maxAge <= 0 {
27+
// No header, so no need to wrap the handler
28+
return next, nil
29+
}
30+
31+
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security
32+
var str strings.Builder
33+
_, err := str.WriteString(fmt.Sprintf("max-age=%d", maxAge))
34+
if err != nil {
35+
return nil, xerrors.Errorf("hsts: write max-age: %w", err)
36+
}
37+
38+
for _, option := range options {
39+
switch {
40+
// Only allow valid options and fix any casing mistakes
41+
case strings.EqualFold(option, "includeSubDomains"):
42+
option = "includeSubDomains"
43+
case strings.EqualFold(option, "preload"):
44+
option = "preload"
45+
default:
46+
return nil, xerrors.Errorf("hsts: invalid option: %q. Must be 'preload' and/or 'includeSubDomains'", option)
47+
}
48+
_, err = str.WriteString("; " + option)
49+
if err != nil {
50+
return nil, xerrors.Errorf("hsts: write option: %w", err)
51+
}
52+
}
53+
54+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
55+
w.Header().Set(hstsHeader, str.String())
56+
next.ServeHTTP(w, r)
57+
}), nil
58+
}

coderd/httpmw/hsts_test.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package httpmw_test
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"testing"
7+
8+
"github.com/coder/coder/coderd/httpmw"
9+
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
func TestHSTS(t *testing.T) {
14+
tests := []struct {
15+
Name string
16+
MaxAge int
17+
Options []string
18+
19+
wantErr bool
20+
expectHeader string
21+
}{
22+
{
23+
Name: "Empty",
24+
MaxAge: 0,
25+
Options: nil,
26+
},
27+
{
28+
Name: "NoAge",
29+
MaxAge: 0,
30+
Options: []string{"includeSubDomains"},
31+
},
32+
{
33+
Name: "NegativeAge",
34+
MaxAge: -100,
35+
Options: []string{"includeSubDomains"},
36+
},
37+
{
38+
Name: "Age",
39+
MaxAge: 1000,
40+
Options: []string{},
41+
expectHeader: "max-age=1000",
42+
},
43+
{
44+
Name: "AgeSubDomains",
45+
MaxAge: 1000,
46+
// Mess with casing
47+
Options: []string{"INCLUDESUBDOMAINS"},
48+
expectHeader: "max-age=1000; includeSubDomains",
49+
},
50+
{
51+
Name: "AgePreload",
52+
MaxAge: 1000,
53+
Options: []string{"Preload"},
54+
expectHeader: "max-age=1000; preload",
55+
},
56+
{
57+
Name: "AllOptions",
58+
MaxAge: 1000,
59+
Options: []string{"preload", "includeSubDomains"},
60+
expectHeader: "max-age=1000; preload; includeSubDomains",
61+
},
62+
63+
// Error values
64+
{
65+
Name: "BadOption",
66+
MaxAge: 100,
67+
Options: []string{"not-valid"},
68+
wantErr: true,
69+
},
70+
{
71+
Name: "BadOptions",
72+
MaxAge: 100,
73+
Options: []string{"includeSubDomains", "not-valid", "still-not-valid"},
74+
wantErr: true,
75+
},
76+
}
77+
for _, tt := range tests {
78+
tt := tt
79+
t.Run(tt.Name, func(t *testing.T) {
80+
t.Parallel()
81+
82+
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
83+
w.WriteHeader(http.StatusOK)
84+
})
85+
86+
got, err := httpmw.HSTS(handler, tt.MaxAge, tt.Options)
87+
if tt.wantErr {
88+
require.Error(t, err, "Expect error, HSTS(%v, %v)", tt.MaxAge, tt.Options)
89+
return
90+
}
91+
92+
require.NoError(t, err, "Expect no error, HSTS(%v, %v)", tt.MaxAge, tt.Options)
93+
req := httptest.NewRequest("GET", "/", nil)
94+
res := httptest.NewRecorder()
95+
96+
got.ServeHTTP(res, req)
97+
require.Equal(t, tt.expectHeader, res.Header().Get("Strict-Transport-Security"), "expected header value")
98+
})
99+
}
100+
}

codersdk/deployment.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,8 @@ type DeploymentConfig struct {
126126
TLS *TLSConfig `json:"tls" typescript:",notnull"`
127127
Trace *TraceConfig `json:"trace" typescript:",notnull"`
128128
SecureAuthCookie *DeploymentConfigField[bool] `json:"secure_auth_cookie" typescript:",notnull"`
129+
StrictTransportSecurity *DeploymentConfigField[int] `json:"strict_transport_security" typescript:",notnull"`
130+
StrictTransportSecurityOptions *DeploymentConfigField[[]string] `json:"strict_transport_security_options" typescript:",notnull"`
129131
SSHKeygenAlgorithm *DeploymentConfigField[string] `json:"ssh_keygen_algorithm" typescript:",notnull"`
130132
MetricsCacheRefreshInterval *DeploymentConfigField[time.Duration] `json:"metrics_cache_refresh_interval" typescript:",notnull"`
131133
AgentStatRefreshInterval *DeploymentConfigField[time.Duration] `json:"agent_stat_refresh_interval" typescript:",notnull"`

0 commit comments

Comments
 (0)