Skip to content

Commit 0cd14d3

Browse files
committed
feat: add csp headers for embedded apps
1 parent 068f9a0 commit 0cd14d3

File tree

7 files changed

+178
-57
lines changed

7 files changed

+178
-57
lines changed

coderd/coderd.go

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,11 @@ import (
8585
"github.com/coder/coder/v2/coderd/updatecheck"
8686
"github.com/coder/coder/v2/coderd/util/slice"
8787
"github.com/coder/coder/v2/coderd/workspaceapps"
88+
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
8889
"github.com/coder/coder/v2/coderd/workspacestats"
8990
"github.com/coder/coder/v2/codersdk"
9091
"github.com/coder/coder/v2/codersdk/healthsdk"
92+
"github.com/coder/coder/v2/enterprise/coderd/proxyhealth"
9193
"github.com/coder/coder/v2/provisionerd/proto"
9294
"github.com/coder/coder/v2/provisionersdk"
9395
"github.com/coder/coder/v2/site"
@@ -1534,16 +1536,27 @@ func New(options *Options) *API {
15341536
// browsers, so these don't make sense on api routes.
15351537
cspMW := httpmw.CSPHeaders(
15361538
api.Experiments,
1537-
options.Telemetry.Enabled(), func() []string {
1539+
options.Telemetry.Enabled(), func() []*proxyhealth.ProxyHost {
15381540
if api.DeploymentValues.Dangerous.AllowAllCors {
1539-
// In this mode, allow all external requests
1540-
return []string{"*"}
1541+
// In this mode, allow all external requests.
1542+
return []*proxyhealth.ProxyHost{
1543+
{
1544+
Host: "*",
1545+
AppHost: "*",
1546+
},
1547+
}
1548+
}
1549+
// Always add the primary, since the app host may be on a sub-domain.
1550+
proxies := []*proxyhealth.ProxyHost{
1551+
{
1552+
Host: api.AccessURL.Host,
1553+
AppHost: appurl.ConvertAppHostForCSP(api.AccessURL.String(), api.AppHostname),
1554+
},
15411555
}
15421556
if f := api.WorkspaceProxyHostsFn.Load(); f != nil {
1543-
return (*f)()
1557+
proxies = append(proxies, (*f)()...)
15441558
}
1545-
// By default we do not add extra websocket connections to the CSP
1546-
return []string{}
1559+
return proxies
15471560
}, additionalCSPHeaders)
15481561

15491562
// Static file handler must be wrapped with HSTS handler if the
@@ -1582,7 +1595,7 @@ type API struct {
15821595
AppearanceFetcher atomic.Pointer[appearance.Fetcher]
15831596
// WorkspaceProxyHostsFn returns the hosts of healthy workspace proxies
15841597
// for header reasons.
1585-
WorkspaceProxyHostsFn atomic.Pointer[func() []string]
1598+
WorkspaceProxyHostsFn atomic.Pointer[func() []*proxyhealth.ProxyHost]
15861599
// TemplateScheduleStore is a pointer to an atomic pointer because this is
15871600
// passed to another struct, and we want them all to be the same reference.
15881601
TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore]

coderd/httpmw/csp.go

Lines changed: 20 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"strings"
77

88
"github.com/coder/coder/v2/codersdk"
9+
"github.com/coder/coder/v2/enterprise/coderd/proxyhealth"
910
)
1011

1112
// cspDirectives is a map of all csp fetch directives to their values.
@@ -47,18 +48,18 @@ const (
4748
// for coderd.
4849
//
4950
// Arguments:
50-
// - websocketHosts: a function that returns a list of supported external websocket hosts.
51-
// This is to support the terminal connecting to a workspace proxy.
52-
// The origin of the terminal request does not match the url of the proxy,
53-
// so the CSP list of allowed hosts must be dynamic and match the current
54-
// available proxy urls.
51+
// - proxyHosts: a function that returns a list of supported proxy hosts
52+
// (including the primary). This is to support the terminal connecting to a
53+
// workspace proxy and for embedding apps in an iframe. The origin of the
54+
// requests do not match the url of the proxy, so the CSP list of allowed
55+
// hosts must be dynamic and match the current available proxy urls.
5556
// - staticAdditions: a map of CSP directives to append to the default CSP headers.
5657
// Used to allow specific static additions to the CSP headers. Allows some niche
5758
// use cases, such as embedding Coder in an iframe.
5859
// Example: https://github.com/coder/coder/issues/15118
5960
//
6061
//nolint:revive
61-
func CSPHeaders(experiments codersdk.Experiments, telemetry bool, websocketHosts func() []string, staticAdditions map[CSPFetchDirective][]string) func(next http.Handler) http.Handler {
62+
func CSPHeaders(experiments codersdk.Experiments, telemetry bool, proxyHosts func() []*proxyhealth.ProxyHost, staticAdditions map[CSPFetchDirective][]string) func(next http.Handler) http.Handler {
6263
return func(next http.Handler) http.Handler {
6364
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
6465
// Content-Security-Policy disables loading certain content types and can prevent XSS injections.
@@ -97,15 +98,6 @@ func CSPHeaders(experiments codersdk.Experiments, telemetry bool, websocketHosts
9798
// "require-trusted-types-for" : []string{"'script'"},
9899
}
99100

100-
if experiments.Enabled(codersdk.ExperimentAITasks) {
101-
// AI tasks use iframe embeds of local apps.
102-
// TODO: Handle region domains too, not just path based apps
103-
cspSrcs.Append(CSPFrameAncestors, `'self'`)
104-
cspSrcs.Append(CSPFrameSource, `'self'`)
105-
} else {
106-
cspSrcs.Append(CSPFrameAncestors, `'none'`)
107-
}
108-
109101
if telemetry {
110102
// If telemetry is enabled, we report to coder.com.
111103
cspSrcs.Append(CSPDirectiveConnectSrc, "https://coder.com")
@@ -126,19 +118,26 @@ func CSPHeaders(experiments codersdk.Experiments, telemetry bool, websocketHosts
126118
cspSrcs.Append(CSPDirectiveConnectSrc, fmt.Sprintf("wss://%[1]s ws://%[1]s", host))
127119
}
128120

129-
// The terminal requires a websocket connection to the workspace proxy.
130-
// Make sure we allow this connection to healthy proxies.
131-
extraConnect := websocketHosts()
121+
// The terminal and iframed apps can use workspace proxies (which includes
122+
// the primary). Make sure we allow connections to healthy proxies.
123+
extraConnect := proxyHosts()
132124
if len(extraConnect) > 0 {
133125
for _, extraHost := range extraConnect {
134-
if extraHost == "*" {
126+
// Allow embedding the app host.
127+
if experiments.Enabled(codersdk.ExperimentAITasks) {
128+
cspSrcs.Append(CSPDirectiveFrameSrc, extraHost.AppHost)
129+
}
130+
if extraHost.Host == "*" {
135131
// '*' means all
136132
cspSrcs.Append(CSPDirectiveConnectSrc, "*")
137133
continue
138134
}
139-
cspSrcs.Append(CSPDirectiveConnectSrc, fmt.Sprintf("wss://%[1]s ws://%[1]s", extraHost))
135+
// Avoid double-adding r.Host.
136+
if extraHost.Host != r.Host {
137+
cspSrcs.Append(CSPDirectiveConnectSrc, fmt.Sprintf("wss://%[1]s ws://%[1]s", extraHost.Host))
138+
}
140139
// We also require this to make http/https requests to the workspace proxy for latency checking.
141-
cspSrcs.Append(CSPDirectiveConnectSrc, fmt.Sprintf("https://%[1]s http://%[1]s", extraHost))
140+
cspSrcs.Append(CSPDirectiveConnectSrc, fmt.Sprintf("https://%[1]s http://%[1]s", extraHost.Host))
142141
}
143142
}
144143

coderd/httpmw/csp_test.go

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,59 @@
11
package httpmw_test
22

33
import (
4-
"fmt"
54
"net/http"
65
"net/http/httptest"
6+
"strings"
77
"testing"
88

99
"github.com/stretchr/testify/require"
1010

1111
"github.com/coder/coder/v2/coderd/httpmw"
1212
"github.com/coder/coder/v2/codersdk"
13+
"github.com/coder/coder/v2/enterprise/coderd/proxyhealth"
1314
)
1415

15-
func TestCSPConnect(t *testing.T) {
16+
func TestCSP(t *testing.T) {
1617
t.Parallel()
1718

18-
expected := []string{"example.com", "coder.com"}
19+
proxyHosts := []*proxyhealth.ProxyHost{
20+
{
21+
Host: "test.com",
22+
AppHost: "*.test.com",
23+
},
24+
{
25+
Host: "coder.com",
26+
AppHost: "*.coder.com",
27+
},
28+
{
29+
// Host is not added because it duplicates the host header.
30+
Host: "example.com",
31+
AppHost: "*.coder2.com",
32+
},
33+
}
1934
expectedMedia := []string{"media.com", "media2.com"}
2035

36+
expected := []string{
37+
"frame-src 'self' *.test.com *.coder.com *.coder2.com",
38+
"media-src 'self' media.com media2.com",
39+
strings.Join([]string{
40+
"connect-src", "'self'",
41+
// Added from host header.
42+
"wss://example.com", "ws://example.com",
43+
// Added via proxy hosts.
44+
"wss://test.com", "ws://test.com", "https://test.com", "http://test.com",
45+
"wss://coder.com", "ws://coder.com", "https://coder.com", "http://coder.com",
46+
}, " "),
47+
}
48+
49+
// When the host is empty, it uses example.com.
2150
r := httptest.NewRequest(http.MethodGet, "/", nil)
2251
rw := httptest.NewRecorder()
2352

24-
httpmw.CSPHeaders(codersdk.Experiments{}, false, func() []string {
25-
return expected
53+
httpmw.CSPHeaders(codersdk.Experiments{
54+
codersdk.ExperimentAITasks,
55+
}, false, func() []*proxyhealth.ProxyHost {
56+
return proxyHosts
2657
}, map[httpmw.CSPFetchDirective][]string{
2758
httpmw.CSPDirectiveMediaSrc: expectedMedia,
2859
})(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
@@ -31,10 +62,6 @@ func TestCSPConnect(t *testing.T) {
3162

3263
require.NotEmpty(t, rw.Header().Get("Content-Security-Policy"), "Content-Security-Policy header should not be empty")
3364
for _, e := range expected {
34-
require.Containsf(t, rw.Header().Get("Content-Security-Policy"), fmt.Sprintf("ws://%s", e), "Content-Security-Policy header should contain ws://%s", e)
35-
require.Containsf(t, rw.Header().Get("Content-Security-Policy"), fmt.Sprintf("wss://%s", e), "Content-Security-Policy header should contain wss://%s", e)
36-
}
37-
for _, e := range expectedMedia {
38-
require.Containsf(t, rw.Header().Get("Content-Security-Policy"), e, "Content-Security-Policy header should contain %s", e)
65+
require.Contains(t, rw.Header().Get("Content-Security-Policy"), e)
3966
}
4067
}

coderd/workspaceapps/appurl/appurl.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,3 +289,23 @@ func ExecuteHostnamePattern(pattern *regexp.Regexp, hostname string) (string, bo
289289

290290
return matches[1], true
291291
}
292+
293+
// ConvertAppHostForCSP converts the wildcard host to a format accepted by CSP.
294+
// For example *--apps.coder.com must become *.coder.com. If there is no
295+
// wildcard host, or it cannot be converted, return the base host.
296+
func ConvertAppHostForCSP(host, wildcard string) string {
297+
if wildcard == "" {
298+
return host
299+
}
300+
parts := strings.Split(wildcard, ".")
301+
for i, part := range parts {
302+
if strings.Contains(part, "*") {
303+
// The wildcard can only be in the first section.
304+
if i != 0 {
305+
return host
306+
}
307+
parts[i] = "*"
308+
}
309+
}
310+
return strings.Join(parts, ".")
311+
}

coderd/workspaceapps/appurl/appurl_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,3 +410,59 @@ func TestCompileHostnamePattern(t *testing.T) {
410410
})
411411
}
412412
}
413+
414+
func TestConvertAppURLForCSP(t *testing.T) {
415+
t.Parallel()
416+
417+
testCases := []struct {
418+
name string
419+
host string
420+
wildcard string
421+
expected string
422+
}{
423+
{
424+
name: "Empty",
425+
host: "example.com",
426+
wildcard: "",
427+
expected: "example.com",
428+
},
429+
{
430+
name: "NoAsterisk",
431+
host: "example.com",
432+
wildcard: "coder.com",
433+
expected: "coder.com",
434+
},
435+
{
436+
name: "Asterisk",
437+
host: "example.com",
438+
wildcard: "*.coder.com",
439+
expected: "*.coder.com",
440+
},
441+
{
442+
name: "FirstPrefix",
443+
host: "example.com",
444+
wildcard: "*--apps.coder.com",
445+
expected: "*.coder.com",
446+
},
447+
{
448+
name: "FirstSuffix",
449+
host: "example.com",
450+
wildcard: "apps--*.coder.com",
451+
expected: "*.coder.com",
452+
},
453+
{
454+
name: "Middle",
455+
host: "example.com",
456+
wildcard: "apps.*.com",
457+
expected: "example.com",
458+
},
459+
}
460+
461+
for _, c := range testCases {
462+
c := c
463+
t.Run(c.name, func(t *testing.T) {
464+
t.Parallel()
465+
require.Equal(t, c.expected, appurl.ConvertAppHostForCSP(c.host, c.wildcard))
466+
})
467+
}
468+
}

enterprise/coderd/proxyhealth/proxyhealth.go

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"github.com/coder/coder/v2/coderd/database"
2222
"github.com/coder/coder/v2/coderd/database/dbauthz"
2323
"github.com/coder/coder/v2/coderd/prometheusmetrics"
24+
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
2425
"github.com/coder/coder/v2/codersdk"
2526
)
2627

@@ -63,7 +64,7 @@ type ProxyHealth struct {
6364

6465
// Cached values for quick access to the health of proxies.
6566
cache *atomic.Pointer[map[uuid.UUID]ProxyStatus]
66-
proxyHosts *atomic.Pointer[[]string]
67+
proxyHosts *atomic.Pointer[[]*ProxyHost]
6768

6869
// PromMetrics
6970
healthCheckDuration prometheus.Histogram
@@ -116,7 +117,7 @@ func New(opts *Options) (*ProxyHealth, error) {
116117
logger: opts.Logger,
117118
client: client,
118119
cache: &atomic.Pointer[map[uuid.UUID]ProxyStatus]{},
119-
proxyHosts: &atomic.Pointer[[]string]{},
120+
proxyHosts: &atomic.Pointer[[]*ProxyHost]{},
120121
healthCheckDuration: healthCheckDuration,
121122
healthCheckResults: healthCheckResults,
122123
}, nil
@@ -144,9 +145,9 @@ func (p *ProxyHealth) Run(ctx context.Context) {
144145
}
145146

146147
func (p *ProxyHealth) storeProxyHealth(statuses map[uuid.UUID]ProxyStatus) {
147-
var proxyHosts []string
148+
var proxyHosts []*ProxyHost
148149
for _, s := range statuses {
149-
if s.ProxyHost != "" {
150+
if s.ProxyHost != nil {
150151
proxyHosts = append(proxyHosts, s.ProxyHost)
151152
}
152153
}
@@ -184,29 +185,35 @@ func (p *ProxyHealth) HealthStatus() map[uuid.UUID]ProxyStatus {
184185
return *ptr
185186
}
186187

188+
type ProxyHost struct {
189+
// Host is the root host of the proxy.
190+
Host string
191+
// AppHost is the wildcard host where apps are hosted.
192+
AppHost string
193+
}
194+
187195
type ProxyStatus struct {
188196
// ProxyStatus includes the value of the proxy at the time of checking. This is
189197
// useful to know as it helps determine if the proxy checked has different values
190198
// then the proxy in hand. AKA if the proxy was updated, and the status was for
191199
// an older proxy.
192200
Proxy database.WorkspaceProxy
193-
// ProxyHost is the host:port of the proxy url. This is included in the status
194-
// to make sure the proxy url is a valid URL. It also makes it easier to
195-
// escalate errors if the url.Parse errors (should never happen).
196-
ProxyHost string
201+
// ProxyHost is the base host:port and app host of the proxy. This is included
202+
// in the status to make sure the proxy url is a valid URL. It also makes it
203+
// easier to escalate errors if the url.Parse errors (should never happen).
204+
ProxyHost *ProxyHost
197205
Status Status
198206
Report codersdk.ProxyHealthReport
199207
CheckedAt time.Time
200208
}
201209

202-
// ProxyHosts returns the host:port of all healthy proxies.
203-
// This can be computed from HealthStatus, but is cached to avoid the
204-
// caller needing to loop over all proxies to compute this on all
205-
// static web requests.
206-
func (p *ProxyHealth) ProxyHosts() []string {
210+
// ProxyHosts returns the host:port and wildcard host of all healthy proxies.
211+
// This can be computed from HealthStatus, but is cached to avoid the caller
212+
// needing to loop over all proxies to compute this on all static web requests.
213+
func (p *ProxyHealth) ProxyHosts() []*ProxyHost {
207214
ptr := p.proxyHosts.Load()
208215
if ptr == nil {
209-
return []string{}
216+
return []*ProxyHost{}
210217
}
211218
return *ptr
212219
}
@@ -350,7 +357,10 @@ func (p *ProxyHealth) runOnce(ctx context.Context, now time.Time) (map[uuid.UUID
350357
status.Report.Errors = append(status.Report.Errors, fmt.Sprintf("failed to parse proxy url: %s", err.Error()))
351358
status.Status = Unhealthy
352359
}
353-
status.ProxyHost = u.Host
360+
status.ProxyHost = &ProxyHost{
361+
Host: u.Host,
362+
AppHost: appurl.ConvertAppHostForCSP(u.Host, proxy.WildcardHostname),
363+
}
354364

355365
// Set the prometheus metric correctly.
356366
switch status.Status {

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy