diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index 6f524dff9a25a..516aa9544e641 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -294,6 +294,13 @@ backed by Tailscale and WireGuard. + 1`. Use special value 'disable' to turn off STUN completely. NETWORKING / HTTP OPTIONS: + --additional-csp-policy string-array, $CODER_ADDITIONAL_CSP_POLICY + Coder configures a Content Security Policy (CSP) to protect against + XSS attacks. This setting allows you to add additional CSP directives, + which can open the attack surface of the deployment. Format matches + the CSP directive format, e.g. --additional-csp-policy="script-src + https://example.com". + --disable-password-auth bool, $CODER_DISABLE_PASSWORD_AUTH Disable password authentication. This is recommended for security purposes in production deployments that rely on an identity provider. diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index 72f39997b5734..50c80c737aecd 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -16,6 +16,12 @@ networking: # HTTP bind address of the server. Unset to disable the HTTP endpoint. # (default: 127.0.0.1:3000, type: string) httpAddress: 127.0.0.1:3000 + # Coder configures a Content Security Policy (CSP) to protect against XSS attacks. + # This setting allows you to add additional CSP directives, which can open the + # attack surface of the deployment. Format matches the CSP directive format, e.g. + # --additional-csp-policy="script-src https://example.com". + # (default: , type: string-array) + additionalCSPPolicy: [] # The maximum lifetime duration users can specify when creating an API token. # (default: 876600h0m0s, type: duration) maxTokenLifetime: 876600h0m0s diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 7c96af792e13c..a8c5b366e339f 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -10503,6 +10503,12 @@ const docTemplate = `{ "access_url": { "$ref": "#/definitions/serpent.URL" }, + "additional_csp_policy": { + "type": "array", + "items": { + "type": "string" + } + }, "address": { "description": "DEPRECATED: Use HTTPAddress or TLS.Address instead.", "allOf": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 56b36a4580a16..4ee537d3644bc 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -9373,6 +9373,12 @@ "access_url": { "$ref": "#/definitions/serpent.URL" }, + "additional_csp_policy": { + "type": "array", + "items": { + "type": "string" + } + }, "address": { "description": "DEPRECATED: Use HTTPAddress or TLS.Address instead.", "allOf": [ diff --git a/coderd/coderd.go b/coderd/coderd.go index bc4afa44c88df..26970407671b2 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -5,6 +5,7 @@ import ( "crypto/tls" "crypto/x509" "database/sql" + "errors" "expvar" "flag" "fmt" @@ -1378,6 +1379,26 @@ func New(options *Options) *API { r.Get("/swagger/*", swaggerDisabled) } + additionalCSPHeaders := make(map[httpmw.CSPFetchDirective][]string, len(api.DeploymentValues.AdditionalCSPPolicy)) + var cspParseErrors error + for _, v := range api.DeploymentValues.AdditionalCSPPolicy { + // Format is " ..." + v = strings.TrimSpace(v) + parts := strings.Split(v, " ") + if len(parts) < 2 { + cspParseErrors = errors.Join(cspParseErrors, xerrors.Errorf("invalid CSP header %q, not enough parts to be valid", v)) + continue + } + additionalCSPHeaders[httpmw.CSPFetchDirective(strings.ToLower(parts[0]))] = parts[1:] + } + + if cspParseErrors != nil { + // Do not fail Coder deployment startup because of this. Just log an error + // and continue + api.Logger.Error(context.Background(), + "parsing additional CSP headers", slog.Error(cspParseErrors)) + } + // Add CSP headers to all static assets and pages. CSP headers only affect // browsers, so these don't make sense on api routes. cspMW := httpmw.CSPHeaders(options.Telemetry.Enabled(), func() []string { @@ -1390,7 +1411,7 @@ func New(options *Options) *API { } // By default we do not add extra websocket connections to the CSP return []string{} - }) + }, additionalCSPHeaders) // Static file handler must be wrapped with HSTS handler if the // StrictTransportSecurityAge is set. We only need to set this header on diff --git a/coderd/httpmw/csp.go b/coderd/httpmw/csp.go index 0862a0cd7cb2a..e6864b7448c41 100644 --- a/coderd/httpmw/csp.go +++ b/coderd/httpmw/csp.go @@ -23,29 +23,39 @@ func (s cspDirectives) Append(d CSPFetchDirective, values ...string) { type CSPFetchDirective string const ( - cspDirectiveDefaultSrc = "default-src" - cspDirectiveConnectSrc = "connect-src" - cspDirectiveChildSrc = "child-src" - cspDirectiveScriptSrc = "script-src" - cspDirectiveFontSrc = "font-src" - cspDirectiveStyleSrc = "style-src" - cspDirectiveObjectSrc = "object-src" - cspDirectiveManifestSrc = "manifest-src" - cspDirectiveFrameSrc = "frame-src" - cspDirectiveImgSrc = "img-src" - cspDirectiveReportURI = "report-uri" - cspDirectiveFormAction = "form-action" - cspDirectiveMediaSrc = "media-src" - cspFrameAncestors = "frame-ancestors" - cspDirectiveWorkerSrc = "worker-src" + CSPDirectiveDefaultSrc CSPFetchDirective = "default-src" + CSPDirectiveConnectSrc CSPFetchDirective = "connect-src" + CSPDirectiveChildSrc CSPFetchDirective = "child-src" + CSPDirectiveScriptSrc CSPFetchDirective = "script-src" + CSPDirectiveFontSrc CSPFetchDirective = "font-src" + CSPDirectiveStyleSrc CSPFetchDirective = "style-src" + CSPDirectiveObjectSrc CSPFetchDirective = "object-src" + CSPDirectiveManifestSrc CSPFetchDirective = "manifest-src" + CSPDirectiveFrameSrc CSPFetchDirective = "frame-src" + CSPDirectiveImgSrc CSPFetchDirective = "img-src" + CSPDirectiveReportURI CSPFetchDirective = "report-uri" + CSPDirectiveFormAction CSPFetchDirective = "form-action" + CSPDirectiveMediaSrc CSPFetchDirective = "media-src" + CSPFrameAncestors CSPFetchDirective = "frame-ancestors" + CSPDirectiveWorkerSrc CSPFetchDirective = "worker-src" ) // CSPHeaders returns a middleware that sets the Content-Security-Policy header -// for coderd. It takes a function that allows adding supported external websocket -// hosts. This is primarily to support the terminal connecting to a workspace proxy. +// for coderd. +// +// Arguments: +// - websocketHosts: a function that returns a list of supported external websocket hosts. +// This is to support the terminal connecting to a workspace proxy. +// The origin of the terminal request does not match the url of the proxy, +// so the CSP list of allowed hosts must be dynamic and match the current +// available proxy urls. +// - staticAdditions: a map of CSP directives to append to the default CSP headers. +// Used to allow specific static additions to the CSP headers. Allows some niche +// use cases, such as embedding Coder in an iframe. +// Example: https://github.com/coder/coder/issues/15118 // //nolint:revive -func CSPHeaders(telemetry bool, websocketHosts func() []string) func(next http.Handler) http.Handler { +func CSPHeaders(telemetry bool, websocketHosts func() []string, staticAdditions map[CSPFetchDirective][]string) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // 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 // The list of CSP options: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/default-src cspSrcs := cspDirectives{ // All omitted fetch csp srcs default to this. - cspDirectiveDefaultSrc: {"'self'"}, - cspDirectiveConnectSrc: {"'self'"}, - cspDirectiveChildSrc: {"'self'"}, + CSPDirectiveDefaultSrc: {"'self'"}, + CSPDirectiveConnectSrc: {"'self'"}, + CSPDirectiveChildSrc: {"'self'"}, // https://github.com/suren-atoyan/monaco-react/issues/168 - cspDirectiveScriptSrc: {"'self'"}, - cspDirectiveStyleSrc: {"'self' 'unsafe-inline'"}, + CSPDirectiveScriptSrc: {"'self'"}, + CSPDirectiveStyleSrc: {"'self' 'unsafe-inline'"}, // data: is used by monaco editor on FE for Syntax Highlight - cspDirectiveFontSrc: {"'self' data:"}, - cspDirectiveWorkerSrc: {"'self' blob:"}, + CSPDirectiveFontSrc: {"'self' data:"}, + CSPDirectiveWorkerSrc: {"'self' blob:"}, // object-src is needed to support code-server - cspDirectiveObjectSrc: {"'self'"}, + CSPDirectiveObjectSrc: {"'self'"}, // blob: for loading the pwa manifest for code-server - cspDirectiveManifestSrc: {"'self' blob:"}, - cspDirectiveFrameSrc: {"'self'"}, + CSPDirectiveManifestSrc: {"'self' blob:"}, + CSPDirectiveFrameSrc: {"'self'"}, // data: for loading base64 encoded icons for generic applications. // https: allows loading images from external sources. This is not ideal // but is required for the templates page that renders readmes. // We should find a better solution in the future. - cspDirectiveImgSrc: {"'self' https: data:"}, - cspDirectiveFormAction: {"'self'"}, - cspDirectiveMediaSrc: {"'self'"}, + CSPDirectiveImgSrc: {"'self' https: data:"}, + CSPDirectiveFormAction: {"'self'"}, + CSPDirectiveMediaSrc: {"'self'"}, // Report all violations back to the server to log - cspDirectiveReportURI: {"/api/v2/csp/reports"}, - cspFrameAncestors: {"'none'"}, + CSPDirectiveReportURI: {"/api/v2/csp/reports"}, + CSPFrameAncestors: {"'none'"}, // Only scripts can manipulate the dom. This prevents someone from // naming themselves something like ''. @@ -87,7 +97,7 @@ func CSPHeaders(telemetry bool, websocketHosts func() []string) func(next http.H if telemetry { // If telemetry is enabled, we report to coder.com. - cspSrcs.Append(cspDirectiveConnectSrc, "https://coder.com") + cspSrcs.Append(CSPDirectiveConnectSrc, "https://coder.com") } // 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 // We can add both ws:// and wss:// as browsers do not let https // pages to connect to non-tls websocket connections. So this // supports both http & https webpages. - cspSrcs.Append(cspDirectiveConnectSrc, fmt.Sprintf("wss://%[1]s ws://%[1]s", host)) + cspSrcs.Append(CSPDirectiveConnectSrc, fmt.Sprintf("wss://%[1]s ws://%[1]s", host)) } // 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 for _, extraHost := range extraConnect { if extraHost == "*" { // '*' means all - cspSrcs.Append(cspDirectiveConnectSrc, "*") + cspSrcs.Append(CSPDirectiveConnectSrc, "*") continue } - cspSrcs.Append(cspDirectiveConnectSrc, fmt.Sprintf("wss://%[1]s ws://%[1]s", extraHost)) + cspSrcs.Append(CSPDirectiveConnectSrc, fmt.Sprintf("wss://%[1]s ws://%[1]s", extraHost)) // We also require this to make http/https requests to the workspace proxy for latency checking. - cspSrcs.Append(cspDirectiveConnectSrc, fmt.Sprintf("https://%[1]s http://%[1]s", extraHost)) + cspSrcs.Append(CSPDirectiveConnectSrc, fmt.Sprintf("https://%[1]s http://%[1]s", extraHost)) } } + for directive, values := range staticAdditions { + cspSrcs.Append(directive, values...) + } + var csp strings.Builder for src, vals := range cspSrcs { _, _ = fmt.Fprintf(&csp, "%s %s; ", src, strings.Join(vals, " ")) diff --git a/coderd/httpmw/csp_test.go b/coderd/httpmw/csp_test.go index d389d778eeba6..c5000d3a29370 100644 --- a/coderd/httpmw/csp_test.go +++ b/coderd/httpmw/csp_test.go @@ -15,12 +15,15 @@ func TestCSPConnect(t *testing.T) { t.Parallel() expected := []string{"example.com", "coder.com"} + expectedMedia := []string{"media.com", "media2.com"} r := httptest.NewRequest(http.MethodGet, "/", nil) rw := httptest.NewRecorder() httpmw.CSPHeaders(false, func() []string { return expected + }, map[httpmw.CSPFetchDirective][]string{ + httpmw.CSPDirectiveMediaSrc: expectedMedia, })(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { rw.WriteHeader(http.StatusOK) })).ServeHTTP(rw, r) @@ -30,4 +33,7 @@ func TestCSPConnect(t *testing.T) { require.Containsf(t, rw.Header().Get("Content-Security-Policy"), fmt.Sprintf("ws://%s", e), "Content-Security-Policy header should contain ws://%s", e) require.Containsf(t, rw.Header().Get("Content-Security-Policy"), fmt.Sprintf("wss://%s", e), "Content-Security-Policy header should contain wss://%s", e) } + for _, e := range expectedMedia { + require.Containsf(t, rw.Header().Get("Content-Security-Policy"), e, "Content-Security-Policy header should contain %s", e) + } } diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 7de961ec869f8..7bb90848a8205 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -391,6 +391,7 @@ type DeploymentValues struct { CLIUpgradeMessage serpent.String `json:"cli_upgrade_message,omitempty" typescript:",notnull"` TermsOfServiceURL serpent.String `json:"terms_of_service_url,omitempty" typescript:",notnull"` Notifications NotificationsConfig `json:"notifications,omitempty" typescript:",notnull"` + AdditionalCSPPolicy serpent.StringArray `json:"additional_csp_policy,omitempty" typescript:",notnull"` Config serpent.YAMLConfigPath `json:"config,omitempty" typescript:",notnull"` WriteConfig serpent.Bool `json:"write_config,omitempty" typescript:",notnull"` @@ -2147,6 +2148,18 @@ when required by your organization's security policy.`, Group: &deploymentGroupIntrospectionLogging, YAML: "enableTerraformDebugMode", }, + { + Name: "Additional CSP Policy", + Description: "Coder configures a Content Security Policy (CSP) to protect against XSS attacks. " + + "This setting allows you to add additional CSP directives, which can open the attack surface of the deployment. " + + "Format matches the CSP directive format, e.g. --additional-csp-policy=\"script-src https://example.com\".", + Flag: "additional-csp-policy", + Env: "CODER_ADDITIONAL_CSP_POLICY", + YAML: "additionalCSPPolicy", + Value: &c.AdditionalCSPPolicy, + Group: &deploymentGroupNetworkingHTTP, + }, + // ☢️ Dangerous settings { Name: "DANGEROUS: Allow all CORS requests", diff --git a/docs/reference/api/general.md b/docs/reference/api/general.md index c65d9ec4175b9..57e62d3ba7fed 100644 --- a/docs/reference/api/general.md +++ b/docs/reference/api/general.md @@ -139,6 +139,7 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ "scheme": "string", "user": {} }, + "additional_csp_policy": ["string"], "address": { "host": "string", "port": "string" diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index d40fe8e240005..c0f887a3771f8 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -1757,6 +1757,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "scheme": "string", "user": {} }, + "additional_csp_policy": ["string"], "address": { "host": "string", "port": "string" @@ -2181,6 +2182,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "scheme": "string", "user": {} }, + "additional_csp_policy": ["string"], "address": { "host": "string", "port": "string" @@ -2515,6 +2517,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | Name | Type | Required | Restrictions | Description | | ------------------------------------ | ---------------------------------------------------------------------------------------------------- | -------- | ------------ | ------------------------------------------------------------------ | | `access_url` | [serpent.URL](#serpenturl) | false | | | +| `additional_csp_policy` | array of string | false | | | | `address` | [serpent.HostPort](#serpenthostport) | false | | Address Use HTTPAddress or TLS.Address instead. | | `agent_fallback_troubleshooting_url` | [serpent.URL](#serpenturl) | false | | | | `agent_stat_refresh_interval` | integer | false | | | diff --git a/docs/reference/cli/server.md b/docs/reference/cli/server.md index bdb338076c1a9..02f5b6ff5f4be 100644 --- a/docs/reference/cli/server.md +++ b/docs/reference/cli/server.md @@ -829,6 +829,16 @@ Output Stackdriver compatible logs to a given file. Allow administrators to enable Terraform debug output. +### --additional-csp-policy + +| | | +| ----------- | ------------------------------------------------ | +| Type | string-array | +| Environment | $CODER_ADDITIONAL_CSP_POLICY | +| YAML | networking.http.additionalCSPPolicy | + +Coder configures a Content Security Policy (CSP) to protect against XSS attacks. This setting allows you to add additional CSP directives, which can open the attack surface of the deployment. Format matches the CSP directive format, e.g. --additional-csp-policy="script-src https://example.com". + ### --dangerous-allow-path-app-sharing | | | diff --git a/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden index 8c12249a8bdd7..cf47e82016af7 100644 --- a/enterprise/cli/testdata/coder_server_--help.golden +++ b/enterprise/cli/testdata/coder_server_--help.golden @@ -295,6 +295,13 @@ backed by Tailscale and WireGuard. + 1`. Use special value 'disable' to turn off STUN completely. NETWORKING / HTTP OPTIONS: + --additional-csp-policy string-array, $CODER_ADDITIONAL_CSP_POLICY + Coder configures a Content Security Policy (CSP) to protect against + XSS attacks. This setting allows you to add additional CSP directives, + which can open the attack surface of the deployment. Format matches + the CSP directive format, e.g. --additional-csp-policy="script-src + https://example.com". + --disable-password-auth bool, $CODER_DISABLE_PASSWORD_AUTH Disable password authentication. This is recommended for security purposes in production deployments that rely on an identity provider. diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index bd00dbda353c3..5ae5944c51e02 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -523,6 +523,7 @@ export interface DeploymentValues { readonly cli_upgrade_message?: string; readonly terms_of_service_url?: string; readonly notifications?: NotificationsConfig; + readonly additional_csp_policy?: string[]; readonly config?: string; readonly write_config?: boolean; readonly address?: string;