From 99df33a57418cea2b7475b92d3e816e0c5618837 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Tue, 16 Aug 2022 12:41:18 -0500 Subject: [PATCH] fix: Strip coder cookies from app proxy requests Fixes coder/security#1. --- coderd/httpapi/cookie.go | 31 +++++++++++++++++++++++++++++++ coderd/httpapi/cookie_test.go | 35 +++++++++++++++++++++++++++++++++++ coderd/httpmw/oauth2.go | 15 +++++---------- coderd/httpmw/oauth2_test.go | 5 +++-- coderd/userauth_test.go | 4 ++-- coderd/workspaceapps.go | 6 ++++++ coderd/workspaceapps_test.go | 3 +++ codersdk/client.go | 11 +++++++++-- 8 files changed, 94 insertions(+), 16 deletions(-) create mode 100644 coderd/httpapi/cookie.go create mode 100644 coderd/httpapi/cookie_test.go diff --git a/coderd/httpapi/cookie.go b/coderd/httpapi/cookie.go new file mode 100644 index 0000000000000..7e0893c477bb1 --- /dev/null +++ b/coderd/httpapi/cookie.go @@ -0,0 +1,31 @@ +package httpapi + +import ( + "net/textproto" + "strings" + + "github.com/coder/coder/codersdk" +) + +// StripCoderCookies removes the session token from the cookie header provided. +func StripCoderCookies(header string) string { + header = textproto.TrimString(header) + cookies := []string{} + + var part string + for len(header) > 0 { // continue since we have rest + part, header, _ = strings.Cut(header, ";") + part = textproto.TrimString(part) + if part == "" { + continue + } + name, _, _ := strings.Cut(part, "=") + if name == codersdk.SessionTokenKey || + name == codersdk.OAuth2StateKey || + name == codersdk.OAuth2RedirectKey { + continue + } + cookies = append(cookies, part) + } + return strings.Join(cookies, "; ") +} diff --git a/coderd/httpapi/cookie_test.go b/coderd/httpapi/cookie_test.go new file mode 100644 index 0000000000000..546f1d2a7b30c --- /dev/null +++ b/coderd/httpapi/cookie_test.go @@ -0,0 +1,35 @@ +package httpapi_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/coderd/httpapi" +) + +func TestStripCoderCookies(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + Input string + Output string + }{{ + "testing=hello; wow=test", + "testing=hello; wow=test", + }, { + "session_token=moo; wow=test", + "wow=test", + }, { + "another_token=wow; session_token=ok", + "another_token=wow", + }, { + "session_token=ok; oauth_state=wow; oauth_redirect=/", + "", + }} { + tc := tc + t.Run(tc.Input, func(t *testing.T) { + t.Parallel() + require.Equal(t, tc.Output, httpapi.StripCoderCookies(tc.Input)) + }) + } +} diff --git a/coderd/httpmw/oauth2.go b/coderd/httpmw/oauth2.go index f9c44ecbc69bd..75f839ed76036 100644 --- a/coderd/httpmw/oauth2.go +++ b/coderd/httpmw/oauth2.go @@ -13,11 +13,6 @@ import ( "github.com/coder/coder/cryptorand" ) -const ( - oauth2StateCookieName = "oauth_state" - oauth2RedirectCookieName = "oauth_redirect" -) - type oauth2StateKey struct{} type OAuth2State struct { @@ -71,7 +66,7 @@ func ExtractOAuth2(config OAuth2Config) func(http.Handler) http.Handler { } http.SetCookie(rw, &http.Cookie{ - Name: oauth2StateCookieName, + Name: codersdk.OAuth2StateKey, Value: state, Path: "/", HttpOnly: true, @@ -80,7 +75,7 @@ func ExtractOAuth2(config OAuth2Config) func(http.Handler) http.Handler { // Redirect must always be specified, otherwise // an old redirect could apply! http.SetCookie(rw, &http.Cookie{ - Name: oauth2RedirectCookieName, + Name: codersdk.OAuth2RedirectKey, Value: r.URL.Query().Get("redirect"), Path: "/", HttpOnly: true, @@ -98,10 +93,10 @@ func ExtractOAuth2(config OAuth2Config) func(http.Handler) http.Handler { return } - stateCookie, err := r.Cookie(oauth2StateCookieName) + stateCookie, err := r.Cookie(codersdk.OAuth2StateKey) if err != nil { httpapi.Write(rw, http.StatusUnauthorized, codersdk.Response{ - Message: fmt.Sprintf("Cookie %q must be provided.", oauth2StateCookieName), + Message: fmt.Sprintf("Cookie %q must be provided.", codersdk.OAuth2StateKey), }) return } @@ -113,7 +108,7 @@ func ExtractOAuth2(config OAuth2Config) func(http.Handler) http.Handler { } var redirect string - stateRedirect, err := r.Cookie(oauth2RedirectCookieName) + stateRedirect, err := r.Cookie(codersdk.OAuth2RedirectKey) if err == nil { redirect = stateRedirect.Value } diff --git a/coderd/httpmw/oauth2_test.go b/coderd/httpmw/oauth2_test.go index 31803b7351487..771dcdd772f78 100644 --- a/coderd/httpmw/oauth2_test.go +++ b/coderd/httpmw/oauth2_test.go @@ -12,6 +12,7 @@ import ( "golang.org/x/oauth2" "github.com/coder/coder/coderd/httpmw" + "github.com/coder/coder/codersdk" ) type testOAuth2Provider struct { @@ -71,7 +72,7 @@ func TestOAuth2(t *testing.T) { t.Parallel() req := httptest.NewRequest("GET", "/?code=something&state=test", nil) req.AddCookie(&http.Cookie{ - Name: "oauth_state", + Name: codersdk.OAuth2StateKey, Value: "mismatch", }) res := httptest.NewRecorder() @@ -82,7 +83,7 @@ func TestOAuth2(t *testing.T) { t.Parallel() req := httptest.NewRequest("GET", "/?code=test&state=something", nil) req.AddCookie(&http.Cookie{ - Name: "oauth_state", + Name: codersdk.OAuth2StateKey, Value: "something", }) req.AddCookie(&http.Cookie{ diff --git a/coderd/userauth_test.go b/coderd/userauth_test.go index 6d4c6af34bd30..e0558897dfd50 100644 --- a/coderd/userauth_test.go +++ b/coderd/userauth_test.go @@ -447,7 +447,7 @@ func oauth2Callback(t *testing.T, client *codersdk.Client) *http.Response { req, err := http.NewRequest("GET", oauthURL.String(), nil) require.NoError(t, err) req.AddCookie(&http.Cookie{ - Name: "oauth_state", + Name: codersdk.OAuth2StateKey, Value: state, }) res, err := client.HTTPClient.Do(req) @@ -469,7 +469,7 @@ func oidcCallback(t *testing.T, client *codersdk.Client) *http.Response { req, err := http.NewRequest("GET", oauthURL.String(), nil) require.NoError(t, err) req.AddCookie(&http.Cookie{ - Name: "oauth_state", + Name: codersdk.OAuth2StateKey, Value: state, }) res, err := client.HTTPClient.Do(req) diff --git a/coderd/workspaceapps.go b/coderd/workspaceapps.go index 99c30eec2b7a7..859232072b038 100644 --- a/coderd/workspaceapps.go +++ b/coderd/workspaceapps.go @@ -170,6 +170,12 @@ func (api *API) workspaceAppsProxyPath(rw http.ResponseWriter, r *http.Request) } defer release() + // 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() proxy.ServeHTTP(rw, r) } diff --git a/coderd/workspaceapps_test.go b/coderd/workspaceapps_test.go index ea9b239fba6eb..76aa3351062ee 100644 --- a/coderd/workspaceapps_test.go +++ b/coderd/workspaceapps_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/google/uuid" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "cdr.dev/slog/sloggers/slogtest" @@ -27,6 +28,8 @@ func TestWorkspaceAppsProxyPath(t *testing.T) { require.NoError(t, err) server := http.Server{ Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, err := r.Cookie(codersdk.SessionTokenKey) + assert.ErrorIs(t, err, http.ErrNoCookie) w.WriteHeader(http.StatusOK) }), } diff --git a/codersdk/client.go b/codersdk/client.go index e7668a34c2843..bbeaa990da81b 100644 --- a/codersdk/client.go +++ b/codersdk/client.go @@ -15,8 +15,15 @@ import ( "nhooyr.io/websocket" ) -// SessionTokenKey represents the name of the cookie or query parameter the API key is stored in. -const SessionTokenKey = "session_token" +// These cookies are Coder-specific. If a new one is added or changed, the name +// shouldn't be likely to conflict with any user-application set cookies. +// Be sure to strip additional cookies in httpapi.StripCoder Cookies! +const ( + // SessionTokenKey represents the name of the cookie or query parameter the API key is stored in. + SessionTokenKey = "session_token" + OAuth2StateKey = "oauth_state" + OAuth2RedirectKey = "oauth_redirect" +) // New creates a Coder client for the provided URL. func New(serverURL *url.URL) *Client {