1package webkit
2
3import (
4 "bytes"
5 "context"
6 "net/http"
7 "net/url"
8 "strings"
9 "testing"
10 "testing/fstest"
11
12 "github.com/andybalholm/brotli"
13 "github.com/bnema/dumber/assets"
14 "github.com/stretchr/testify/assert"
15 "github.com/stretchr/testify/require"
16)
17
18func TestCrashOriginalURI(t *testing.T) {
19 assert.Equal(t, "https://example.com/path?q=1", crashOriginalURI("dumb://history/crash?url=https%3A%2F%2Fexample.com%2Fpath%3Fq%3D1"))
20 assert.Empty(t, crashOriginalURI("dumb://history/crash"))
21 assert.Empty(t, crashOriginalURI("%%%"))
22}
23
24func TestBuildCrashPageHTMLIncludesReloadTarget(t *testing.T) {
25 body := buildCrashPageHTML("https://example.com/foo?a=1&b=2")
26 assert.Contains(t, body, `data-target="https://example.com/foo?a=1&b=2"`)
27 assert.Contains(t, body, "https://example.com/foo?a=1&b=2")
28}
29
30func TestBuildCrashPageHTMLFallbackReloadWithoutTarget(t *testing.T) {
31 body := buildCrashPageHTML("")
32 assert.Contains(t, body, "window.location.reload();")
33}
34
35func TestBuildCrashPageHTMLRejectsUnsafeReloadScheme(t *testing.T) {
36 // buildCrashPageHTML receives already-sanitized input from the caller.
37 // sanitizeCrashPageOriginalURI strips unsafe schemes to "".
38 sanitized := sanitizeCrashPageOriginalURI("javascript:alert(1)")
39 assert.Empty(t, sanitized)
40 body := buildCrashPageHTML(sanitized)
41 assert.Contains(t, body, `data-target=""`)
42}
43
44func TestBuildCrashPageHTMLAllowsDumbSchemeTarget(t *testing.T) {
45 body := buildCrashPageHTML("dumb://history")
46 assert.Contains(t, body, `data-target="dumb://history"`)
47}
48
49func TestBuildCrashPageHTMLEscapesScriptBreakoutPayload(t *testing.T) {
50 payload := `https://example.com/?q=</script><script>alert(1)</script>`
51 body := buildCrashPageHTML(payload)
52 assert.NotContains(t, body, "</script><script>alert(1)</script>")
53 assert.Contains(t, body, "</script><script>alert(1)</script>")
54}
55
56func TestIsTrustedSystemviewURL(t *testing.T) {
57 t.Parallel()
58
59 for _, raw := range []string{
60 "dumb://history",
61 "dumb://favorites",
62 "dumb:config",
63 } {
64 require.True(t, isTrustedSystemviewURL(raw), raw)
65 }
66 for _, raw := range []string{
67 "",
68 "https://dumber.invalid/history",
69 "dumb://api/favicon",
70 "dumb://evil/history",
71 } {
72 require.False(t, isTrustedSystemviewURL(raw), raw)
73 }
74}
75
76func TestIsTrustedSystemviewFaviconRequestRequiresTrustedRefererWhenOriginAbsent(t *testing.T) {
77 t.Parallel()
78
79 require.True(t, isTrustedSystemviewFaviconRequest(&SchemeRequest{Referer: "dumb://history"}))
80 require.False(t, isTrustedSystemviewFaviconRequest(&SchemeRequest{Referer: ""}))
81}
82
83func TestIsTrustedSystemviewFaviconRequestHonorsOriginBeforeReferer(t *testing.T) {
84 t.Parallel()
85
86 require.True(t, isTrustedSystemviewFaviconRequest(&SchemeRequest{Origin: "dumb://history"}))
87 require.True(t, isTrustedSystemviewFaviconRequest(&SchemeRequest{Origin: "dumb://history", Referer: "https://evil.example"}))
88 require.False(t, isTrustedSystemviewFaviconRequest(&SchemeRequest{Origin: "https://evil.example", Referer: "dumb://history"}))
89}
90
91func TestRegisterDefaultsIncludesCrashHandler(t *testing.T) {
92 handler := NewDumbSchemeHandler(context.Background())
93 require.NotNil(t, handler)
94
95 handler.mu.RLock()
96 crashHandler, ok := handler.handlers["/crash"]
97 handler.mu.RUnlock()
98 require.True(t, ok)
99 require.NotNil(t, crashHandler)
100
101 resp := crashHandler.Handle(&SchemeRequest{
102 URI: "dumb://history/crash?url=https%3A%2F%2Fexample.com",
103 Path: "/crash",
104 Method: "GET",
105 Scheme: "dumb",
106 })
107 require.NotNil(t, resp)
108 assert.Equal(t, 200, resp.StatusCode)
109 assert.Equal(t, "text/html; charset=utf-8", resp.ContentType)
110 assert.Contains(t, string(resp.Data), "Renderer process ended")
111}
112
113func TestCrashHandlerSanitizesUnsafeURLQuery(t *testing.T) {
114 handler := NewDumbSchemeHandler(context.Background())
115 require.NotNil(t, handler)
116
117 handler.mu.RLock()
118 crashHandler, ok := handler.handlers["/crash"]
119 handler.mu.RUnlock()
120 require.True(t, ok)
121 require.NotNil(t, crashHandler)
122
123 resp := crashHandler.Handle(&SchemeRequest{
124 URI: "dumb://history/crash?url=javascript%3Aalert(1)",
125 Path: "/crash",
126 Method: "GET",
127 Scheme: "dumb",
128 })
129 require.NotNil(t, resp)
130 assert.Contains(t, string(resp.Data), `data-target=""`)
131}
132
133func TestHandleAsset_ConfigOpaqueFormServesSystemviewsShell(t *testing.T) {
134 h := NewDumbSchemeHandler(context.Background())
135 h.SetAssets(assets.WebUIAssets)
136
137 u, err := url.Parse("dumb:config")
138 require.NoError(t, err)
139
140 resp := h.handleAsset(u)
141 require.NotNil(t, resp)
142 assert.Equal(t, "text/html; charset=utf-8", resp.ContentType)
143 assert.Contains(t, string(resp.Data), "systemviews.wasm")
144}
145
146func TestHandleAsset_SystemviewsCSSIsServed(t *testing.T) {
147 h := NewDumbSchemeHandler(context.Background())
148 h.SetAssets(assets.WebUIAssets)
149
150 u, err := url.Parse("dumb://history/systemviews.css")
151 require.NoError(t, err)
152
153 resp := h.handleAsset(u)
154 require.NotNil(t, resp)
155 // Charset suffixes are acceptable; assert the MIME type contract only.
156 assert.Equal(t, "text/css", strings.Split(resp.ContentType, ";")[0])
157 assert.Contains(t, string(resp.Data), ".sv-app")
158}
159
160func TestHandleAsset_SystemviewsRootsServeShell(t *testing.T) {
161 h := NewDumbSchemeHandler(context.Background())
162 h.SetAssets(assets.WebUIAssets)
163
164 tests := []string{
165 "dumb://history",
166 "dumb:history",
167 "dumb://favorites",
168 "dumb:favorites",
169 "dumb://config",
170 "dumb:config",
171 "dumb://error",
172 "dumb:error",
173 }
174
175 for _, raw := range tests {
176 t.Run(raw, func(t *testing.T) {
177 u, err := url.Parse(raw)
178 require.NoError(t, err)
179
180 resp := h.handleAsset(u)
181 require.NotNil(t, resp)
182 assert.Equal(t, "text/html; charset=utf-8", resp.ContentType)
183 assert.Contains(t, string(resp.Data), "systemviews.wasm")
184 })
185 }
186}
187
188func TestConfigHandlersUseInjectedPayloadBuilders(t *testing.T) {
189 h := NewDumbSchemeHandler(context.Background())
190 h.SetConfigPayloadBuilders(
191 func() ([]byte, error) { return []byte(`{"current":true}`), nil },
192 func() ([]byte, error) { return []byte(`{"default":true}`), nil },
193 )
194
195 h.mu.RLock()
196 currentHandler := h.handlers["/api/config"]
197 defaultHandler := h.handlers["/api/config/default"]
198 h.mu.RUnlock()
199 require.NotNil(t, currentHandler)
200 require.NotNil(t, defaultHandler)
201
202 currentResp := currentHandler.Handle(&SchemeRequest{
203 URI: "dumb://config/api/config",
204 Path: "/api/config",
205 Method: http.MethodGet,
206 Scheme: "dumb",
207 Referer: "dumb://config",
208 })
209 require.NotNil(t, currentResp)
210 assert.Equal(t, http.StatusOK, currentResp.StatusCode)
211 assert.Equal(t, "application/json", currentResp.ContentType)
212 assert.Equal(t, []byte(`{"current":true}`), currentResp.Data)
213
214 defaultResp := defaultHandler.Handle(&SchemeRequest{
215 URI: "dumb://config/api/config/default",
216 Path: "/api/config/default",
217 Method: http.MethodGet,
218 Scheme: "dumb",
219 Referer: "dumb://config",
220 })
221 require.NotNil(t, defaultResp)
222 assert.Equal(t, http.StatusOK, defaultResp.StatusCode)
223 assert.Equal(t, "application/json", defaultResp.ContentType)
224 assert.Equal(t, []byte(`{"default":true}`), defaultResp.Data)
225}
226
227func TestConfigHandlersRejectUntrustedRequests(t *testing.T) {
228 h := NewDumbSchemeHandler(context.Background())
229 h.SetConfigPayloadBuilders(
230 func() ([]byte, error) { return []byte(`{"current":true}`), nil },
231 func() ([]byte, error) { return []byte(`{"default":true}`), nil },
232 )
233
234 h.mu.RLock()
235 currentHandler := h.handlers["/api/config"]
236 defaultHandler := h.handlers["/api/config/default"]
237 h.mu.RUnlock()
238
239 for _, handler := range []PageHandler{currentHandler, defaultHandler} {
240 resp := handler.Handle(&SchemeRequest{Method: http.MethodGet, Origin: "https://evil.example"})
241 require.NotNil(t, resp)
242 assert.Equal(t, http.StatusForbidden, resp.StatusCode)
243 assert.True(t, resp.SuppressDefaultHeaders)
244 assert.NotContains(t, resp.Headers, "Access-Control-Allow-Origin")
245 }
246}
247
248func TestConfigHandlersAllowOpaqueOriginWithTrustedReferer(t *testing.T) {
249 h := NewDumbSchemeHandler(context.Background())
250 h.SetConfigPayloadBuilders(
251 func() ([]byte, error) { return []byte(`{"current":true}`), nil },
252 func() ([]byte, error) { return []byte(`{"default":true}`), nil },
253 )
254
255 h.mu.RLock()
256 currentHandler := h.handlers["/api/config"]
257 h.mu.RUnlock()
258 require.NotNil(t, currentHandler)
259
260 resp := currentHandler.Handle(&SchemeRequest{
261 URI: "dumb://config/api/config",
262 Path: "/api/config",
263 Method: http.MethodGet,
264 Scheme: "dumb",
265 Origin: "null",
266 Referer: "dumb://config",
267 })
268
269 require.NotNil(t, resp)
270 assert.Equal(t, http.StatusOK, resp.StatusCode)
271 assert.Equal(t, []byte(`{"current":true}`), resp.Data)
272}
273
274func TestConfigHandlersExposeTrustedResponsesToWebKitFetch(t *testing.T) {
275 h := NewDumbSchemeHandler(context.Background())
276 h.SetConfigPayloadBuilders(
277 func() ([]byte, error) { return []byte(`{"current":true}`), nil },
278 func() ([]byte, error) { return []byte(`{"default":true}`), nil },
279 )
280
281 h.mu.RLock()
282 currentHandler := h.handlers["/api/config"]
283 h.mu.RUnlock()
284 require.NotNil(t, currentHandler)
285
286 resp := currentHandler.Handle(&SchemeRequest{
287 URI: "dumb://config/api/config",
288 Path: "/api/config",
289 Method: http.MethodGet,
290 Scheme: "dumb",
291 Origin: "null",
292 Referer: "dumb://config",
293 })
294
295 require.NotNil(t, resp)
296 assert.Equal(t, http.StatusOK, resp.StatusCode)
297 assert.Equal(t, "*", resp.Headers["Access-Control-Allow-Origin"])
298 assert.Equal(t, "GET, OPTIONS", resp.Headers["Access-Control-Allow-Methods"])
299 assert.Equal(t, "Content-Type", resp.Headers["Access-Control-Allow-Headers"])
300}
301
302func TestShouldAddCORSHeaders(t *testing.T) {
303 t.Parallel()
304
305 tests := []struct {
306 name string
307 path string
308 want bool
309 }{
310 {name: "private api route", path: "/api/config", want: false},
311 {name: "wasm asset", path: "/systemviews.wasm", want: true},
312 {name: "html shell", path: "/", want: false},
313 {name: "js asset", path: "/wasm_exec.js", want: false},
314 }
315
316 for _, tt := range tests {
317 t.Run(tt.name, func(t *testing.T) {
318 t.Parallel()
319 assert.Equal(t, tt.want, shouldAddCORSHeaders(tt.path))
320 })
321 }
322}
323
324func TestResponseHeadersForPath_WASMIncludesContentTypeAndCORS(t *testing.T) {
325 t.Parallel()
326
327 headers := responseHeadersForPath("/systemviews.wasm", "application/wasm")
328 assert.Equal(t, "application/wasm", headers["Content-Type"])
329 assert.Equal(t, "*", headers["Access-Control-Allow-Origin"])
330 assert.Equal(t, "GET, POST, OPTIONS", headers["Access-Control-Allow-Methods"])
331}
332
333func TestReadAssetWithEncodingDecompressesBrotliWASM(t *testing.T) {
334 t.Parallel()
335
336 wasm := []byte("\x00asmfixture")
337 fsys := fstest.MapFS{
338 "systemviews/systemviews.wasm.br": {Data: brotliCompressForTest(t, wasm)},
339 }
340
341 data, err := readAssetWithEncoding(fsys, "systemviews/systemviews.wasm", "systemviews.wasm")
342 require.NoError(t, err)
343 assert.Equal(t, wasm, data)
344}
345
346func brotliCompressForTest(t *testing.T, data []byte) []byte {
347 t.Helper()
348 var buf bytes.Buffer
349 w := brotli.NewWriter(&buf)
350 _, err := w.Write(data)
351 require.NoError(t, err)
352 require.NoError(t, w.Close())
353 return buf.Bytes()
354}
355
356func TestHandleAssetRejectsTraversalOutsideSystemviews(t *testing.T) {
357 h := NewDumbSchemeHandler(context.Background())
358 h.SetAssets(assets.WebUIAssets)
359
360 for _, raw := range []string{
361 "dumb://history/../logo.svg",
362 "dumb://history/nested/../../logo.svg",
363 } {
364 t.Run(raw, func(t *testing.T) {
365 u, err := url.Parse(raw)
366 require.NoError(t, err)
367 assert.Nil(t, h.handleAsset(u))
368 })
369 }
370}
371
372func TestSafeSystemviewsAssetPathRejectsTraversal(t *testing.T) {
373 t.Parallel()
374
375 fullPath, relPath, ok := safeSystemviewsAssetPath(systemviewsAssetDir, "nested/../systemviews.css")
376 require.True(t, ok)
377 assert.Equal(t, "systemviews/systemviews.css", fullPath)
378 assert.Equal(t, "systemviews.css", relPath)
379
380 invalid := []struct {
381 name string
382 assetDir string
383 relPath string
384 }{
385 {name: "parent escape", assetDir: systemviewsAssetDir, relPath: "../logo.svg"},
386 {name: "nested parent escape", assetDir: systemviewsAssetDir, relPath: "nested/../../logo.svg"},
387 {name: "absolute parent escape", assetDir: systemviewsAssetDir, relPath: "/../logo.svg"},
388 {name: "null byte", assetDir: systemviewsAssetDir, relPath: "systemviews.css\x00"},
389 {name: "wrong asset dir", assetDir: "logos", relPath: "logo.svg"},
390 }
391 for _, tt := range invalid {
392 t.Run(tt.name, func(t *testing.T) {
393 t.Parallel()
394 _, _, ok := safeSystemviewsAssetPath(tt.assetDir, tt.relPath)
395 assert.False(t, ok)
396 })
397 }
398}