Skip to content

Commit 27f2df8

Browse files
committed
chore: Expose proxy hostnames to csp header
1 parent d3a9d7c commit 27f2df8

File tree

6 files changed

+188
-21
lines changed

6 files changed

+188
-21
lines changed

coderd/coderd.go

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -793,7 +793,16 @@ func New(options *Options) *API {
793793
r.Get("/swagger/*", globalHTTPSwaggerHandler)
794794
}
795795

796-
r.NotFound(compressHandler(http.HandlerFunc(api.siteHandler.ServeHTTP)).ServeHTTP)
796+
// Add CSP headers to all static assets and pages. CSP headers only affect
797+
// browsers, so these don't make sense on api routes.
798+
cspMW := httpmw.CSPHeaders(func() []string {
799+
if f := api.HealthyWorkspaceProxyHosts.Load(); f != nil {
800+
// By default we do not add extra websocket connections to the CSP
801+
return []string{}
802+
}
803+
return nil
804+
})
805+
r.NotFound(cspMW(compressHandler(http.HandlerFunc(api.siteHandler.ServeHTTP))).ServeHTTP)
797806
return api
798807
}
799808

@@ -813,7 +822,12 @@ type API struct {
813822
WorkspaceClientCoordinateOverride atomic.Pointer[func(rw http.ResponseWriter) bool]
814823
TailnetCoordinator atomic.Pointer[tailnet.Coordinator]
815824
QuotaCommitter atomic.Pointer[proto.QuotaCommitter]
816-
TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore]
825+
// HealthyWorkspaceProxyHosts returns the hostnames of healthy workspace proxies
826+
// for header reasons.
827+
HealthyWorkspaceProxyHosts atomic.Pointer[func() []string]
828+
// TemplateScheduleStore is a pointer to an atomic pointer because this is
829+
// passed to another struct, and we want them all to be the same reference.
830+
TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore]
817831

818832
HTTPAuth *HTTPAuthorizer
819833

coderd/httpmw/csp.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package httpmw
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
"strings"
7+
)
8+
9+
// cspDirectives is a map of all csp fetch directives to their values.
10+
// Each directive is a set of values that is joined by a space (' ').
11+
// All directives are semi-colon separated as a single string for the csp header.
12+
type cspDirectives map[CSPFetchDirective][]string
13+
14+
func (s cspDirectives) Append(d CSPFetchDirective, values ...string) {
15+
if _, ok := s[d]; !ok {
16+
s[d] = make([]string, 0)
17+
}
18+
s[d] = append(s[d], values...)
19+
}
20+
21+
// CSPFetchDirective is the list of all constant fetch directives that
22+
// can be used/appended to.
23+
type CSPFetchDirective string
24+
25+
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"
41+
)
42+
43+
// 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.
46+
func CSPHeaders(websocketHosts func() []string) func(next http.Handler) http.Handler {
47+
return func(next http.Handler) http.Handler {
48+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
49+
// Content-Security-Policy disables loading certain content types and can prevent XSS injections.
50+
// This site helps eval your policy for syntax and other common issues: https://csp-evaluator.withgoogle.com/
51+
// If we ever want to render something like a PDF, we need to adjust "object-src"
52+
//
53+
// The list of CSP options: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/default-src
54+
cspSrcs := cspDirectives{
55+
// All omitted fetch csp srcs default to this.
56+
cspDirectiveDefaultSrc: {"'self'"},
57+
cspDirectiveConnectSrc: {"'self'"},
58+
cspDirectiveChildSrc: {"'self'"},
59+
// https://github.com/suren-atoyan/monaco-react/issues/168
60+
cspDirectiveScriptSrc: {"'self'"},
61+
cspDirectiveStyleSrc: {"'self' 'unsafe-inline'"},
62+
// data: is used by monaco editor on FE for Syntax Highlight
63+
cspDirectiveFontSrc: {"'self' data:"},
64+
cspDirectiveWorkerSrc: {"'self' blob:"},
65+
// object-src is needed to support code-server
66+
cspDirectiveObjectSrc: {"'self'"},
67+
// blob: for loading the pwa manifest for code-server
68+
cspDirectiveManifestSrc: {"'self' blob:"},
69+
cspDirectiveFrameSrc: {"'self'"},
70+
// data: for loading base64 encoded icons for generic applications.
71+
// https: allows loading images from external sources. This is not ideal
72+
// but is required for the templates page that renders readmes.
73+
// We should find a better solution in the future.
74+
cspDirectiveImgSrc: {"'self' https: data:"},
75+
cspDirectiveFormAction: {"'self'"},
76+
cspDirectiveMediaSrc: {"'self'"},
77+
// Report all violations back to the server to log
78+
cspDirectiveReportURI: {"/api/v2/csp/reports"},
79+
cspFrameAncestors: {"'none'"},
80+
81+
// Only scripts can manipulate the dom. This prevents someone from
82+
// naming themselves something like '<svg onload="alert(/cross-site-scripting/)" />'.
83+
// "require-trusted-types-for" : []string{"'script'"},
84+
}
85+
86+
// This extra connect-src addition is required to support old webkit
87+
// based browsers (Safari).
88+
// See issue: https://github.com/w3c/webappsec-csp/issues/7
89+
// Once webkit browsers support 'self' on connect-src, we can remove this.
90+
// When we remove this, the csp header can be static, as opposed to being
91+
// dynamically generated for each request.
92+
host := r.Host
93+
// It is important r.Host is not an empty string.
94+
if host != "" {
95+
// We can add both ws:// and wss:// as browsers do not let https
96+
// pages to connect to non-tls websocket connections. So this
97+
// supports both http & https webpages.
98+
cspSrcs.Append(cspDirectiveConnectSrc, fmt.Sprintf("wss://%[1]s ws://%[1]s", host))
99+
}
100+
101+
extraConnect := websocketHosts()
102+
if len(extraConnect) > 0 {
103+
for _, extraHost := range extraConnect {
104+
cspSrcs.Append(cspDirectiveConnectSrc, fmt.Sprintf("wss://%[1]s ws://%[1]s", extraHost))
105+
}
106+
}
107+
108+
var csp strings.Builder
109+
for src, vals := range cspSrcs {
110+
_, _ = fmt.Fprintf(&csp, "%s %s; ", src, strings.Join(vals, " "))
111+
}
112+
113+
w.Header().Set("Content-Security-Policy", csp.String())
114+
next.ServeHTTP(w, r)
115+
})
116+
}
117+
}

coderd/httpmw/csp_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
package httpmw_test

enterprise/coderd/coderd.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,10 @@ func New(ctx context.Context, options *Options) (*API, error) {
250250
// Force the initial loading of the cache. Do this in a go routine in case
251251
// the calls to the workspace proxies hang and this takes some time.
252252
go api.forceWorkspaceProxyHealthUpdate(ctx)
253+
254+
// Use proxy health to return the healthy workspace proxy hostnames.
255+
f := api.ProxyHealth.HealthyHostnames
256+
api.AGPL.HealthyWorkspaceProxyHosts.Store(&f)
253257
}
254258

255259
err = api.updateEntitlements(ctx)

enterprise/coderd/proxyhealth/proxyhealth.go

Lines changed: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/json"
66
"fmt"
77
"net/http"
8+
"net/url"
89
"strings"
910
"sync"
1011
"sync/atomic"
@@ -59,7 +60,9 @@ type ProxyHealth struct {
5960
logger slog.Logger
6061
client *http.Client
6162

62-
cache *atomic.Pointer[map[uuid.UUID]ProxyStatus]
63+
// Cached values for quick access to the health of proxies.
64+
cache *atomic.Pointer[map[uuid.UUID]ProxyStatus]
65+
heathyHosts *atomic.Pointer[[]string]
6366

6467
// PromMetrics
6568
healthCheckDuration prometheus.Histogram
@@ -112,6 +115,7 @@ func New(opts *Options) (*ProxyHealth, error) {
112115
logger: opts.Logger,
113116
client: client,
114117
cache: &atomic.Pointer[map[uuid.UUID]ProxyStatus]{},
118+
heathyHosts: &atomic.Pointer[[]string]{},
115119
healthCheckDuration: healthCheckDuration,
116120
healthCheckResults: healthCheckResults,
117121
}, nil
@@ -133,12 +137,25 @@ func (p *ProxyHealth) Run(ctx context.Context) {
133137
p.logger.Error(ctx, "proxy health check failed", slog.Error(err))
134138
continue
135139
}
136-
// Store the statuses in the cache.
137-
p.cache.Store(&statuses)
140+
p.storeProxyHealth(statuses)
138141
}
139142
}
140143
}
141144

145+
func (p *ProxyHealth) storeProxyHealth(statuses map[uuid.UUID]ProxyStatus) {
146+
var healthyHosts []string
147+
for _, s := range statuses {
148+
if s.Status == Healthy {
149+
healthyHosts = append(healthyHosts, s.ProxyHostname)
150+
}
151+
}
152+
153+
fmt.Println("healthyHosts", healthyHosts)
154+
// Store the statuses in the cache before any other quick values.
155+
p.cache.Store(&statuses)
156+
p.heathyHosts.Store(&healthyHosts)
157+
}
158+
142159
// ForceUpdate runs a single health check and updates the cache. If the health
143160
// check fails, the cache is not updated and an error is returned. This is useful
144161
// to trigger an update when a proxy is created or deleted.
@@ -148,8 +165,7 @@ func (p *ProxyHealth) ForceUpdate(ctx context.Context) error {
148165
return err
149166
}
150167

151-
// Store the statuses in the cache.
152-
p.cache.Store(&statuses)
168+
p.storeProxyHealth(statuses)
153169
return nil
154170
}
155171

@@ -163,15 +179,31 @@ func (p *ProxyHealth) HealthStatus() map[uuid.UUID]ProxyStatus {
163179
return *ptr
164180
}
165181

182+
// HealthyHostnames returns the hostnames of all healthy proxies.
183+
// This can be computed from HealthStatus, but is cached to avoid the
184+
// caller needing to loop over all proxies to compute this on all
185+
// static web requests.
186+
func (p *ProxyHealth) HealthyHostnames() []string {
187+
ptr := p.heathyHosts.Load()
188+
if ptr == nil {
189+
return []string{}
190+
}
191+
return *ptr
192+
}
193+
166194
type ProxyStatus struct {
167195
// ProxyStatus includes the value of the proxy at the time of checking. This is
168196
// useful to know as it helps determine if the proxy checked has different values
169197
// then the proxy in hand. AKA if the proxy was updated, and the status was for
170198
// an older proxy.
171-
Proxy database.WorkspaceProxy
172-
Status Status
173-
Report codersdk.ProxyHealthReport
174-
CheckedAt time.Time
199+
Proxy database.WorkspaceProxy
200+
// ProxyHostname is the hostname of the proxy url. This is included in the status
201+
// to make sure the proxy url is a valid URL. It also makes it easier to
202+
// escalate errors if the url.Parse errors (should never happen).
203+
ProxyHostname string
204+
Status Status
205+
Report codersdk.ProxyHealthReport
206+
CheckedAt time.Time
175207
}
176208

177209
// runOnce runs the health check for all workspace proxies. If there is an
@@ -248,7 +280,16 @@ func (p *ProxyHealth) runOnce(ctx context.Context, now time.Time) (map[uuid.UUID
248280
status.Status = Unhealthy
249281
break
250282
}
283+
u, err := url.Parse(proxy.Url)
284+
if err != nil {
285+
// This should never happen. This would mean the proxy sent
286+
// us an invalid url?
287+
status.Report.Errors = append(status.Report.Errors, fmt.Sprintf("failed to parse proxy url: %s", err.Error()))
288+
status.Status = Unhealthy
289+
break
290+
}
251291
status.Status = Healthy
292+
status.ProxyHostname = u.Hostname()
252293
case err == nil && resp.StatusCode != http.StatusOK:
253294
// Unhealthy as we did reach the proxy but it got an unexpected response.
254295
status.Status = Unhealthy

site/site.go

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -125,11 +125,6 @@ func Handler(siteFS fs.FS, binFS http.FileSystem, binHashes map[string]string) h
125125
type handler struct {
126126
fs fs.FS
127127
// htmlFiles is the text/template for all *.html files.
128-
// This is needed to support Content Security Policy headers.
129-
// Due to material UI, we are forced to use a nonce to allow inline
130-
// scripts, and that nonce is passed through a template.
131-
// We only do this for html files to reduce the amount of in memory caching
132-
// of duplicate files as `fs`.
133128
htmlFiles *htmlTemplates
134129
h http.Handler
135130
buildInfoJSON string
@@ -152,15 +147,10 @@ func (h *handler) exists(filePath string) bool {
152147
}
153148

154149
type htmlState struct {
155-
CSP cspState
156150
CSRF csrfState
157151
BuildInfo string
158152
}
159153

160-
type cspState struct {
161-
Nonce string
162-
}
163-
164154
type csrfState struct {
165155
Token string
166156
}

0 commit comments

Comments
 (0)