1package webterm
2
3import (
4 "context"
5 "encoding/json"
6 "io"
7 "net/http"
8 "net/http/httptest"
9 "net/url"
10 "strings"
11 "testing"
12 "time"
13
14 "github.com/coder/websocket"
15 "github.com/stretchr/testify/require"
16)
17
18func TestHTTPTrustBoundary(t *testing.T) {
19 token := strings.Repeat("a", 43)
20 server, err := NewServer(t.Context(), Settings{}, token, func(context.Context, *Terminal) error { return nil })
21 require.NoError(t, err)
22 tests := []struct {
23 name, method, path, host, origin, site, credential string
24 want int
25 }{
26 {"shell", "GET", "/", Address, "", "", "", 200},
27 {"no auth", "GET", "/health", Address, "", "", "", 401},
28 {"authenticated", "GET", "/health", Address, "", "", token, 204},
29 {"rebinding", "GET", "/health", "attacker.invalid", "", "", token, 403},
30 {"cross origin", "GET", "/health", Address, "https://attacker.invalid", "", token, 403},
31 {"cross site", "GET", "/", Address, "", "cross-site", "", 403},
32 {"ws requires origin", "GET", "/ws", Address, "", "", token, 403},
33 {"health is read only", "POST", "/health", Address, Origin, "", token, 405},
34 {"license is read only", "POST", "/icons-license", Address, "", "", "", 405},
35 {"uppercase host", "GET", "/health", strings.ToUpper(Address), "", "", token, 204},
36 {"trailing dot host", "GET", "/health", "127.0.0.1.:8778", "", "", token, 204},
37 }
38 for _, tt := range tests {
39 t.Run(tt.name, func(t *testing.T) {
40 request := httptest.NewRequest(tt.method, Origin+tt.path, nil)
41 request.Host = tt.host
42 request.Header.Set("Origin", tt.origin)
43 request.Header.Set("Sec-Fetch-Site", tt.site)
44 if tt.credential != "" {
45 request.AddCookie(&http.Cookie{Name: cookieName, Value: tt.credential})
46 }
47 response := httptest.NewRecorder()
48 server.ServeHTTP(response, request)
49 require.Equal(t, tt.want, response.Code)
50 require.Contains(t, response.Header().Get("Content-Security-Policy"), "frame-ancestors 'none'")
51 require.NotContains(t, response.Body.String(), token)
52 })
53 }
54}
55
56func TestLogin(t *testing.T) {
57 token := strings.Repeat("b", 43)
58 server, err := NewServer(t.Context(), Settings{}, token, func(context.Context, *Terminal) error { return nil })
59 require.NoError(t, err)
60 for _, valid := range []bool{false, true} {
61 t.Run(map[bool]string{false: "invalid", true: "valid"}[valid], func(t *testing.T) {
62 supplied := "wrong"
63 if valid {
64 supplied = token
65 }
66 request := httptest.NewRequest("POST", Origin+"/login", strings.NewReader(url.Values{"token": {supplied}}.Encode()))
67 request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
68 request.Header.Set("Origin", Origin)
69 response := httptest.NewRecorder()
70 server.ServeHTTP(response, request)
71 if !valid {
72 require.Equal(t, 403, response.Code)
73 return
74 }
75 require.Equal(t, 204, response.Code)
76 cookies := response.Result().Cookies()
77 require.Len(t, cookies, 1)
78 require.True(t, cookies[0].HttpOnly)
79 require.Equal(t, http.SameSiteStrictMode, cookies[0].SameSite)
80 })
81 }
82}
83
84func TestWebSocketRoundTripAndDetach(t *testing.T) {
85 ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
86 defer cancel()
87 exited := make(chan struct{})
88 token := strings.Repeat("c", 43)
89 handler, err := NewServer(ctx, Settings{}, token, func(ctx context.Context, terminal *Terminal) error {
90 defer close(exited)
91 if _, err := terminal.EnterRaw(); err != nil {
92 return err
93 }
94 data := make([]byte, 1)
95 if _, err := io.ReadFull(terminal.In(), data); err != nil {
96 return err
97 }
98 if _, err := terminal.Write(data); err != nil {
99 return err
100 }
101 if err := terminal.Flush(); err != nil {
102 return err
103 }
104 <-ctx.Done()
105 return ctx.Err()
106 })
107 require.NoError(t, err)
108 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { r.Host = Address; handler.ServeHTTP(w, r) }))
109 defer server.Close()
110 headers := http.Header{"Origin": {Origin}, "Cookie": {cookieName + "=" + token}}
111 conn, _, err := websocket.Dial(ctx, "ws"+strings.TrimPrefix(server.URL, "http")+"/ws", &websocket.DialOptions{HTTPHeader: headers})
112 require.NoError(t, err)
113 defer conn.CloseNow()
114 conn.SetReadLimit(64 << 20)
115 _, data, err := conn.Read(ctx)
116 require.NoError(t, err)
117 require.True(t, json.Valid(data))
118 require.NoError(t, conn.Write(ctx, websocket.MessageText, []byte(`{"schemaVersion":1,"type":"text","text":"Z"}`)))
119 for {
120 _, data, err = conn.Read(ctx)
121 require.NoError(t, err)
122 if strings.Contains(string(data), `"text":"Z"`) {
123 break
124 }
125 }
126 require.NoError(t, conn.CloseNow())
127 select {
128 case <-exited:
129 case <-ctx.Done():
130 t.Fatal("attachment did not detach")
131 }
132 handler.Wait()
133}