feat(inspect): serveHTML route under /api/runs/<id>/html/<name>

Mirrors serveScreenshot path validation; rejects traversal segments and
unknown extensions. text/html content-type so the iframe renders cleanly.
This commit is contained in:
pj committed 2026-04-27 18:46:49 +07:00
1 parent d2626eeccc
commit ee3737c831
2 files changed
+64

No files matched your search

+19
View File
@@ -84,6 +84,7 @@ func (s *Server) handleRunsList(responseWriter http.ResponseWriter, request *htt
var stepPathPattern = regexp.MustCompile(`^([a-zA-Z0-9._-]+)/steps/([^/]+)$`) var stepPathPattern = regexp.MustCompile(`^([a-zA-Z0-9._-]+)/steps/([^/]+)$`)
var screenshotPathPattern = regexp.MustCompile(`^([a-zA-Z0-9._-]+)/screenshots/([a-zA-Z0-9._-]+\.png)$`) 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._-]+)/?$`) var runDetailPathPattern = regexp.MustCompile(`^([a-zA-Z0-9._-]+)/?$`)
func (s *Server) handleRunsTree(responseWriter http.ResponseWriter, request *http.Request) { 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]) s.serveScreenshot(responseWriter, request, match[1], match[2])
return return
} }
if match := htmlPathPattern.FindStringSubmatch(rest); match != nil {
s.serveHTML(responseWriter, request, match[1], match[2])
return
}
if match := runDetailPathPattern.FindStringSubmatch(rest); match != nil { if match := runDetailPathPattern.FindStringSubmatch(rest); match != nil {
s.serveDetail(responseWriter, match[1]) s.serveDetail(responseWriter, match[1])
return return
@@ -160,6 +165,20 @@ func (s *Server) serveScreenshot(responseWriter http.ResponseWriter, request *ht
http.ServeFile(responseWriter, request, full) 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) { func (s *Server) handleEvents(responseWriter http.ResponseWriter, request *http.Request) {
flusher, ok := responseWriter.(http.Flusher) flusher, ok := responseWriter.(http.Flusher)
if !ok { if !ok {
+45
View File
@@ -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("<!doctype html><html><body>hi</body></html>")
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) { func TestScreenshot_RejectsTraversalAndBadNames(t *testing.T) {
server, _ := newFixtureServer(t) server, _ := newFixtureServer(t)
cases := []string{ cases := []string{