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