420 lines
12 KiB
Go

// Copyright (c) 2020 TypeFox GmbH. All rights reserved.
// Licensed under the GNU Affero General Public License (AGPL).
// See License-AGPL.txt in the project root for license information.
package proxy
import (
"fmt"
"io/ioutil"
"net"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/gitpod-io/gitpod/common-go/util"
"github.com/google/go-cmp/cmp"
"github.com/gorilla/mux"
)
// TestRoutes tests the routing behavior - and thus the backend - of ws-proxy. The behavior of the host should be
// completely host-agnostic, so the tests are carried out without any specfic host information
func TestRoutes(t *testing.T) {
type routerFactory = func(*RouteHandlerConfig, WorkspaceInfoProvider) *mux.Router
type testRequest struct {
method string
url string
headers map[string]string
}
type targetResponse struct {
code int
content string
}
type proxyTarget struct {
host string
path string
response *targetResponse
handler http.HandlerFunc
}
type expectedResponse struct {
code int
content string
headers map[string]string
}
type handlerTest struct {
description string
router routerFactory
req testRequest
targets []proxyTarget
response expectedResponse
}
// config which configures all possible proxy target to reside on "localhost"
theiaTestPort := uint16(1234)
theiaTestHost := fmt.Sprintf("localhost:%d", theiaTestPort)
theiaTestURL := "http://" + theiaTestHost
portTestHost := "localhost:1236"
portTestURL := "http://" + portTestHost
config := &Config{
TransportConfig: &TransportConfig{
ConnectTimeout: util.Duration(10 * time.Second),
IdleConnTimeout: util.Duration(60 * time.Second),
WebsocketIdleConnTimeout: util.Duration(5 * time.Minute),
MaxIdleConns: 100,
},
TheiaServer: &TheiaServer{
Host: theiaTestHost,
Scheme: "http",
StaticVersionPathPrefix: "/test-version.1234",
},
GitpodInstallation: &GitpodInstallation{
HostName: "gitpod.io",
Scheme: "https",
WorkspaceHostSuffix: "",
},
WorkspacePodConfig: &WorkspacePodConfig{
ServiceTemplate: theiaTestURL,
PortServiceTemplate: portTestURL,
TheiaPort: theiaTestPort,
SupervisorPort: 1235,
},
}
// some common proxy targets
content := "some content"
theiaOkResponse := proxyTarget{
host: theiaTestHost,
path: "/",
response: &targetResponse{
code: 200,
content: content,
},
}
failOnRequest := func(t *testing.T, host string, path string) proxyTarget {
return proxyTarget{
host: host,
path: path,
handler: func(w http.ResponseWriter, req *http.Request) {
t.Error("this should not be called")
},
}
}
// test table
tt := []handlerTest{
{
description: "Theia: basic GET /",
router: theiaRouter,
req: testRequest{
method: "GET",
url: "/",
},
targets: []proxyTarget{
theiaOkResponse,
},
response: expectedResponse{
code: 200,
content: content,
},
},
{
description: "Exposed port: Ensure sessions cookies are filtered",
router: portRouter,
req: testRequest{
method: "GET",
url: "/some/path/on/an/exposed/port",
headers: map[string]string{
"Cookie": "_gitpod_io_=s%3Af2da2196-4afe-46e7-97b6-00eadfb4e373.KuHVEHhTuNln8RiegerwgSsAYF0LqwV5wI18tVeUNUw; ",
"Authenticated": "yes",
},
},
targets: []proxyTarget{
{
host: portTestHost,
path: "/",
handler: func(w http.ResponseWriter, req *http.Request) {
hostnameSuffix := config.GitpodInstallation.HostName
hostnameSuffix = strings.ReplaceAll(hostnameSuffix, " ", "_")
hostnameSuffix = strings.ReplaceAll(hostnameSuffix, "-", "_")
hostnameSuffix = strings.ReplaceAll(hostnameSuffix, ".", "_")
hostnameSuffix = strings.ToLower(hostnameSuffix)
hostnameSuffix = fmt.Sprintf("_%s_", hostnameSuffix)
for _, cookie := range req.Cookies() {
if strings.HasSuffix(cookie.Name, "_port_auth_") || strings.HasSuffix(cookie.Name, hostnameSuffix) {
t.Errorf("requests contained cookie which should have been filtered by name: %s", cookie.Name)
w.WriteHeader(404)
return
}
}
w.WriteHeader(200)
},
},
},
response: expectedResponse{
code: 200,
},
},
{
description: "Theia: cors preflight GET /",
router: theiaRouter,
req: testRequest{
method: "OPTIONS",
url: "/",
headers: map[string]string{
"Origin": config.GitpodInstallation.HostName,
"Access-Control-Request-Method": "OPTIONS",
},
},
targets: []proxyTarget{
failOnRequest(t, theiaTestHost, "/"),
},
response: expectedResponse{
code: 200,
headers: map[string]string{
"Origin": config.GitpodInstallation.HostName,
"Access-Control-Allow-Methods": "GET,POST,OPTIONS",
},
},
},
{
description: "Exposed port: Websocket support does not hinder regular requests",
router: portRouter,
req: testRequest{
method: "GET",
url: "/some/path",
headers: map[string]string{"Authenticated": "yes"},
},
targets: []proxyTarget{
{
host: portTestHost,
path: "/some/path",
response: &targetResponse{
code: 200,
content: content,
},
},
},
response: expectedResponse{
code: 200,
content: content,
},
},
{
description: "Unauthenticated supervisor API (supervisor status)",
router: theiaRouter,
req: testRequest{method: "GET", url: "/_supervisor/v1/status/supervisor"},
targets: []proxyTarget{theiaOkResponse},
response: expectedResponse{
code: 200,
},
},
{
description: "Unauthenticated supervisor API (IDE status)",
router: theiaRouter,
req: testRequest{method: "GET", url: "/_supervisor/v1/status/ide"},
targets: []proxyTarget{theiaOkResponse},
response: expectedResponse{
code: 200,
},
},
{
description: "Unauthenticated req against authenticated supervisor API",
router: theiaRouter,
req: testRequest{method: "GET", url: "/_supervisor/v1/status/backup"},
targets: []proxyTarget{theiaOkResponse},
response: expectedResponse{
code: 401,
},
},
{
description: "Authenticated req against authenticated supervisor API",
router: theiaRouter,
req: testRequest{method: "GET", url: "/_supervisor/v1/status/backup", headers: map[string]string{"Authenticated": "yes"}},
targets: []proxyTarget{theiaOkResponse},
response: expectedResponse{
code: 200,
},
},
}
// execute each proxy test
for _, tc := range tt {
t.Run(tc.description, func(t *testing.T) {
// setup fake proxy target(s)
var wg sync.WaitGroup
proxyTargetHandler := http.NewServeMux()
var netListeners []net.Listener
for _, target := range tc.targets {
wg.Add(1)
tResponse := target.response
if tResponse != nil {
proxyTargetHandler.HandleFunc(target.path, func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(tResponse.code)
if tResponse.content != "" {
_, err := w.Write([]byte(tResponse.content))
if err != nil {
t.Fatal("error writing result to response")
}
}
})
} else if target.handler != nil {
proxyTargetHandler.HandleFunc(target.path, target.handler)
}
srv := &http.Server{Addr: target.host, Handler: proxyTargetHandler}
// TODO ignore err until we can reliably filter out Accept errors provoked by l.Close below
//nolint:errcheck
l, _ := net.Listen("tcp", target.host)
// if err != nil {
// t.Fatalf("error setting up fake proxy target: %w", err)
// }
netListeners = append(netListeners, l)
go func(t *testing.T, host string) {
wg.Done()
//nolint:errcheck
srv.Serve(l)
}(t, target.host)
}
wg.Wait()
// setup test handler
handlerConfig, err := NewRouteHandlerConfig(config)
handlerConfig.WorkspaceAuthHandler = func(h http.Handler) http.Handler {
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
header, ok := req.Header["Authenticated"]
if !ok || len(header) == 0 || header[0] != "yes" {
http.Error(resp, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
}
h.ServeHTTP(resp, req)
})
}
if err != nil {
t.Fatalf("error while creating RouteHandlerConfig: %s", err.Error())
}
r := tc.router(handlerConfig, nil)
// create artificial request
req, err := http.NewRequest(tc.req.method, tc.req.url, nil)
if err != nil {
t.Fatal("error sending test request: %s" + err.Error())
}
for k, v := range tc.req.headers {
req.Header.Add(k, v)
}
// "send" artificial request
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
// check result
if rr.Code != tc.response.code {
t.Errorf("expected code %d, got %d!", tc.response.code, rr.Code)
}
if tc.response.content != "" {
body, err := ioutil.ReadAll(rr.Body)
if err != nil {
t.Fatalf("error reading body from fake response: %s", err.Error())
}
actualContent := string(body)
if actualContent != tc.response.content {
t.Errorf("expected content '%s', got '%s'!", tc.response.content, actualContent)
}
}
for _, l := range netListeners {
if err := l.Close(); err != nil {
t.Fatalf("error shutting down fake proxy target: %s", err.Error())
}
}
})
}
}
func theiaRouter(handlerConfig *RouteHandlerConfig, infoProvider WorkspaceInfoProvider) *mux.Router {
r := mux.NewRouter()
installTheiaRoutes(r, handlerConfig, infoProvider)
return r
}
func portRouter(handlerConfig *RouteHandlerConfig, infoProvider WorkspaceInfoProvider) *mux.Router {
r := mux.NewRouter()
installWorkspacePortRoutes(r, handlerConfig)
return r
}
type fakeWsInfoProvider struct {
infos []WorkspaceInfo
}
// GetWsInfoByID returns the workspace for the given ID
func (p *fakeWsInfoProvider) WorkspaceInfo(workspaceID string) *WorkspaceInfo {
for _, nfo := range p.infos {
if nfo.WorkspaceID == workspaceID {
return &nfo
}
}
return nil
}
// WorkspaceCoords returns the workspace coords for a public port
func (p *fakeWsInfoProvider) WorkspaceCoords(wsProxyPort string) *WorkspaceCoords {
for _, info := range p.infos {
if info.IDEPublicPort == wsProxyPort {
return &WorkspaceCoords{
ID: info.WorkspaceID,
Port: "",
}
}
for _, portInfo := range info.Ports {
if portInfo.PublicPort == wsProxyPort {
return &WorkspaceCoords{
ID: info.WorkspaceID,
Port: strconv.Itoa(int(portInfo.Port)),
}
}
}
}
return nil
}
func TestRemoveSensitiveCookies(t *testing.T) {
var (
domain = "test-domain.com"
sessionCookie = &http.Cookie{Domain: domain, Name: "_test_domain_com_", Value: "fobar"}
portAuthCookie = &http.Cookie{Domain: domain, Name: "_test_domain_com_ws_77f6b236_3456_4b88_8284_81ca543a9d65_port_auth_", Value: "some-token"}
ownerCookie = &http.Cookie{Domain: domain, Name: "_test_domain_com_ws_77f6b236_3456_4b88_8284_81ca543a9d65_owner_", Value: "some-other-token"}
miscCookie = &http.Cookie{Domain: domain, Name: "some-other-cookie", Value: "I like cookies"}
)
tests := []struct {
Name string
Input []*http.Cookie
Expected []*http.Cookie
}{
{"no cookies", []*http.Cookie{}, []*http.Cookie{}},
{"session cookie", []*http.Cookie{sessionCookie, miscCookie}, []*http.Cookie{miscCookie}},
{"portAuth cookie", []*http.Cookie{portAuthCookie, miscCookie}, []*http.Cookie{miscCookie}},
{"owner cookie", []*http.Cookie{ownerCookie, miscCookie}, []*http.Cookie{miscCookie}},
{"misc cookie", []*http.Cookie{miscCookie}, []*http.Cookie{miscCookie}},
}
for _, test := range tests {
t.Run(test.Name, func(t *testing.T) {
res := removeSensitiveCookies(test.Input, domain)
if diff := cmp.Diff(test.Expected, res); diff != "" {
t.Errorf("unexpected result (-want +got):\n%s", diff)
}
})
}
}