From ee3737c831d17c7444ca8186f43ad845cd954dc9 Mon Sep 17 00:00:00 2001 From: PJ Date: Mon, 27 Apr 2026 18:46:49 +0700 Subject: [PATCH] feat(inspect): serveHTML route under /api/runs//html/ Mirrors serveScreenshot path validation; rejects traversal segments and unknown extensions. text/html content-type so the iframe renders cleanly. --- internal/inspect/server.go | 19 ++++++++++++++ internal/inspect/server_test.go | 45 +++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/internal/inspect/server.go b/internal/inspect/server.go index 637720a..39033f0 100644 --- a/internal/inspect/server.go +++ b/internal/inspect/server.go @@ -84,6 +84,7 @@ func (s *Server) handleRunsList(responseWriter http.ResponseWriter, request *htt var stepPathPattern = regexp.MustCompile(`^([a-zA-Z0-9._-]+)/steps/([^/]+)$`) var screenshotPathPattern = regexp.MustCompile(`^([a-zA-Z0-9._-]+)/screenshots/([a-zA-Z0-9._-]+\.png)$`) +var htmlPathPattern = regexp.MustCompile(`^([a-zA-Z0-9._-]+)/html/([a-zA-Z0-9._-]+\.html)$`) var runDetailPathPattern = regexp.MustCompile(`^([a-zA-Z0-9._-]+)/?$`) func (s *Server) handleRunsTree(responseWriter http.ResponseWriter, request *http.Request) { @@ -104,6 +105,10 @@ func (s *Server) handleRunsTree(responseWriter http.ResponseWriter, request *htt s.serveScreenshot(responseWriter, request, match[1], match[2]) return } + if match := htmlPathPattern.FindStringSubmatch(rest); match != nil { + s.serveHTML(responseWriter, request, match[1], match[2]) + return + } if match := runDetailPathPattern.FindStringSubmatch(rest); match != nil { s.serveDetail(responseWriter, match[1]) return @@ -160,6 +165,20 @@ func (s *Server) serveScreenshot(responseWriter http.ResponseWriter, request *ht http.ServeFile(responseWriter, request, full) } +func (s *Server) serveHTML(responseWriter http.ResponseWriter, request *http.Request, id, name string) { + if !validRunID(id) || strings.Contains(name, "..") { + http.Error(responseWriter, "run not found", http.StatusNotFound) + return + } + full := filepath.Join(s.options.RunsDirectory, id, "html", name) + if _, err := http.Dir(filepath.Join(s.options.RunsDirectory, id, "html")).Open(name); err != nil { + http.NotFound(responseWriter, request) + return + } + responseWriter.Header().Set("Content-Type", "text/html; charset=utf-8") + http.ServeFile(responseWriter, request, full) +} + func (s *Server) handleEvents(responseWriter http.ResponseWriter, request *http.Request) { flusher, ok := responseWriter.(http.Flusher) if !ok { diff --git a/internal/inspect/server_test.go b/internal/inspect/server_test.go index 13a4f99..6aa1471 100644 --- a/internal/inspect/server_test.go +++ b/internal/inspect/server_test.go @@ -171,6 +171,51 @@ func TestScreenshot_ServesWhitelistedPNG(t *testing.T) { } } +func TestServeHTML_Returns200(t *testing.T) { + server, root := newFixtureServer(t) + htmlDirectory := filepath.Join(root, "run-a", "html") + if err := os.MkdirAll(htmlDirectory, 0o755); err != nil { + t.Fatal(err) + } + body := []byte("hi") + if err := os.WriteFile(filepath.Join(htmlDirectory, "step-00001.html"), body, 0o644); err != nil { + t.Fatal(err) + } + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/api/runs/run-a/html/step-00001.html", nil) + server.Handler().ServeHTTP(recorder, request) + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", recorder.Code, recorder.Body.String()) + } + if recorder.Body.String() != string(body) { + t.Errorf("body mismatch") + } + if !strings.Contains(recorder.Header().Get("Content-Type"), "text/html") { + t.Errorf("content-type = %q", recorder.Header().Get("Content-Type")) + } +} + +func TestServeHTML_RejectsTraversalAndMissing(t *testing.T) { + server, _ := newFixtureServer(t) + cases := []string{ + "/api/runs/run-a/html/../meta.json", + "/api/runs/run-a/html/..%2Fmeta.json", + "/api/runs/run-a/html/missing.html", + "/api/runs/run-a/html/step-00001.txt", + } + for _, path := range cases { + t.Run(path, func(t *testing.T) { + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, path, nil) + server.Handler().ServeHTTP(recorder, request) + if recorder.Code == http.StatusOK { + t.Errorf("status = %d (should not be 200) body=%s", recorder.Code, recorder.Body.String()) + } + }) + } +} + func TestScreenshot_RejectsTraversalAndBadNames(t *testing.T) { server, _ := newFixtureServer(t) cases := []string{