Path: blob/main/components/ws-proxy/pkg/proxy/routes_test.go
2500 views
// Copyright (c) 2020 Gitpod GmbH. All rights reserved.1// Licensed under the GNU Affero General Public License (AGPL).2// See License.AGPL.txt in the project root for license information.34package proxy56import (7"context"8"crypto/rand"9"crypto/rsa"10"crypto/x509"11"encoding/json"12"encoding/pem"13"fmt"14"io"15"net"16"net/http"17"net/http/httptest"18"strconv"19"strings"20"testing"21"time"2223"github.com/gitpod-io/golang-crypto/ssh"24"github.com/google/go-cmp/cmp"25"github.com/sirupsen/logrus"2627"github.com/gitpod-io/gitpod/common-go/log"28"github.com/gitpod-io/gitpod/common-go/util"29server_lib "github.com/gitpod-io/gitpod/server/go/pkg/lib"30"github.com/gitpod-io/gitpod/ws-manager/api"31"github.com/gitpod-io/gitpod/ws-proxy/pkg/common"32"github.com/gitpod-io/gitpod/ws-proxy/pkg/sshproxy"33)3435const (36hostBasedHeader = "x-host-header"37wsHostSuffix = ".test-domain.com"38wsHostNameRegex = "\\.test-domain\\.com"39)4041var (42debugWorkspaceURL = "https://debug-amaranth-smelt-9ba20cc1.test-domain.com/"43workspaces = []common.WorkspaceInfo{44{45IDEImage: "gitpod-io/ide:latest",46SupervisorImage: "gitpod-io/supervisor:latest",47Auth: &api.WorkspaceAuthentication{48Admission: api.AdmissionLevel_ADMIT_OWNER_ONLY,49OwnerToken: "owner-token",50},51IDEPublicPort: "23000",52InstanceID: "1943c611-a014-4f4d-bf5d-14ccf0123c60",53Ports: []*api.PortSpec{54{Port: 28080, Url: "https://28080-amaranth-smelt-9ba20cc1.test-domain.com/", Visibility: api.PortVisibility_PORT_VISIBILITY_PUBLIC},55},56URL: "https://amaranth-smelt-9ba20cc1.test-domain.com/",57WorkspaceID: "amaranth-smelt-9ba20cc1",58},59}6061ideServerHost = "localhost:20000"62workspacePort = uint16(20001)63supervisorPort = uint16(20002)64workspaceDebugPort = uint16(20004)65supervisorDebugPort = uint16(20005)66debugWorkspaceProxyPort = uint16(20006)67workspaceHost = fmt.Sprintf("localhost:%d", workspacePort)68portServeHost = fmt.Sprintf("localhost:%d", workspaces[0].Ports[0].Port)69blobServeHost = "localhost:20003"7071config = Config{72TransportConfig: &TransportConfig{73ConnectTimeout: util.Duration(10 * time.Second),74IdleConnTimeout: util.Duration(60 * time.Second),75MaxIdleConns: 0,76MaxIdleConnsPerHost: 100,77},78GitpodInstallation: &GitpodInstallation{79HostName: "test-domain.com",80Scheme: "https",81WorkspaceHostSuffix: ".ws.test-domain.com",82},83BlobServer: &BlobServerConfig{84Host: blobServeHost,85Scheme: "http",86},87WorkspacePodConfig: &WorkspacePodConfig{88TheiaPort: workspacePort,89SupervisorPort: supervisorPort,90IDEDebugPort: workspaceDebugPort,91SupervisorDebugPort: supervisorDebugPort,92DebugWorkspaceProxyPort: debugWorkspaceProxyPort,93},94BuiltinPages: BuiltinPagesConfig{95Location: "../../public",96},97}98)99100type Target struct {101Status int102Handler func(w http.ResponseWriter, r *http.Request, requestCount uint8)103}104105type testTarget struct {106Target *Target107RequestCount uint8108listener net.Listener109server *http.Server110}111112func (tt *testTarget) Close() {113_ = tt.listener.Close()114_ = tt.server.Shutdown(context.Background())115}116117// startTestTarget starts a new HTTP server that serves as some test target during the unit tests.118func startTestTarget(t *testing.T, host, name string, checkedHost bool) *testTarget {119t.Helper()120121l, err := net.Listen("tcp", host)122if err != nil {123t.Fatalf("cannot start fake IDE host: %q", err)124return nil125}126127tt := &testTarget{128Target: &Target{Status: http.StatusOK},129listener: l,130}131srv := &http.Server{Addr: host, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {132defer func() {133tt.RequestCount++134}()135136if tt.Target.Handler != nil {137tt.Target.Handler(w, r, tt.RequestCount)138return139}140141if tt.Target.Status == http.StatusOK {142w.Header().Set("Content-Type", "text/plain; charset=utf-8")143w.WriteHeader(http.StatusOK)144format := "%s hit: %s\n"145args := []interface{}{name, r.URL.String()}146if checkedHost {147format += "host: %s\n"148args = append(args, r.Host)149}150inlineVars := r.Header.Get("X-BlobServe-InlineVars")151if inlineVars != "" {152format += "inlineVars: %s\n"153args = append(args, inlineVars)154}155fmt.Fprintf(w, format, args...)156return157}158159if tt.Target.Status != 0 {160w.WriteHeader(tt.Target.Status)161return162}163w.WriteHeader(http.StatusOK)164})}165go func() { _ = srv.Serve(l) }()166tt.server = srv167168return tt169}170171type requestModifier func(r *http.Request)172173func addHeader(name string, val string) requestModifier {174return func(r *http.Request) {175r.Header.Add(name, val)176}177}178179func addHostHeader(r *http.Request) {180r.Header.Add(hostBasedHeader, r.Host)181}182183func addOwnerToken(domain, instanceID, token string) requestModifier {184return func(r *http.Request) {185setOwnerTokenCookie(r, domain, instanceID, token)186}187}188189func addCookie(c http.Cookie) requestModifier {190return func(r *http.Request) {191r.AddCookie(&c)192}193}194195func modifyRequest(r *http.Request, mod ...requestModifier) *http.Request {196for _, m := range mod {197m(r)198}199return r200}201202func TestRoutes(t *testing.T) {203type RouterFactory func(cfg *Config) WorkspaceRouter204type Expectation struct {205Status int206Header http.Header207Body string208}209type Targets struct {210IDE *Target211Blobserve *Target212Workspace *Target213DebugWorkspace *Target214Supervisor *Target215DebugSupervisor *Target216Port *Target217DebugWorkspaceProxy *Target218}219220domain := "test-domain.com"221tests := []struct {222Desc string223Config *Config224Request *http.Request225Router RouterFactory226Targets *Targets227IgnoreBody bool228Expectation Expectation229}{230{231Desc: "favicon",232Request: modifyRequest(httptest.NewRequest("GET", workspaces[0].URL+"favicon.ico", nil),233addHostHeader,234),235Expectation: Expectation{236Status: http.StatusSeeOther,237Header: http.Header{238"Content-Type": {"text/html; charset=utf-8"},239"Location": {240"https://ide.test-domain.com/blobserve/gitpod-io/supervisor:latest/__files__/favicon.ico",241},242"Vary": {"Accept-Encoding"},243},244Body: "<a href=\"https://ide.test-domain.com/blobserve/gitpod-io/supervisor:latest/__files__/favicon.ico\">See Other</a>.\n\n",245},246},247{248Desc: "/health",249Request: modifyRequest(httptest.NewRequest("GET", "/health", nil),250addHostHeader,251),252Expectation: Expectation{253Status: http.StatusOK,254Header: nil,255Body: "",256},257},258{259Desc: "blobserve IDE unauthorized GET /",260Config: &config,261Request: modifyRequest(httptest.NewRequest("GET", workspaces[0].URL, nil),262addHostHeader,263),264Expectation: Expectation{265Status: http.StatusSeeOther,266Header: http.Header{267"Content-Type": {"text/html; charset=utf-8"},268"Location": {"https://ide.test-domain.com/blobserve/gitpod-io/ide:latest/__files__/"},269"Vary": {"Accept-Encoding"},270},271Body: "<a href=\"https://ide.test-domain.com/blobserve/gitpod-io/ide:latest/__files__/\">See Other</a>.\n\n",272},273},274{275Desc: "blobserve IDE unauthorized navigate /",276Config: &config,277Request: modifyRequest(httptest.NewRequest("GET", workspaces[0].URL, nil),278addHostHeader,279addHeader("Sec-Fetch-Mode", "navigate"),280),281Expectation: Expectation{282Status: http.StatusOK,283Header: http.Header{284"Content-Length": {"242"},285"Content-Type": {"text/plain; charset=utf-8"},286"Vary": {"Accept-Encoding"},287},288Body: "blobserve hit: /gitpod-io/ide:latest/\nhost: localhost:20003\ninlineVars: {\"ide\":\"https://ide.test-domain.com/blobserve/gitpod-io/ide:latest/__files__\",\"supervisor\":\"https://ide.test-domain.com/blobserve/gitpod-io/supervisor:latest/__files__\"}\n",289},290},291{292Desc: "blobserve IDE unauthorized same-origin /",293Config: &config,294Request: modifyRequest(httptest.NewRequest("GET", workspaces[0].URL, nil),295addHostHeader,296addHeader("Sec-Fetch-Mode", "same-origin"),297),298Expectation: Expectation{299Status: http.StatusOK,300Header: http.Header{301"Content-Length": {"242"},302"Content-Type": {"text/plain; charset=utf-8"},303"Vary": {"Accept-Encoding"},304},305Body: "blobserve hit: /gitpod-io/ide:latest/\nhost: localhost:20003\ninlineVars: {\"ide\":\"https://ide.test-domain.com/blobserve/gitpod-io/ide:latest/__files__\",\"supervisor\":\"https://ide.test-domain.com/blobserve/gitpod-io/supervisor:latest/__files__\"}\n",306},307},308{309Desc: "blobserve IDE authorized GET /?foobar",310Config: &config,311Request: modifyRequest(httptest.NewRequest("GET", workspaces[0].URL+"?foobar", nil),312addHostHeader,313addOwnerToken(domain, workspaces[0].InstanceID, workspaces[0].Auth.OwnerToken),314),315Targets: &Targets{Workspace: &Target{Status: http.StatusOK}},316Expectation: Expectation{317Status: http.StatusOK,318Header: http.Header{319"Content-Length": {"24"},320"Content-Type": {"text/plain; charset=utf-8"},321"Vary": {"Accept-Encoding"},322},323Body: "workspace hit: /?foobar\n",324},325},326{327Desc: "blobserve IDE authorized GET /not-from-blobserve",328Config: &config,329Request: modifyRequest(httptest.NewRequest("GET", workspaces[0].URL+"not-from-blobserve", nil),330addHostHeader,331addOwnerToken(domain, workspaces[0].InstanceID, workspaces[0].Auth.OwnerToken),332),333Targets: &Targets{Workspace: &Target{Status: http.StatusOK}, Blobserve: &Target{Status: http.StatusNotFound}},334Expectation: Expectation{335Status: http.StatusOK,336Header: http.Header{337"Content-Length": {"35"},338"Content-Type": {"text/plain; charset=utf-8"},339"Vary": {"Accept-Encoding"},340},341Body: "workspace hit: /not-from-blobserve\n",342},343},344{345Desc: "blobserve IDE authorized GET /not-from-failed-blobserve",346Config: &config,347Request: modifyRequest(httptest.NewRequest("GET", workspaces[0].URL+"not-from-failed-blobserve", nil),348addHostHeader,349addOwnerToken(domain, workspaces[0].InstanceID, workspaces[0].Auth.OwnerToken),350),351Targets: &Targets{Workspace: &Target{Status: http.StatusOK}, Blobserve: &Target{Status: http.StatusInternalServerError}},352Expectation: Expectation{353Status: http.StatusOK,354Header: http.Header{355"Content-Length": {"42"},356"Content-Type": {"text/plain; charset=utf-8"},357"Vary": {"Accept-Encoding"},358},359Body: "workspace hit: /not-from-failed-blobserve\n",360},361},362{363Desc: "blobserve foreign resource",364Config: &config,365Request: modifyRequest(httptest.NewRequest("GET", "https://v--sr1o1nu24nqdf809l0u27jk5t7"+wsHostSuffix+"/image/__files__/test.html", nil),366addHostHeader,367addHeader("Sec-Fetch-Mode", "navigate"),368),369Expectation: Expectation{370Status: http.StatusOK,371Header: http.Header{372"Cache-Control": {"public, max-age=31536000"},373"Content-Length": {"54"},374"Content-Type": {"text/plain; charset=utf-8"},375},376Body: "blobserve hit: /image/test.html\nhost: localhost:20003\n",377},378},379{380Desc: "port foreign resource",381Config: &config,382Request: modifyRequest(httptest.NewRequest("GET", "https://v--sr1o1nu24nqdf809l0u27jk5t7"+wsHostSuffix+"/28080-amaranth-smelt-9ba20cc1/test.html", nil),383addHostHeader,384addOwnerToken(domain, workspaces[0].InstanceID, workspaces[0].Auth.OwnerToken),385),386Targets: &Targets{387Port: &Target{388Handler: func(w http.ResponseWriter, r *http.Request, requestCount uint8) {389fmt.Fprintf(w, "host: %s\n", r.Host)390fmt.Fprintf(w, "path: %s\n", r.URL.Path)391},392},393},394Expectation: Expectation{395Status: http.StatusOK,396Header: http.Header{397"Content-Length": {"69"},398"Content-Type": {"text/plain; charset=utf-8"},399},400Body: "host: v--sr1o1nu24nqdf809l0u27jk5t7.test-domain.com\npath: /test.html\n",401},402},403{404Desc: "debug foreign resource",405Config: &config,406Request: modifyRequest(httptest.NewRequest("GET", "https://v--sr1o1nu24nqdf809l0u27jk5t7"+wsHostSuffix+"/debug-amaranth-smelt-9ba20cc1/test.html", nil),407addHostHeader,408addOwnerToken(domain, workspaces[0].InstanceID, workspaces[0].Auth.OwnerToken),409),410Targets: &Targets{411DebugWorkspace: &Target{412Handler: func(w http.ResponseWriter, r *http.Request, requestCount uint8) {413fmt.Fprintf(w, "host: %s\n", r.Host)414fmt.Fprintf(w, "path: %s\n", r.URL.Path)415},416},417},418Expectation: Expectation{419Status: http.StatusOK,420Header: http.Header{421"Content-Length": {"69"},422"Content-Type": {"text/plain; charset=utf-8"},423},424Body: "host: v--sr1o1nu24nqdf809l0u27jk5t7.test-domain.com\npath: /test.html\n",425},426},427{428Desc: "port debug foreign resource",429Config: &config,430Request: modifyRequest(httptest.NewRequest("GET", "https://v--sr1o1nu24nqdf809l0u27jk5t7"+wsHostSuffix+"/28080-debug-amaranth-smelt-9ba20cc1/test.html", nil),431addHostHeader,432addOwnerToken(domain, workspaces[0].InstanceID, workspaces[0].Auth.OwnerToken),433),434Targets: &Targets{435DebugWorkspaceProxy: &Target{436Handler: func(w http.ResponseWriter, r *http.Request, requestCount uint8) {437fmt.Fprintf(w, "host: %s\n", r.Host)438fmt.Fprintf(w, "path: %s\n", r.URL.Path)439},440},441},442Expectation: Expectation{443Status: http.StatusOK,444Header: http.Header{445"Content-Length": {"69"},446"Content-Type": {"text/plain; charset=utf-8"},447},448Body: "host: v--sr1o1nu24nqdf809l0u27jk5t7.test-domain.com\npath: /test.html\n",449},450},451{452Desc: "no CORS allow in workspace urls",453Config: &config,454Request: modifyRequest(httptest.NewRequest("GET", workspaces[0].URL+"somewhere/in/the/ide", nil),455addHostHeader,456addOwnerToken(domain, workspaces[0].InstanceID, workspaces[0].Auth.OwnerToken),457addHeader("Origin", config.GitpodInstallation.HostName),458addHeader("Access-Control-Request-Method", "OPTIONS"),459),460Targets: &Targets{Workspace: &Target{Status: http.StatusOK}, Blobserve: &Target{Status: http.StatusNotFound}},461Expectation: Expectation{462Status: http.StatusOK,463Header: http.Header{464"Content-Length": {"37"},465"Content-Type": {"text/plain; charset=utf-8"},466"Vary": {"Accept-Encoding"},467},468Body: "workspace hit: /somewhere/in/the/ide\n",469},470},471{472Desc: "unauthenticated supervisor API (supervisor status)",473Request: modifyRequest(httptest.NewRequest("GET", workspaces[0].URL+"_supervisor/v1/status/supervisor", nil),474addHostHeader,475),476Expectation: Expectation{477Status: http.StatusOK,478Header: http.Header{479"Content-Length": {"50"},480"Content-Type": {"text/plain; charset=utf-8"},481},482Body: "supervisor hit: /_supervisor/v1/status/supervisor\n",483},484},485{486Desc: "unauthenticated supervisor API (IDE status)",487Request: modifyRequest(httptest.NewRequest("GET", workspaces[0].URL+"_supervisor/v1/status/ide", nil),488addHostHeader,489),490Expectation: Expectation{491Status: http.StatusOK,492Header: http.Header{493"Content-Length": {"43"},494"Content-Type": {"text/plain; charset=utf-8"},495},496Body: "supervisor hit: /_supervisor/v1/status/ide\n",497},498},499{500Desc: "unauthenticated supervisor API (content status)",501Request: modifyRequest(httptest.NewRequest("GET", workspaces[0].URL+"_supervisor/v1/status/content", nil),502addHostHeader,503),504Expectation: Expectation{505Status: http.StatusUnauthorized,506},507},508{509Desc: "authenticated supervisor API (content status)",510Request: modifyRequest(httptest.NewRequest("GET", workspaces[0].URL+"_supervisor/v1/status/content", nil),511addHostHeader,512addOwnerToken(domain, workspaces[0].InstanceID, workspaces[0].Auth.OwnerToken),513),514Expectation: Expectation{515Status: http.StatusOK,516Header: http.Header{517"Content-Length": {"47"},518"Content-Type": {"text/plain; charset=utf-8"},519},520Body: "supervisor hit: /_supervisor/v1/status/content\n",521},522},523{524Desc: "non-existent authorized GET /",525Request: modifyRequest(httptest.NewRequest("GET", strings.ReplaceAll(workspaces[0].URL, "amaranth", "blabla"), nil),526addHostHeader,527addOwnerToken(domain, workspaces[0].InstanceID, workspaces[0].Auth.OwnerToken),528),529Expectation: Expectation{530Status: http.StatusFound,531Header: http.Header{532"Content-Type": {"text/html; charset=utf-8"},533"Location": {"https://test-domain.com/start/?not_found=true#blabla-smelt-9ba20cc1"},534"Vary": {"Accept-Encoding"},535},536Body: ("<a href=\"https://test-domain.com/start/?not_found=true#blabla-smelt-9ba20cc1\">Found</a>.\n\n"),537},538},539{540Desc: "non-existent unauthorized GET /",541Request: modifyRequest(httptest.NewRequest("GET", strings.ReplaceAll(workspaces[0].URL, "amaranth", "blabla"), nil),542addHostHeader,543),544Expectation: Expectation{545Status: http.StatusFound,546Header: http.Header{547"Content-Type": {"text/html; charset=utf-8"},548"Location": {"https://test-domain.com/start/?not_found=true#blabla-smelt-9ba20cc1"},549"Vary": {"Accept-Encoding"},550},551Body: ("<a href=\"https://test-domain.com/start/?not_found=true#blabla-smelt-9ba20cc1\">Found</a>.\n\n"),552},553},554{555Desc: "blobserve supervisor frontend /worker-proxy.js",556Config: &config,557Request: modifyRequest(httptest.NewRequest("GET", workspaces[0].URL+"_supervisor/frontend/worker-proxy.js", nil),558addHostHeader,559),560Expectation: Expectation{561Status: http.StatusOK,562Header: http.Header{563"Content-Length": {"82"},564"Content-Type": {"text/plain; charset=utf-8"},565"Vary": {"Accept-Encoding"},566},567Body: "blobserve hit: /gitpod-io/supervisor:latest/worker-proxy.js\nhost: localhost:20003\n",568},569},570{571Desc: "blobserve supervisor frontend /main.js",572Config: &config,573Request: modifyRequest(httptest.NewRequest("GET", workspaces[0].URL+"_supervisor/frontend/main.js", nil),574addHostHeader,575),576Expectation: Expectation{577Status: http.StatusSeeOther,578Header: http.Header{579"Content-Type": {"text/html; charset=utf-8"},580"Location": {"https://ide.test-domain.com/blobserve/gitpod-io/supervisor:latest/__files__/main.js"},581"Vary": {"Accept-Encoding"},582},583Body: "<a href=\"https://ide.test-domain.com/blobserve/gitpod-io/supervisor:latest/__files__/main.js\">See Other</a>.\n\n",584},585},586{587Desc: "blobserve supervisor frontend /main.js retry on timeout",588Config: &config,589Request: modifyRequest(httptest.NewRequest("GET", workspaces[0].URL+"_supervisor/frontend/main.js", nil),590addHostHeader,591),592Targets: &Targets{Blobserve: &Target{593Handler: func(w http.ResponseWriter, r *http.Request, requestCount uint8) {594if requestCount == 0 {595w.WriteHeader(http.StatusServiceUnavailable)596_, _ = io.WriteString(w, "timeout")597return598}599w.WriteHeader(http.StatusOK)600},601}},602Expectation: Expectation{603Status: http.StatusSeeOther,604Header: http.Header{605"Content-Type": {"text/html; charset=utf-8"},606"Location": {"https://ide.test-domain.com/blobserve/gitpod-io/supervisor:latest/__files__/main.js"},607"Vary": {"Accept-Encoding"},608},609Body: "<a href=\"https://ide.test-domain.com/blobserve/gitpod-io/supervisor:latest/__files__/main.js\">See Other</a>.\n\n",610},611},612{613Desc: "port GET 404",614Request: modifyRequest(httptest.NewRequest("GET", workspaces[0].Ports[0].Url+"this-does-not-exist", nil),615addHostHeader,616addOwnerToken(domain, workspaces[0].InstanceID, workspaces[0].Auth.OwnerToken),617),618Targets: &Targets{Port: &Target{619Handler: func(w http.ResponseWriter, r *http.Request, requestCount uint8) {620w.WriteHeader(http.StatusNotFound)621fmt.Fprintf(w, "host: %s\n", r.Host)622},623}},624Expectation: Expectation{625Header: http.Header{"Content-Length": {"52"}, "Content-Type": {"text/plain; charset=utf-8"}},626Status: http.StatusNotFound,627Body: "host: 28080-amaranth-smelt-9ba20cc1.test-domain.com\n",628},629},630{631Desc: "debug port GET 404",632Request: modifyRequest(httptest.NewRequest("GET", "https://28080-debug-amaranth-smelt-9ba20cc1.test-domain.com/this-does-not-exist", nil),633addHostHeader,634addOwnerToken(domain, workspaces[0].InstanceID, workspaces[0].Auth.OwnerToken),635),636Targets: &Targets{637DebugWorkspaceProxy: &Target{638Handler: func(w http.ResponseWriter, r *http.Request, requestCount uint8) {639w.WriteHeader(http.StatusNotFound)640fmt.Fprintf(w, "host: %s\n", r.Host)641},642},643},644Expectation: Expectation{645Header: http.Header{"Content-Length": {"58"}, "Content-Type": {"text/plain; charset=utf-8"}},646Status: http.StatusNotFound,647Body: "host: 28080-debug-amaranth-smelt-9ba20cc1.test-domain.com\n",648},649},650{651Desc: "port GET unexposed",652Request: modifyRequest(httptest.NewRequest("GET", workspaces[0].Ports[0].Url+"this-does-not-exist", nil),653addHostHeader,654addOwnerToken(domain, workspaces[0].InstanceID, workspaces[0].Auth.OwnerToken),655),656Targets: &Targets{},657IgnoreBody: true,658Expectation: Expectation{659Status: http.StatusNotFound,660Body: "",661},662},663{664Desc: "port cookies",665Request: modifyRequest(httptest.NewRequest("GET", workspaces[0].Ports[0].Url+"this-does-not-exist", nil),666addHostHeader,667addOwnerToken(domain, workspaces[0].InstanceID, workspaces[0].Auth.OwnerToken),668addCookie(http.Cookie{Name: "foobar", Value: "baz"}),669addCookie(http.Cookie{Name: "another", Value: "cookie"}),670),671Targets: &Targets{672Port: &Target{673Handler: func(w http.ResponseWriter, r *http.Request, requestCount uint8) {674fmt.Fprintf(w, "host: %s\n", r.Host)675fmt.Fprintf(w, "%+q\n", r.Header["Cookie"])676},677},678},679Expectation: Expectation{680Status: http.StatusOK,681Header: http.Header{"Content-Length": {"82"}, "Content-Type": {"text/plain; charset=utf-8"}},682Body: "host: 28080-amaranth-smelt-9ba20cc1.test-domain.com\n[\"foobar=baz;another=cookie\"]\n",683},684},685{686Desc: "port GET 200 w/o X-Frame-Options header",687Request: modifyRequest(httptest.NewRequest("GET", workspaces[0].Ports[0].Url+"returns-200-with-frame-options-header", nil),688addHostHeader,689addOwnerToken(domain, workspaces[0].InstanceID, workspaces[0].Auth.OwnerToken),690),691Targets: &Targets{692Port: &Target{693Handler: func(w http.ResponseWriter, r *http.Request, requestCount uint8) {694w.Header().Add("X-Frame-Options", "sameorigin")695fmt.Fprintf(w, "host: %s\n", r.Host)696w.WriteHeader(http.StatusOK)697},698},699},700Expectation: Expectation{701Header: http.Header{702"Content-Length": {"52"},703"Content-Type": {"text/plain; charset=utf-8"},704},705Status: http.StatusOK,706Body: "host: 28080-amaranth-smelt-9ba20cc1.test-domain.com\n",707},708},709{710Desc: "debug IDE authorized GE",711Config: &config,712Request: modifyRequest(httptest.NewRequest("GET", debugWorkspaceURL, nil),713addHostHeader,714addOwnerToken(domain, workspaces[0].InstanceID, workspaces[0].Auth.OwnerToken),715),716Targets: &Targets{DebugWorkspace: &Target{Status: http.StatusOK}},717Expectation: Expectation{718Status: http.StatusOK,719Header: http.Header{720"Content-Length": {"23"},721"Content-Type": {"text/plain; charset=utf-8"},722"Vary": {"Accept-Encoding"},723},724Body: "debug workspace hit: /\n",725},726},727{728Desc: "debug supervisor frontend /main.js",729Config: &config,730Request: modifyRequest(httptest.NewRequest("GET", debugWorkspaceURL+"_supervisor/frontend/main.js", nil),731addHostHeader,732),733Targets: &Targets{DebugSupervisor: &Target{Status: http.StatusOK}},734Expectation: Expectation{735Status: http.StatusOK,736Header: http.Header{737"Content-Length": {"52"},738"Content-Type": {"text/plain; charset=utf-8"},739"Vary": {"Accept-Encoding"},740},741Body: "supervisor debug hit: /_supervisor/frontend/main.js\n",742},743},744}745746log.Init("ws-proxy-test", "", false, true)747log.Log.Logger.SetLevel(logrus.ErrorLevel)748749defaultTargets := &Targets{750IDE: &Target{Status: http.StatusOK},751Blobserve: &Target{Status: http.StatusOK},752Port: &Target{Status: http.StatusOK},753Supervisor: &Target{Status: http.StatusOK},754Workspace: &Target{Status: http.StatusOK},755}756targets := make(map[string]*testTarget)757controlTarget := func(target *Target, name, host string, checkedHost bool) {758_, runs := targets[name]759if runs && target == nil {760targets[name].Close()761delete(targets, name)762return763}764765if !runs && target != nil {766targets[name] = startTestTarget(t, host, name, checkedHost)767runs = true768}769770if runs {771targets[name].Target = target772targets[name].RequestCount = 0773}774}775defer func() {776for _, c := range targets {777c.Close()778}779}()780781for _, test := range tests {782if test.Targets == nil {783test.Targets = defaultTargets784}785786t.Run(test.Desc, func(t *testing.T) {787controlTarget(test.Targets.IDE, "IDE", ideServerHost, false)788controlTarget(test.Targets.Blobserve, "blobserve", blobServeHost, true)789controlTarget(test.Targets.Port, "port", portServeHost, true)790controlTarget(test.Targets.DebugWorkspaceProxy, "debug workspace proxy", fmt.Sprintf("localhost:%d", debugWorkspaceProxyPort), false)791controlTarget(test.Targets.Workspace, "workspace", workspaceHost, false)792controlTarget(test.Targets.DebugWorkspace, "debug workspace", fmt.Sprintf("localhost:%d", workspaceDebugPort), false)793controlTarget(test.Targets.Supervisor, "supervisor", fmt.Sprintf("localhost:%d", supervisorPort), false)794controlTarget(test.Targets.DebugSupervisor, "supervisor debug", fmt.Sprintf("localhost:%d", supervisorDebugPort), false)795796cfg := config797if test.Config != nil {798cfg = *test.Config799err := cfg.Validate()800if err != nil {801t.Fatalf("invalid configuration: %q", err)802}803}804router := HostBasedRouter(hostBasedHeader, wsHostSuffix, wsHostNameRegex)805if test.Router != nil {806router = test.Router(&cfg)807}808809ingress := HostBasedIngressConfig{810HTTPAddress: "8080",811HTTPSAddress: "9090",812Header: "",813}814815proxy := NewWorkspaceProxy(ingress, cfg, router, &fakeWsInfoProvider{infos: workspaces}, nil)816handler, err := proxy.Handler()817if err != nil {818t.Fatalf("cannot create proxy handler: %q", err)819}820821rec := httptest.NewRecorder()822handler.ServeHTTP(rec, test.Request)823resp := rec.Result()824825body, _ := io.ReadAll(resp.Body)826resp.Body.Close()827act := Expectation{828Status: resp.StatusCode,829Body: string(body),830Header: resp.Header,831}832833delete(act.Header, "Date")834835if len(act.Header) == 0 {836act.Header = nil837}838if test.IgnoreBody == true {839test.Expectation.Body = act.Body840}841if diff := cmp.Diff(test.Expectation, act); diff != "" {842t.Errorf("Expectation mismatch (-want +got):\n%s", diff)843}844})845}846}847848type fakeWsInfoProvider struct {849infos []common.WorkspaceInfo850}851852// GetWsInfoByID returns the workspace for the given ID.853func (p *fakeWsInfoProvider) WorkspaceInfo(workspaceID string) *common.WorkspaceInfo {854for _, nfo := range p.infos {855if nfo.WorkspaceID == workspaceID {856return &nfo857}858}859860return nil861}862863func (p *fakeWsInfoProvider) AcquireContext(ctx context.Context, workspaceID string, port string) (context.Context, string, error) {864return ctx, "", nil865}866func (p *fakeWsInfoProvider) ReleaseContext(id string) {867}868869// WorkspaceCoords returns the workspace coords for a public port.870func (p *fakeWsInfoProvider) WorkspaceCoords(wsProxyPort string) *common.WorkspaceCoords {871for _, info := range p.infos {872if info.IDEPublicPort == wsProxyPort {873return &common.WorkspaceCoords{874ID: info.WorkspaceID,875Port: "",876}877}878879for _, portInfo := range info.Ports {880if fmt.Sprint(portInfo.Port) == wsProxyPort {881return &common.WorkspaceCoords{882ID: info.WorkspaceID,883Port: strconv.Itoa(int(portInfo.Port)),884}885}886}887}888889return nil890}891892func TestSSHGatewayRouter(t *testing.T) {893generatePrivateKey := func() ssh.Signer {894prik, err := rsa.GenerateKey(rand.Reader, 2048)895if err != nil {896return nil897}898b := pem.EncodeToMemory(&pem.Block{899Bytes: x509.MarshalPKCS1PrivateKey(prik),900Type: "RSA PRIVATE KEY",901})902signal, err := ssh.ParsePrivateKey(b)903if err != nil {904return nil905}906return signal907}908909tests := []struct {910Name string911Input []ssh.Signer912Expected int913}{914{"one hostkey", []ssh.Signer{generatePrivateKey()}, 1},915{"multi hostkey", []ssh.Signer{generatePrivateKey(), generatePrivateKey()}, 2},916}917for _, test := range tests {918t.Run(test.Name, func(t *testing.T) {919router := HostBasedRouter(hostBasedHeader, wsHostSuffix, wsHostNameRegex)920ingress := HostBasedIngressConfig{921HTTPAddress: "8080",922HTTPSAddress: "9090",923Header: "",924}925926proxy := NewWorkspaceProxy(ingress, config, router, &fakeWsInfoProvider{infos: workspaces}, &sshproxy.Server{HostKeys: test.Input})927handler, err := proxy.Handler()928if err != nil {929t.Fatalf("cannot create proxy handler: %q", err)930}931932rec := httptest.NewRecorder()933handler.ServeHTTP(rec, modifyRequest(httptest.NewRequest("GET", workspaces[0].URL+"_ssh/host_keys", nil),934addHostHeader,935))936resp := rec.Result()937body, _ := io.ReadAll(resp.Body)938resp.Body.Close()939if resp.StatusCode != 200 {940t.Fatalf("status code should be 200, but got %d", resp.StatusCode)941}942var hostkeys []map[string]interface{}943fmt.Println(string(body))944err = json.Unmarshal(body, &hostkeys)945if err != nil {946t.Fatal(err)947}948t.Log(hostkeys, len(hostkeys), test.Expected)949950if len(hostkeys) != test.Expected {951t.Fatalf("hostkey length is not expected")952}953})954}955}956957func TestNoSSHGatewayRouter(t *testing.T) {958t.Run("TestNoSSHGatewayRouter", func(t *testing.T) {959router := HostBasedRouter(hostBasedHeader, wsHostSuffix, wsHostNameRegex)960ingress := HostBasedIngressConfig{961HTTPAddress: "8080",962HTTPSAddress: "9090",963Header: "",964}965966proxy := NewWorkspaceProxy(ingress, config, router, &fakeWsInfoProvider{infos: workspaces}, nil)967handler, err := proxy.Handler()968if err != nil {969t.Fatalf("cannot create proxy handler: %q", err)970}971rec := httptest.NewRecorder()972handler.ServeHTTP(rec, modifyRequest(httptest.NewRequest("GET", workspaces[0].URL+"_ssh/host_keys", nil),973addHostHeader,974))975resp := rec.Result()976resp.Body.Close()977if resp.StatusCode != 401 {978t.Fatalf("status code should be 401, but got %d", resp.StatusCode)979}980})981982}983984func TestRemoveSensitiveCookies(t *testing.T) {985var (986domain = "test-domain.com"987sessionCookie = &http.Cookie{Domain: domain, Name: "_test_domain_com_", Value: "fobar"}988sessionCookieJwt2 = &http.Cookie{Domain: domain, Name: "__Host-_test_domain_com_jwt2_", Value: "fobar"}989realGitpodSessionCookie = &http.Cookie{Domain: domain, Name: server_lib.CookieNameFromDomain(domain), Value: "fobar"}990portAuthCookie = &http.Cookie{Domain: domain, Name: "_test_domain_com_ws_77f6b236_3456_4b88_8284_81ca543a9d65_port_auth_", Value: "some-token"}991ownerCookie = &http.Cookie{Domain: domain, Name: "_test_domain_com_ws_77f6b236_3456_4b88_8284_81ca543a9d65_owner_", Value: "some-other-token"}992ownerCookieGen = ownerTokenCookie(domain, "77f6b236_3456_4b88_8284_81ca543a9d65", "owner-token-gen")993miscCookie = &http.Cookie{Domain: domain, Name: "some-other-cookie", Value: "I like cookies"}994invalidCookieName = &http.Cookie{Domain: domain, Name: "foobar[0]", Value: "violates RFC6266"}995)996997tests := []struct {998Name string999Input []*http.Cookie1000Expected []*http.Cookie1001}{1002{Name: "no cookies", Input: []*http.Cookie{}, Expected: []*http.Cookie{}},1003{Name: "session cookie", Input: []*http.Cookie{sessionCookie, miscCookie}, Expected: []*http.Cookie{miscCookie}},1004{Name: "session cookie ending on _jwt2_", Input: []*http.Cookie{sessionCookieJwt2, miscCookie}, Expected: []*http.Cookie{miscCookie}},1005{Name: "real Gitpod session cookie", Input: []*http.Cookie{realGitpodSessionCookie, miscCookie}, Expected: []*http.Cookie{miscCookie}},1006{Name: "portAuth cookie", Input: []*http.Cookie{portAuthCookie, miscCookie}, Expected: []*http.Cookie{miscCookie}},1007{Name: "owner cookie", Input: []*http.Cookie{ownerCookie, miscCookie}, Expected: []*http.Cookie{miscCookie}},1008{Name: "owner cookie generated", Input: []*http.Cookie{ownerCookieGen, miscCookie}, Expected: []*http.Cookie{miscCookie}},1009{Name: "misc cookie", Input: []*http.Cookie{miscCookie}, Expected: []*http.Cookie{miscCookie}},1010{Name: "invalid cookie name", Input: []*http.Cookie{invalidCookieName}, Expected: []*http.Cookie{invalidCookieName}},1011}1012for _, test := range tests {1013t.Run(test.Name, func(t *testing.T) {1014res := removeSensitiveCookies(test.Input, domain)1015if diff := cmp.Diff(test.Expected, res); diff != "" {1016t.Errorf("unexpected result (-want +got):\n%s", diff)1017}1018})1019}1020}10211022func TestSensitiveCookieHandler(t *testing.T) {1023var (1024domain = "test-domain.com"1025miscCookie = &http.Cookie{Domain: domain, Name: "some-other-cookie", Value: "I like cookies"}1026)1027tests := []struct {1028Name string1029Input string1030Expected string1031}{1032{Name: "no cookies", Input: "", Expected: ""},1033{Name: "valid cookie", Input: miscCookie.String(), Expected: `some-other-cookie="I like cookies";Domain=test-domain.com`},1034{Name: "invalid cookie", Input: `foobar[0]="violates RFC6266"`, Expected: `foobar[0]="violates RFC6266"`},1035}1036for _, test := range tests {1037t.Run(test.Name, func(t *testing.T) {1038req := httptest.NewRequest("GET", "http://"+domain, nil)1039if test.Input != "" {1040req.Header.Set("cookie", test.Input)1041}1042rec := httptest.NewRecorder()10431044var act string1045sensitiveCookieHandler(domain)(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {1046act = r.Header.Get("cookie")1047rw.WriteHeader(http.StatusOK)1048})).ServeHTTP(rec, req)10491050if diff := cmp.Diff(test.Expected, act); diff != "" {1051t.Errorf("unexpected result (-want +got):\n%s", diff)1052}1053})1054}1055}105610571058