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