Skip to content

Commit 136b6c9

Browse files
committed
chore: deployment config option to append custom csp directives
Allows adding custom static CSP directives to Coder. Niche use case but makes this easier then creating a reverse proxy that has to replace the header. We want to preserve our directives.
1 parent 576e1f4 commit 136b6c9

File tree

5 files changed

+94
-39
lines changed

5 files changed

+94
-39
lines changed

coderd/coderd.go

+22-1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"crypto/tls"
66
"crypto/x509"
77
"database/sql"
8+
"errors"
89
"expvar"
910
"flag"
1011
"fmt"
@@ -1378,6 +1379,26 @@ func New(options *Options) *API {
13781379
r.Get("/swagger/*", swaggerDisabled)
13791380
}
13801381

1382+
additionalCSPHeaders := make(map[httpmw.CSPFetchDirective][]string)
1383+
var cspParseErrors error
1384+
for _, v := range api.DeploymentValues.AdditionalCSPPolicy {
1385+
// Format is "<directive> <value> <value> ..."
1386+
v = strings.TrimSpace(v)
1387+
parts := strings.Split(v, " ")
1388+
if len(parts) < 2 {
1389+
cspParseErrors = errors.Join(cspParseErrors, fmt.Errorf("invalid CSP header %q, not enough parts to be valid", v))
1390+
continue
1391+
}
1392+
additionalCSPHeaders[httpmw.CSPFetchDirective(strings.ToLower(parts[0]))] = parts[1:]
1393+
}
1394+
1395+
if cspParseErrors != nil {
1396+
// Do not fail Coder deployment startup because of this. Just log an error
1397+
// and continue
1398+
api.Logger.Error(context.Background(),
1399+
"parsing additional CSP headers", slog.Error(cspParseErrors))
1400+
}
1401+
13811402
// Add CSP headers to all static assets and pages. CSP headers only affect
13821403
// browsers, so these don't make sense on api routes.
13831404
cspMW := httpmw.CSPHeaders(options.Telemetry.Enabled(), func() []string {
@@ -1390,7 +1411,7 @@ func New(options *Options) *API {
13901411
}
13911412
// By default we do not add extra websocket connections to the CSP
13921413
return []string{}
1393-
})
1414+
}, additionalCSPHeaders)
13941415

13951416
// Static file handler must be wrapped with HSTS handler if the
13961417
// StrictTransportSecurityAge is set. We only need to set this header on

coderd/httpmw/csp.go

+52-38
Original file line numberDiff line numberDiff line change
@@ -23,29 +23,39 @@ func (s cspDirectives) Append(d CSPFetchDirective, values ...string) {
2323
type CSPFetchDirective string
2424

2525
const (
26-
cspDirectiveDefaultSrc = "default-src"
27-
cspDirectiveConnectSrc = "connect-src"
28-
cspDirectiveChildSrc = "child-src"
29-
cspDirectiveScriptSrc = "script-src"
30-
cspDirectiveFontSrc = "font-src"
31-
cspDirectiveStyleSrc = "style-src"
32-
cspDirectiveObjectSrc = "object-src"
33-
cspDirectiveManifestSrc = "manifest-src"
34-
cspDirectiveFrameSrc = "frame-src"
35-
cspDirectiveImgSrc = "img-src"
36-
cspDirectiveReportURI = "report-uri"
37-
cspDirectiveFormAction = "form-action"
38-
cspDirectiveMediaSrc = "media-src"
39-
cspFrameAncestors = "frame-ancestors"
40-
cspDirectiveWorkerSrc = "worker-src"
26+
CSPDirectiveDefaultSrc CSPFetchDirective = "default-src"
27+
CSPDirectiveConnectSrc CSPFetchDirective = "connect-src"
28+
CSPDirectiveChildSrc CSPFetchDirective = "child-src"
29+
CSPDirectiveScriptSrc CSPFetchDirective = "script-src"
30+
CSPDirectiveFontSrc CSPFetchDirective = "font-src"
31+
CSPDirectiveStyleSrc CSPFetchDirective = "style-src"
32+
CSPDirectiveObjectSrc CSPFetchDirective = "object-src"
33+
CSPDirectiveManifestSrc CSPFetchDirective = "manifest-src"
34+
CSPDirectiveFrameSrc CSPFetchDirective = "frame-src"
35+
CSPDirectiveImgSrc CSPFetchDirective = "img-src"
36+
CSPDirectiveReportURI CSPFetchDirective = "report-uri"
37+
CSPDirectiveFormAction CSPFetchDirective = "form-action"
38+
CSPDirectiveMediaSrc CSPFetchDirective = "media-src"
39+
CSPFrameAncestors CSPFetchDirective = "frame-ancestors"
40+
CSPDirectiveWorkerSrc CSPFetchDirective = "worker-src"
4141
)
4242

4343
// CSPHeaders returns a middleware that sets the Content-Security-Policy header
44-
// for coderd. It takes a function that allows adding supported external websocket
45-
// hosts. This is primarily to support the terminal connecting to a workspace proxy.
44+
// for coderd.
45+
//
46+
// Arguments:
47+
// - websocketHosts: a function that returns a list of supported external websocket hosts.
48+
// This is to support the terminal connecting to a workspace proxy.
49+
// The origin of the terminal request does not match the url of the proxy,
50+
// so the CSP list of allowed hosts must be dynamic and match the current
51+
// available proxy urls.
52+
// - staticAdditions: a map of CSP directives to append to the default CSP headers.
53+
// Used to allow specific static additions to the CSP headers. Allows some niche
54+
// use cases, such as embedding Coder in an iframe.
55+
// Example: https://github.com/coder/coder/issues/15118
4656
//
4757
//nolint:revive
48-
func CSPHeaders(telemetry bool, websocketHosts func() []string) func(next http.Handler) http.Handler {
58+
func CSPHeaders(telemetry bool, websocketHosts func() []string, staticAdditions map[CSPFetchDirective][]string) func(next http.Handler) http.Handler {
4959
return func(next http.Handler) http.Handler {
5060
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
5161
// Content-Security-Policy disables loading certain content types and can prevent XSS injections.
@@ -55,30 +65,30 @@ func CSPHeaders(telemetry bool, websocketHosts func() []string) func(next http.H
5565
// The list of CSP options: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/default-src
5666
cspSrcs := cspDirectives{
5767
// All omitted fetch csp srcs default to this.
58-
cspDirectiveDefaultSrc: {"'self'"},
59-
cspDirectiveConnectSrc: {"'self'"},
60-
cspDirectiveChildSrc: {"'self'"},
68+
CSPDirectiveDefaultSrc: {"'self'"},
69+
CSPDirectiveConnectSrc: {"'self'"},
70+
CSPDirectiveChildSrc: {"'self'"},
6171
// https://github.com/suren-atoyan/monaco-react/issues/168
62-
cspDirectiveScriptSrc: {"'self'"},
63-
cspDirectiveStyleSrc: {"'self' 'unsafe-inline'"},
72+
CSPDirectiveScriptSrc: {"'self'"},
73+
CSPDirectiveStyleSrc: {"'self' 'unsafe-inline'"},
6474
// data: is used by monaco editor on FE for Syntax Highlight
65-
cspDirectiveFontSrc: {"'self' data:"},
66-
cspDirectiveWorkerSrc: {"'self' blob:"},
75+
CSPDirectiveFontSrc: {"'self' data:"},
76+
CSPDirectiveWorkerSrc: {"'self' blob:"},
6777
// object-src is needed to support code-server
68-
cspDirectiveObjectSrc: {"'self'"},
78+
CSPDirectiveObjectSrc: {"'self'"},
6979
// blob: for loading the pwa manifest for code-server
70-
cspDirectiveManifestSrc: {"'self' blob:"},
71-
cspDirectiveFrameSrc: {"'self'"},
80+
CSPDirectiveManifestSrc: {"'self' blob:"},
81+
CSPDirectiveFrameSrc: {"'self'"},
7282
// data: for loading base64 encoded icons for generic applications.
7383
// https: allows loading images from external sources. This is not ideal
7484
// but is required for the templates page that renders readmes.
7585
// We should find a better solution in the future.
76-
cspDirectiveImgSrc: {"'self' https: data:"},
77-
cspDirectiveFormAction: {"'self'"},
78-
cspDirectiveMediaSrc: {"'self'"},
86+
CSPDirectiveImgSrc: {"'self' https: data:"},
87+
CSPDirectiveFormAction: {"'self'"},
88+
CSPDirectiveMediaSrc: {"'self'"},
7989
// Report all violations back to the server to log
80-
cspDirectiveReportURI: {"/api/v2/csp/reports"},
81-
cspFrameAncestors: {"'none'"},
90+
CSPDirectiveReportURI: {"/api/v2/csp/reports"},
91+
CSPFrameAncestors: {"'none'"},
8292

8393
// Only scripts can manipulate the dom. This prevents someone from
8494
// naming themselves something like '<svg onload="alert(/cross-site-scripting/)" />'.
@@ -87,7 +97,7 @@ func CSPHeaders(telemetry bool, websocketHosts func() []string) func(next http.H
8797

8898
if telemetry {
8999
// If telemetry is enabled, we report to coder.com.
90-
cspSrcs.Append(cspDirectiveConnectSrc, "https://coder.com")
100+
cspSrcs.Append(CSPDirectiveConnectSrc, "https://coder.com")
91101
}
92102

93103
// This extra connect-src addition is required to support old webkit
@@ -102,7 +112,7 @@ func CSPHeaders(telemetry bool, websocketHosts func() []string) func(next http.H
102112
// We can add both ws:// and wss:// as browsers do not let https
103113
// pages to connect to non-tls websocket connections. So this
104114
// supports both http & https webpages.
105-
cspSrcs.Append(cspDirectiveConnectSrc, fmt.Sprintf("wss://%[1]s ws://%[1]s", host))
115+
cspSrcs.Append(CSPDirectiveConnectSrc, fmt.Sprintf("wss://%[1]s ws://%[1]s", host))
106116
}
107117

108118
// The terminal requires a websocket connection to the workspace proxy.
@@ -112,15 +122,19 @@ func CSPHeaders(telemetry bool, websocketHosts func() []string) func(next http.H
112122
for _, extraHost := range extraConnect {
113123
if extraHost == "*" {
114124
// '*' means all
115-
cspSrcs.Append(cspDirectiveConnectSrc, "*")
125+
cspSrcs.Append(CSPDirectiveConnectSrc, "*")
116126
continue
117127
}
118-
cspSrcs.Append(cspDirectiveConnectSrc, fmt.Sprintf("wss://%[1]s ws://%[1]s", extraHost))
128+
cspSrcs.Append(CSPDirectiveConnectSrc, fmt.Sprintf("wss://%[1]s ws://%[1]s", extraHost))
119129
// We also require this to make http/https requests to the workspace proxy for latency checking.
120-
cspSrcs.Append(cspDirectiveConnectSrc, fmt.Sprintf("https://%[1]s http://%[1]s", extraHost))
130+
cspSrcs.Append(CSPDirectiveConnectSrc, fmt.Sprintf("https://%[1]s http://%[1]s", extraHost))
121131
}
122132
}
123133

134+
for directive, values := range staticAdditions {
135+
cspSrcs.Append(directive, values...)
136+
}
137+
124138
var csp strings.Builder
125139
for src, vals := range cspSrcs {
126140
_, _ = fmt.Fprintf(&csp, "%s %s; ", src, strings.Join(vals, " "))

coderd/httpmw/csp_test.go

+6
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,15 @@ func TestCSPConnect(t *testing.T) {
1515
t.Parallel()
1616

1717
expected := []string{"example.com", "coder.com"}
18+
expectedMedia := []string{"media.com", "media2.com"}
1819

1920
r := httptest.NewRequest(http.MethodGet, "/", nil)
2021
rw := httptest.NewRecorder()
2122

2223
httpmw.CSPHeaders(false, func() []string {
2324
return expected
25+
}, map[httpmw.CSPFetchDirective][]string{
26+
httpmw.CSPDirectiveMediaSrc: expectedMedia,
2427
})(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
2528
rw.WriteHeader(http.StatusOK)
2629
})).ServeHTTP(rw, r)
@@ -30,4 +33,7 @@ func TestCSPConnect(t *testing.T) {
3033
require.Containsf(t, rw.Header().Get("Content-Security-Policy"), fmt.Sprintf("ws://%s", e), "Content-Security-Policy header should contain ws://%s", e)
3134
require.Containsf(t, rw.Header().Get("Content-Security-Policy"), fmt.Sprintf("wss://%s", e), "Content-Security-Policy header should contain wss://%s", e)
3235
}
36+
for _, e := range expectedMedia {
37+
require.Containsf(t, rw.Header().Get("Content-Security-Policy"), fmt.Sprintf("%s", e), "Content-Security-Policy header should contain %s", e)
38+
}
3339
}

codersdk/deployment.go

+13
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,7 @@ type DeploymentValues struct {
391391
CLIUpgradeMessage serpent.String `json:"cli_upgrade_message,omitempty" typescript:",notnull"`
392392
TermsOfServiceURL serpent.String `json:"terms_of_service_url,omitempty" typescript:",notnull"`
393393
Notifications NotificationsConfig `json:"notifications,omitempty" typescript:",notnull"`
394+
AdditionalCSPPolicy serpent.StringArray `json:"additional_csp_policy,omitempty" typescript:",notnull"`
394395

395396
Config serpent.YAMLConfigPath `json:"config,omitempty" typescript:",notnull"`
396397
WriteConfig serpent.Bool `json:"write_config,omitempty" typescript:",notnull"`
@@ -2147,6 +2148,18 @@ when required by your organization's security policy.`,
21472148
Group: &deploymentGroupIntrospectionLogging,
21482149
YAML: "enableTerraformDebugMode",
21492150
},
2151+
{
2152+
Name: "Additional CSP Policy",
2153+
Description: "Coder configures a Content Security Policy (CSP) to protect against XSS attacks. " +
2154+
"This setting allows you to add additional CSP directives, which can open the attack surface of the deployment. " +
2155+
"Format matches the CSP directive format, e.g. 'script-src https://example.com'.",
2156+
Flag: "additional-csp-policy",
2157+
Env: "CODER_ADDITIONAL_CSP_POLICY",
2158+
YAML: "additionalCSPPolicy",
2159+
Value: &c.AdditionalCSPPolicy,
2160+
Group: &deploymentGroupNetworkingHTTP,
2161+
},
2162+
21502163
// ☢️ Dangerous settings
21512164
{
21522165
Name: "DANGEROUS: Allow all CORS requests",

site/src/api/typesGenerated.ts

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)