mirror of
https://github.com/coder/coder.git
synced 2025-07-15 22:20:27 +00:00
chore: Dynamic CSP connect-src to support terminals connecting to workspace proxies (#7352)
* chore: Expose proxy hostnames to csp header
This commit is contained in:
@ -793,7 +793,16 @@ func New(options *Options) *API {
|
||||
r.Get("/swagger/*", globalHTTPSwaggerHandler)
|
||||
}
|
||||
|
||||
r.NotFound(compressHandler(http.HandlerFunc(api.siteHandler.ServeHTTP)).ServeHTTP)
|
||||
// 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(func() []string {
|
||||
if f := api.WorkspaceProxyHostsFn.Load(); f != nil {
|
||||
return (*f)()
|
||||
}
|
||||
// By default we do not add extra websocket connections to the CSP
|
||||
return []string{}
|
||||
})
|
||||
r.NotFound(cspMW(compressHandler(http.HandlerFunc(api.siteHandler.ServeHTTP))).ServeHTTP)
|
||||
return api
|
||||
}
|
||||
|
||||
@ -813,7 +822,12 @@ type API struct {
|
||||
WorkspaceClientCoordinateOverride atomic.Pointer[func(rw http.ResponseWriter) bool]
|
||||
TailnetCoordinator atomic.Pointer[tailnet.Coordinator]
|
||||
QuotaCommitter atomic.Pointer[proto.QuotaCommitter]
|
||||
TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore]
|
||||
// WorkspaceProxyHostsFn returns the hosts of healthy workspace proxies
|
||||
// for header reasons.
|
||||
WorkspaceProxyHostsFn atomic.Pointer[func() []string]
|
||||
// TemplateScheduleStore is a pointer to an atomic pointer because this is
|
||||
// passed to another struct, and we want them all to be the same reference.
|
||||
TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore]
|
||||
|
||||
HTTPAuth *HTTPAuthorizer
|
||||
|
||||
|
119
coderd/httpmw/csp.go
Normal file
119
coderd/httpmw/csp.go
Normal file
@ -0,0 +1,119 @@
|
||||
package httpmw
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// cspDirectives is a map of all csp fetch directives to their values.
|
||||
// Each directive is a set of values that is joined by a space (' ').
|
||||
// All directives are semi-colon separated as a single string for the csp header.
|
||||
type cspDirectives map[CSPFetchDirective][]string
|
||||
|
||||
func (s cspDirectives) Append(d CSPFetchDirective, values ...string) {
|
||||
if _, ok := s[d]; !ok {
|
||||
s[d] = make([]string, 0)
|
||||
}
|
||||
s[d] = append(s[d], values...)
|
||||
}
|
||||
|
||||
// CSPFetchDirective is the list of all constant fetch directives that
|
||||
// can be used/appended to.
|
||||
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"
|
||||
)
|
||||
|
||||
// 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.
|
||||
func CSPHeaders(websocketHosts func() []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.
|
||||
// This site helps eval your policy for syntax and other common issues: https://csp-evaluator.withgoogle.com/
|
||||
// If we ever want to render something like a PDF, we need to adjust "object-src"
|
||||
//
|
||||
// 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'"},
|
||||
// https://github.com/suren-atoyan/monaco-react/issues/168
|
||||
cspDirectiveScriptSrc: {"'self'"},
|
||||
cspDirectiveStyleSrc: {"'self' 'unsafe-inline'"},
|
||||
// data: is used by monaco editor on FE for Syntax Highlight
|
||||
cspDirectiveFontSrc: {"'self' data:"},
|
||||
cspDirectiveWorkerSrc: {"'self' blob:"},
|
||||
// object-src is needed to support code-server
|
||||
cspDirectiveObjectSrc: {"'self'"},
|
||||
// blob: for loading the pwa manifest for code-server
|
||||
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'"},
|
||||
// Report all violations back to the server to log
|
||||
cspDirectiveReportURI: {"/api/v2/csp/reports"},
|
||||
cspFrameAncestors: {"'none'"},
|
||||
|
||||
// Only scripts can manipulate the dom. This prevents someone from
|
||||
// naming themselves something like '<svg onload="alert(/cross-site-scripting/)" />'.
|
||||
// "require-trusted-types-for" : []string{"'script'"},
|
||||
}
|
||||
|
||||
// This extra connect-src addition is required to support old webkit
|
||||
// based browsers (Safari).
|
||||
// See issue: https://github.com/w3c/webappsec-csp/issues/7
|
||||
// Once webkit browsers support 'self' on connect-src, we can remove this.
|
||||
// When we remove this, the csp header can be static, as opposed to being
|
||||
// dynamically generated for each request.
|
||||
host := r.Host
|
||||
// It is important r.Host is not an empty string.
|
||||
if host != "" {
|
||||
// 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))
|
||||
}
|
||||
|
||||
// The terminal requires a websocket connection to the workspace proxy.
|
||||
// Make sure we allow this connection to healthy proxies.
|
||||
extraConnect := websocketHosts()
|
||||
if len(extraConnect) > 0 {
|
||||
for _, extraHost := range extraConnect {
|
||||
cspSrcs.Append(cspDirectiveConnectSrc, fmt.Sprintf("wss://%[1]s ws://%[1]s", extraHost))
|
||||
}
|
||||
}
|
||||
|
||||
var csp strings.Builder
|
||||
for src, vals := range cspSrcs {
|
||||
_, _ = fmt.Fprintf(&csp, "%s %s; ", src, strings.Join(vals, " "))
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Security-Policy", csp.String())
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
33
coderd/httpmw/csp_test.go
Normal file
33
coderd/httpmw/csp_test.go
Normal file
@ -0,0 +1,33 @@
|
||||
package httpmw_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/coderd/httpmw"
|
||||
)
|
||||
|
||||
func TestCSPConnect(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
expected := []string{"example.com", "coder.com"}
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
httpmw.CSPHeaders(func() []string {
|
||||
return expected
|
||||
})(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
})).ServeHTTP(rw, r)
|
||||
|
||||
require.NotEmpty(t, rw.Header().Get("Content-Security-Policy"), "Content-Security-Policy header should not be empty")
|
||||
for _, e := range expected {
|
||||
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)
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user