Skip to content

fix: do not canonicalize Sec-WebSocket-* headers in apps #5334

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Dec 7, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion coderd/workspaceapps.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,23 @@ const (
redirectURIQueryParam = "redirect_uri"
)

// nonCanonicalHeaders is a map from "canonical" headers to the actual header we
// should send to the app in the workspace. Some headers (such as the websocket
// upgrade headers from RFC 6455) are not canonical according to the HTTP/1
// spec. Golang has said that they will not add custom cases for these headers,
// so we need to do it ourselves.
//
// Some apps our customers use are sensitive to the case of these headers.
//
// https://github.com/golang/go/issues/18495
var nonCanonicalHeaders = map[string]string{
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Only these keys or every Sec-Websocket-*? If the latter one, maybe you can refactor it to the future-proof logic (Sec-Websocket-Foo, Sec-Websocket-Bar, etc.)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I considered using a regex or some custom function to do it but decided against it as the websocket spec was made in 2011 and they haven't added any new headers that require this level of massaging since. Hashmap is the fastest so I think it's a fine trade off

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair enough, feel free to ship it

"Sec-Websocket-Accept": "Sec-WebSocket-Accept",
"Sec-Websocket-Extensions": "Sec-WebSocket-Extensions",
"Sec-Websocket-Key": "Sec-WebSocket-Key",
"Sec-Websocket-Protocol": "Sec-WebSocket-Protocol",
"Sec-Websocket-Version": "Sec-WebSocket-Version",
}

func (api *API) appHost(rw http.ResponseWriter, r *http.Request) {
host := api.AppHostname
if api.AccessURL.Port() != "" {
Expand Down Expand Up @@ -708,14 +725,24 @@ func (api *API) proxyWorkspaceApplication(proxyApp proxyApplication, rw http.Res
return
}
defer release()
proxy.Transport = conn.HTTPTransport()

// This strips the session token from a workspace app request.
cookieHeaders := r.Header.Values("Cookie")[:]
r.Header.Del("Cookie")
for _, cookieHeader := range cookieHeaders {
r.Header.Add("Cookie", httpapi.StripCoderCookies(cookieHeader))
}
proxy.Transport = conn.HTTPTransport()

// Convert canonicalized headers to their non-canonicalized counterparts.
// See the comment on `nonCanonicalHeaders` for more information on why this
// is necessary.
for k, v := range r.Header {
if n, ok := nonCanonicalHeaders[k]; ok {
r.Header.Del(k)
r.Header[n] = v
}
}

// end span so we don't get long lived trace data
tracing.EndHTTPSpan(r, http.StatusOK, trace.SpanFromContext(ctx))
Expand Down
194 changes: 194 additions & 0 deletions coderd/workspaceapps_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package coderd_test

import (
"bufio"
"context"
"encoding/json"
"fmt"
Expand All @@ -16,6 +17,7 @@ import (
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"

"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/agent"
Expand Down Expand Up @@ -1024,3 +1026,195 @@ func TestAppSharing(t *testing.T) {
})
})
}

func TestWorkspaceAppsNonCanonicalHeaders(t *testing.T) {
t.Parallel()

setupNonCanonicalHeadersTest := func(t *testing.T, customAppHost ...string) (*codersdk.Client, codersdk.CreateFirstUserResponse, codersdk.Workspace, uint16) {
// Start a TCP server that manually parses the request. Golang's HTTP
// server canonicalizes all HTTP request headers it receives, so we
// can't use it to test that we forward non-canonical headers.
// #nosec
ln, err := net.Listen("tcp", ":0")
require.NoError(t, err)
go func() {
for {
c, err := ln.Accept()
if xerrors.Is(err, net.ErrClosed) {
return
}
require.NoError(t, err)

go func() {
s := bufio.NewScanner(c)

// Read request line.
assert.True(t, s.Scan())
reqLine := s.Text()
assert.True(t, strings.HasPrefix(reqLine, fmt.Sprintf("GET /?%s HTTP/1.1", proxyTestAppQuery)))

// Read headers and discard them. We collect the
// Sec-WebSocket-Key header (with a capital S) to respond
// with.
secWebSocketKey := "(none found)"
for s.Scan() {
if s.Text() == "" {
break
}

line := strings.TrimSpace(s.Text())
if strings.HasPrefix(line, "Sec-WebSocket-Key: ") {
secWebSocketKey = strings.TrimPrefix(line, "Sec-WebSocket-Key: ")
}
}

// Write response containing text/plain with the
// Sec-WebSocket-Key header.
res := fmt.Sprintf("HTTP/1.1 204 No Content\r\nSec-WebSocket-Key: %s\r\nConnection: close\r\n\r\n", secWebSocketKey)
_, err = c.Write([]byte(res))
assert.NoError(t, err)
err = c.Close()
assert.NoError(t, err)
}()
}
}()
t.Cleanup(func() {
_ = ln.Close()
})
tcpAddr, ok := ln.Addr().(*net.TCPAddr)
require.True(t, ok)

appHost := proxyTestSubdomainRaw
if len(customAppHost) > 0 {
appHost = customAppHost[0]
}

client := coderdtest.New(t, &coderdtest.Options{
AppHostname: appHost,
IncludeProvisionerDaemon: true,
AgentStatsRefreshInterval: time.Millisecond * 100,
MetricsCacheRefreshInterval: time.Millisecond * 100,
RealIPConfig: &httpmw.RealIPConfig{
TrustedOrigins: []*net.IPNet{{
IP: net.ParseIP("127.0.0.1"),
Mask: net.CIDRMask(8, 32),
}},
TrustedHeaders: []string{
"CF-Connecting-IP",
},
},
})

user := coderdtest.CreateFirstUser(t, client)

workspace := createWorkspaceWithApps(t, client, user.OrganizationID, appHost, uint16(tcpAddr.Port))

// Configure the HTTP client to not follow redirects and to route all
// requests regardless of hostname to the coderd test server.
client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}
defaultTransport, ok := http.DefaultTransport.(*http.Transport)
require.True(t, ok)
transport := defaultTransport.Clone()
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, network, client.URL.Host)
}
client.HTTPClient.Transport = transport
t.Cleanup(func() {
transport.CloseIdleConnections()
})

return client, user, workspace, uint16(tcpAddr.Port)
}

t.Run("ProxyPath", func(t *testing.T) {
t.Parallel()

client, _, workspace, _ := setupNonCanonicalHeadersTest(t)

ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()

u, err := client.URL.Parse(fmt.Sprintf("/@me/%s/apps/%s/?%s", workspace.Name, proxyTestAppNameOwner, proxyTestAppQuery))
require.NoError(t, err)

req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
require.NoError(t, err)

// Use a non-canonical header name. The S in Sec-WebSocket-Key should be
// capitalized according to the websocket spec, but Golang will
// lowercase it to match the HTTP/1 spec.
//
// Setting the header on the map directly will force the header to not
// be canonicalized on the client, but it will be canonicalized on the
// server.
secWebSocketKey := "test-dean-was-here"
req.Header["Sec-WebSocket-Key"] = []string{secWebSocketKey}

req.Header.Set(codersdk.SessionCustomHeader, client.SessionToken())
resp, err := client.HTTPClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()

// The response should be a 204 No Content with the Sec-WebSocket-Key
// header set to the value we sent.
res, err := httputil.DumpResponse(resp, true)
require.NoError(t, err)
t.Log(string(res))
require.Equal(t, http.StatusNoContent, resp.StatusCode)
require.Equal(t, secWebSocketKey, resp.Header.Get("Sec-WebSocket-Key"))
})

t.Run("Subdomain", func(t *testing.T) {
t.Parallel()

appHost := proxyTestSubdomainRaw
client, _, workspace, _ := setupNonCanonicalHeadersTest(t, appHost)

ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()

user, err := client.User(ctx, codersdk.Me)
require.NoError(t, err)

u := fmt.Sprintf(
"http://%s--%s--%s--%s%s?%s",
proxyTestAppNameOwner,
proxyTestAgentName,
workspace.Name,
user.Username,
strings.ReplaceAll(appHost, "*", ""),
proxyTestAppQuery,
)

// Re-enable the default redirect behavior.
client.HTTPClient.CheckRedirect = nil

req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
require.NoError(t, err)

// Use a non-canonical header name. The S in Sec-WebSocket-Key should be
// capitalized according to the websocket spec, but Golang will
// lowercase it to match the HTTP/1 spec.
//
// Setting the header on the map directly will force the header to not
// be canonicalized on the client, but it will be canonicalized on the
// server.
secWebSocketKey := "test-dean-was-here"
req.Header["Sec-WebSocket-Key"] = []string{secWebSocketKey}

req.Header.Set(codersdk.SessionCustomHeader, client.SessionToken())
resp, err := client.HTTPClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()

// The response should be a 204 No Content with the Sec-WebSocket-Key
// header set to the value we sent.
res, err := httputil.DumpResponse(resp, true)
require.NoError(t, err)
t.Log(string(res))
require.Equal(t, http.StatusNoContent, resp.StatusCode)
require.Equal(t, secWebSocketKey, resp.Header.Get("Sec-WebSocket-Key"))
})
}