From c5b622b3566df247ee383b0b1bbf21350f3d0cb9 Mon Sep 17 00:00:00 2001 From: aprv10 <1apoorvindia@gmail.com> Date: Sun, 19 Jul 2026 08:37:55 +0530 Subject: [PATCH 1/4] fix(preview): resolve root-relative static assets --- .../internal/httpd/controllers/sessions.go | 81 +++++++++ .../httpd/controllers/sessions_test.go | 170 ++++++++++++++++-- backend/internal/httpd/cors.go | 8 +- backend/internal/httpd/cors_test.go | 14 ++ backend/internal/httpd/router.go | 15 +- backend/internal/preview/entry.go | 60 +++++-- backend/internal/preview/entry_test.go | 66 +++++++ backend/internal/preview/poller.go | 3 + backend/internal/preview/poller_test.go | 25 ++- 9 files changed, 408 insertions(+), 34 deletions(-) diff --git a/backend/internal/httpd/controllers/sessions.go b/backend/internal/httpd/controllers/sessions.go index 82cc9ed753..49769393ae 100644 --- a/backend/internal/httpd/controllers/sessions.go +++ b/backend/internal/httpd/controllers/sessions.go @@ -200,6 +200,87 @@ func (c *SessionsController) previewFile(w http.ResponseWriter, r *http.Request) http.ServeFile(w, r, file) } +// PreviewOrigin serves a workspace preview from its isolated *.localhost +// origin. It returns false when the request host is not a preview origin so the +// daemon router can continue handling its normal API and control surfaces. +// +// The selected entry's directory is mounted at the origin root. For example, +// dist/index.html is reachable at both /dist/ (the persisted URL) and /, while +// /assets/app.css maps to dist/assets/app.css. This mirrors a production static +// server and fixes root-relative URLs without rewriting user-generated files. +func (c *SessionsController) PreviewOrigin(w http.ResponseWriter, r *http.Request) bool { + id, ok := previewutil.SessionIDFromHost(r.Host) + if !ok { + return false + } + if r.Method != http.MethodGet && r.Method != http.MethodHead { + w.Header().Set("Allow", "GET, HEAD") + envelope.WriteAPIError(w, r, http.StatusMethodNotAllowed, "method_not_allowed", "METHOD_NOT_ALLOWED", + r.Method+" not allowed on preview origin", nil) + return true + } + if c.Svc == nil { + envelope.WriteAPIError(w, r, http.StatusNotFound, "not_found", "PREVIEW_NOT_FOUND", "Preview not found", nil) + return true + } + sess, err := c.Svc.Get(r.Context(), id) + if err != nil { + envelope.WriteError(w, r, err) + return true + } + entry, ok := previewOriginEntry(sess) + if !ok { + envelope.WriteAPIError(w, r, http.StatusNotFound, "not_found", "NO_PREVIEW_ENTRY", "No preview entry point found in session workspace", nil) + return true + } + asset := previewOriginAssetPath(entry, r.URL.Path) + file, ok := confinedPreviewPath(sess.Metadata.WorkspacePath, asset) + if !ok { + envelope.WriteAPIError(w, r, http.StatusNotFound, "not_found", "PREVIEW_FILE_NOT_FOUND", "Preview file not found", nil) + return true + } + if info, statErr := os.Stat(file); statErr != nil || info.IsDir() { + envelope.WriteAPIError(w, r, http.StatusNotFound, "not_found", "PREVIEW_FILE_NOT_FOUND", "Preview file not found", nil) + return true + } + if previewutil.IsMarkdownPath(file) { + c.servePreviewMarkdown(w, r, file) + return true + } + http.ServeFile(w, r, file) + return true +} + +func previewOriginEntry(sess domain.Session) (string, bool) { + if parsed, err := url.Parse(strings.TrimSpace(sess.Metadata.PreviewURL)); err == nil { + if id, ok := previewutil.SessionIDFromHost(parsed.Host); ok && id == sess.ID { + entry := strings.TrimPrefix(path.Clean("/"+parsed.Path), "/") + if file, confined := confinedPreviewPath(sess.Metadata.WorkspacePath, entry); confined { + if info, statErr := os.Stat(file); statErr == nil && !info.IsDir() { + return entry, true + } + } + } + } + return discoverPreviewEntry(sess.Metadata.WorkspacePath) +} + +func previewOriginAssetPath(entry, requestPath string) string { + requested := strings.TrimPrefix(path.Clean("/"+requestPath), "/") + if requested == "" || requested == "." { + return entry + } + root := path.Dir(entry) + if root == "." { + return requested + } + if requested == root { + return entry + } + requested = strings.TrimPrefix(requested, root+"/") + return path.Join(root, requested) +} + // servePreviewMarkdown renders a workspace Markdown file to a self-contained // HTML document so the browser panel displays formatted content instead of raw // source. diff --git a/backend/internal/httpd/controllers/sessions_test.go b/backend/internal/httpd/controllers/sessions_test.go index aec4a7ca9f..cb42edbd67 100644 --- a/backend/internal/httpd/controllers/sessions_test.go +++ b/backend/internal/httpd/controllers/sessions_test.go @@ -253,6 +253,30 @@ func newSessionTestServer(t *testing.T, svc *fakeSessionService) *httptest.Serve return srv } +func doPreviewOriginRequest(t *testing.T, srv *httptest.Server, previewURL, requestPath string) ([]byte, int, http.Header) { + t.Helper() + preview, err := url.Parse(previewURL) + if err != nil { + t.Fatalf("parse preview URL: %v", err) + } + req, err := http.NewRequest(http.MethodGet, srv.URL+requestPath, nil) + if err != nil { + t.Fatalf("new preview request: %v", err) + } + req.Host = preview.Host + req.Header.Set("Origin", preview.Scheme+"://"+preview.Host) + resp, err := srv.Client().Do(req) + if err != nil { + t.Fatalf("do preview request: %v", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read preview response: %v", err) + } + return body, resp.StatusCode, resp.Header +} + func TestSessionsRoutes_DefaultToStubsWithoutService(t *testing.T) { log := slog.New(slog.NewTextHandler(io.Discard, nil)) srv := httptest.NewServer(httpd.NewRouterWithControl(config.Config{}, log, nil, httpd.APIDeps{}, httpd.ControlDeps{})) @@ -394,14 +418,14 @@ func TestSessionsAPI_PreviewDiscoversAndServesStaticIndex(t *testing.T) { if strings.Contains(preview.PreviewURL, workspace) { t.Fatalf("preview leaked workspace path: %s", preview.PreviewURL) } - if !strings.Contains(preview.PreviewURL, "/index.html") { - t.Fatalf("preview URL = %q, want index.html asset path", preview.PreviewURL) - } parsed, err := url.Parse(preview.PreviewURL) if err != nil { t.Fatalf("parse preview URL: %v", err) } - body, status, headers := doRequest(t, srv, "GET", parsed.RequestURI(), "") + if !strings.HasSuffix(parsed.Hostname(), ".localhost") || parsed.Path != "/index.html" { + t.Fatalf("preview URL = %q, want isolated localhost origin ending in /index.html", preview.PreviewURL) + } + body, status, headers := doPreviewOriginRequest(t, srv, preview.PreviewURL, "/") if status != http.StatusOK { t.Fatalf("preview file = %d, want 200; body=%s", status, body) } @@ -487,8 +511,8 @@ func TestSessionsAPI_SetPreviewEmptyURLPrefersWorkspaceEntryOverExistingTarget(t } `json:"session"` } mustJSON(t, body, &resp) - if !strings.HasSuffix(resp.Session.PreviewURL, "/preview/files/index.html") { - t.Fatalf("response previewUrl = %q, want workspace index files URL", resp.Session.PreviewURL) + if !strings.HasSuffix(resp.Session.PreviewURL, "/index.html") { + t.Fatalf("response previewUrl = %q, want workspace index preview URL", resp.Session.PreviewURL) } } @@ -513,8 +537,8 @@ func TestSessionsAPI_SetPreviewEmptyURLNormalizesExistingRelativeTarget(t *testi } `json:"session"` } mustJSON(t, body, &resp) - if !strings.HasSuffix(resp.Session.PreviewURL, "/preview/files/index.html") { - t.Fatalf("response previewUrl = %q, want index.html files URL", resp.Session.PreviewURL) + if !strings.HasSuffix(resp.Session.PreviewURL, "/index.html") { + t.Fatalf("response previewUrl = %q, want index.html preview URL", resp.Session.PreviewURL) } if got := svc.sessions["ao-1"].Metadata.PreviewURL; got != resp.Session.PreviewURL { t.Fatalf("persisted previewUrl = %q, want normalized response URL %q", got, resp.Session.PreviewURL) @@ -543,7 +567,7 @@ func TestSessionsAPI_SetPreviewEmptyURLReusesExistingTargetWhenNoEntryExists(t * } } -func TestSessionsAPI_SetPreviewLocalRelativePathResolvesToFilesURL(t *testing.T) { +func TestSessionsAPI_SetPreviewLocalRelativePathResolvesToPreviewOrigin(t *testing.T) { svc := newFakeSessionService() workspace := t.TempDir() if err := os.MkdirAll(filepath.Join(workspace, "dist"), 0o755); err != nil { @@ -567,23 +591,135 @@ func TestSessionsAPI_SetPreviewLocalRelativePathResolvesToFilesURL(t *testing.T) } `json:"session"` } mustJSON(t, body, &resp) - if !strings.HasSuffix(resp.Session.PreviewURL, "/preview/files/dist/index.html") { - t.Fatalf("response previewUrl = %q, want dist/index.html files URL", resp.Session.PreviewURL) + if !strings.HasSuffix(resp.Session.PreviewURL, "/dist/index.html") { + t.Fatalf("response previewUrl = %q, want dist/index.html preview URL", resp.Session.PreviewURL) } if strings.Contains(resp.Session.PreviewURL, workspace) { t.Fatalf("preview leaked workspace path: %s", resp.Session.PreviewURL) } - // The resolved files URL actually serves the local file. - parsed, err := url.Parse(resp.Session.PreviewURL) - if err != nil { - t.Fatalf("parse preview URL: %v", err) - } - fileBody, fileStatus, _ := doRequest(t, srv, "GET", parsed.RequestURI(), "") + // The resolved preview origin actually serves the local file. + fileBody, fileStatus, _ := doPreviewOriginRequest(t, srv, resp.Session.PreviewURL, "/") if fileStatus != http.StatusOK { t.Fatalf("serve local file = %d, want 200; body=%s", fileStatus, fileBody) } } +func TestSessionsAPI_PreviewOriginResolvesRootRelativeAssetsFromEntryDirectory(t *testing.T) { + svc := newFakeSessionService() + workspace := t.TempDir() + for _, dir := range []string{ + filepath.Join(workspace, "dist", "assets"), + filepath.Join(workspace, "dist", "fonts"), + filepath.Join(workspace, "assets"), + } { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir %s: %v", dir, err) + } + } + files := map[string]string{ + filepath.Join(workspace, "dist", "index.html"): ``, + filepath.Join(workspace, "dist", "assets", "app.css"): `@font-face { src: url("/fonts/app.woff2") }`, + filepath.Join(workspace, "dist", "assets", "app.js"): `document.body.dataset.loaded = "yes"`, + filepath.Join(workspace, "dist", "fonts", "app.woff2"): "preview-font", + // A conflicting workspace-root asset proves /assets is mounted relative + // to the selected deployment directory, not the workspace root. + filepath.Join(workspace, "assets", "app.css"): "wrong-root", + } + for file, contents := range files { + if err := os.WriteFile(file, []byte(contents), 0o644); err != nil { + t.Fatalf("write %s: %v", file, err) + } + } + s := svc.sessions["ao-1"] + s.Metadata = domain.SessionMetadata{WorkspacePath: workspace} + svc.sessions["ao-1"] = s + srv := newSessionTestServer(t, svc) + + body, status, _ := doRequest(t, srv, http.MethodPost, "/api/v1/sessions/ao-1/preview", `{"url":"./dist/index.html"}`) + if status != http.StatusOK { + t.Fatalf("set preview = %d, want 200; body=%s", status, body) + } + var resp struct { + Session struct { + PreviewURL string `json:"previewUrl"` + } `json:"session"` + } + mustJSON(t, body, &resp) + + for requestPath, want := range map[string]string{ + "/": `/assets/app.css`, + "/assets/app.css": `/fonts/app.woff2`, + "/dist/assets/app.js": `dataset.loaded`, + "/fonts/app.woff2": `preview-font`, + } { + assetBody, assetStatus, _ := doPreviewOriginRequest(t, srv, resp.Session.PreviewURL, requestPath) + if assetStatus != http.StatusOK { + t.Errorf("GET %s = %d, want 200; body=%s", requestPath, assetStatus, assetBody) + continue + } + if !strings.Contains(string(assetBody), want) { + t.Errorf("GET %s body = %q, want content containing %q", requestPath, assetBody, want) + } + } + + // Retain the old API route as a compatibility surface for stored URLs and + // external callers while new previews use the isolated origin. + legacyBody, legacyStatus, _ := doRequest(t, srv, http.MethodGet, "/api/v1/sessions/ao-1/preview/files/dist/assets/app.css", "") + if legacyStatus != http.StatusOK || !strings.Contains(string(legacyBody), "/fonts/app.woff2") { + t.Fatalf("legacy preview route = %d, body=%q; want existing file response", legacyStatus, legacyBody) + } +} + +func TestSessionsAPI_PreviewOriginsIsolateConcurrentSessionsAndSurviveRouterRestart(t *testing.T) { + svc := newFakeSessionService() + previewURLs := make(map[domain.SessionID]string) + for _, tc := range []struct { + id domain.SessionID + content string + }{{id: "ao-1", content: "session-one"}, {id: "ao-2", content: "session-two"}} { + workspace := t.TempDir() + if err := os.WriteFile(filepath.Join(workspace, "index.html"), []byte(``), 0o644); err != nil { + t.Fatalf("write index: %v", err) + } + if err := os.WriteFile(filepath.Join(workspace, "theme.css"), []byte(tc.content), 0o644); err != nil { + t.Fatalf("write theme: %v", err) + } + s := domain.Session{SessionRecord: domain.SessionRecord{ID: tc.id, Kind: domain.KindWorker}} + s.Metadata = domain.SessionMetadata{WorkspacePath: workspace} + svc.sessions[tc.id] = s + } + + srv := newSessionTestServer(t, svc) + for id := range svc.sessions { + body, status, _ := doRequest(t, srv, http.MethodPost, "/api/v1/sessions/"+string(id)+"/preview", `{}`) + if status != http.StatusOK { + t.Fatalf("set preview %s = %d, want 200; body=%s", id, status, body) + } + var resp struct { + Session struct { + PreviewURL string `json:"previewUrl"` + } `json:"session"` + } + mustJSON(t, body, &resp) + previewURLs[id] = resp.Session.PreviewURL + } + + for id, want := range map[domain.SessionID]string{"ao-1": "session-one", "ao-2": "session-two"} { + body, status, _ := doPreviewOriginRequest(t, srv, previewURLs[id], "/theme.css") + if status != http.StatusOK || string(body) != want { + t.Fatalf("session %s asset = %d, %q; want 200, %q", id, status, body, want) + } + } + + // A new router has no in-memory preview registry. The persisted URL and + // session workspace are sufficient to reconstruct the same virtual root. + restarted := newSessionTestServer(t, svc) + body, status, _ := doPreviewOriginRequest(t, restarted, previewURLs["ao-1"], "/theme.css") + if status != http.StatusOK || string(body) != "session-one" { + t.Fatalf("asset after router restart = %d, %q; want 200, session-one", status, body) + } +} + func TestSessionsAPI_SetPreviewAbsoluteFilePathPersistsFileURL(t *testing.T) { svc := newFakeSessionService() file := filepath.Join(t.TempDir(), "implementation_plan.html") diff --git a/backend/internal/httpd/cors.go b/backend/internal/httpd/cors.go index 454bb6c68f..b5155bd9dc 100644 --- a/backend/internal/httpd/cors.go +++ b/backend/internal/httpd/cors.go @@ -83,8 +83,12 @@ func isLoopbackOrigin(origin string) bool { if err != nil || (u.Scheme != "http" && u.Scheme != "https") { return false } - host := u.Hostname() - if host == "localhost" { + host := strings.ToLower(strings.TrimSuffix(u.Hostname(), ".")) + // RFC 6761 reserves localhost and every name below it for loopback. + // Workspace previews use a per-session subdomain so browser-enforced CORS + // requests (ES modules and fetch) remain isolated without being rejected by + // the daemon's origin boundary. + if host == "localhost" || strings.HasSuffix(host, ".localhost") { return true } if ip := net.ParseIP(host); ip != nil { diff --git a/backend/internal/httpd/cors_test.go b/backend/internal/httpd/cors_test.go index 286778f410..14adc270dc 100644 --- a/backend/internal/httpd/cors_test.go +++ b/backend/internal/httpd/cors_test.go @@ -48,6 +48,13 @@ func TestCORS(t *testing.T) { wantStatus: http.StatusOK, wantACAO: "http://127.0.0.1:8080", }, + { + name: "isolated localhost preview origin allowed", + method: http.MethodGet, + headers: map[string]string{"Origin": "http://ao-preview.mfxs2mi.localhost:5181"}, + wantStatus: http.StatusOK, + wantACAO: "http://ao-preview.mfxs2mi.localhost:5181", + }, { // localhost in the host position of a non-loopback origin must not // fool the predicate. @@ -67,6 +74,13 @@ func TestCORS(t *testing.T) { wantStatus: http.StatusForbidden, wantACAO: "", }, + { + name: "localhost suffix lookalike rejected", + method: http.MethodGet, + headers: map[string]string{"Origin": "http://ao-preview.localhost.evil.example"}, + wantStatus: http.StatusForbidden, + wantACAO: "", + }, { name: "null origin is rejected", method: http.MethodGet, diff --git a/backend/internal/httpd/router.go b/backend/internal/httpd/router.go index 2380a240a2..60fd1a5c51 100644 --- a/backend/internal/httpd/router.go +++ b/backend/internal/httpd/router.go @@ -46,12 +46,14 @@ type ControlDeps struct { func NewRouterWithControl(cfg config.Config, log *slog.Logger, termMgr *terminal.Manager, deps APIDeps, control ControlDeps) chi.Router { log = loggerOrDefault(log) r := chi.NewRouter() + api := NewAPI(cfg, deps) r.Use(middleware.RequestID) r.Use(middleware.RealIP) r.Use(requestLogger(log, deps.Telemetry)) r.Use(recoverTelemetry(log, deps.Telemetry)) r.Use(corsMiddleware(cfg.AllowedOrigins)) + r.Use(previewOriginMiddleware(api.sessions)) // JSON envelopes for unmatched routes / methods — chi's defaults are // text/plain, which would break consumers that parse every response as @@ -64,11 +66,22 @@ func NewRouterWithControl(cfg config.Config, log *slog.Logger, termMgr *terminal mountControl(r, control) mountTelemetry(r, deps.Telemetry) mountMobile(r, deps.Mobile) - NewAPI(cfg, deps).Register(r) + api.Register(r) return r } +func previewOriginMiddleware(sessions *controllers.SessionsController) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if sessions != nil && sessions.PreviewOrigin(w, r) { + return + } + next.ServeHTTP(w, r) + }) + } +} + // mountHealth registers the liveness and readiness probes the Electron // supervisor polls before letting the renderer connect. func mountHealth(r chi.Router) { diff --git a/backend/internal/preview/entry.go b/backend/internal/preview/entry.go index 0b2a2bbf5f..c26b0f0bd7 100644 --- a/backend/internal/preview/entry.go +++ b/backend/internal/preview/entry.go @@ -1,17 +1,22 @@ package preview import ( + "encoding/base32" "io/fs" + "net" "net/url" "os" "path" "path/filepath" "strings" "time" + "unicode/utf8" "github.com/aoagents/agent-orchestrator/backend/internal/domain" ) +const previewHostLabel = "ao-preview" + var entryCandidates = []string{"index.html", "public/index.html", "dist/index.html", "build/index.html"} // previewableExts are the file extensions the browser panel can render: HTML @@ -155,15 +160,58 @@ func ConfinedPath(workspacePath, assetPath string) (string, bool) { return absFile, true } -// FileURL builds the daemon preview/files URL for a workspace-local entry. +// FileURL builds an isolated localhost origin for a workspace-local entry. +// Mounting the entry directory at the origin root makes both relative and +// root-relative browser requests resolve inside the preview instead of falling +// through to the daemon's API origin. func FileURL(baseURL string, id domain.SessionID, entry string) string { u := normalizedBaseURL(baseURL) - u.Path = "/api/v1/sessions/" + url.PathEscape(string(id)) + "/preview/files/" + escapePath(entry) + u.Host = previewHost(u, id) + // URL.String escapes Path exactly once. Supplying an already-escaped path + // here would turn spaces into %2520 and make otherwise valid workspace files + // impossible to resolve. + u.Path = path.Clean("/" + strings.TrimSpace(entry)) u.RawQuery = "" u.Fragment = "" return u.String() } +// SessionIDFromHost decodes the session identity carried by a FileURL host. +// The labels use unpadded base32 so arbitrary session IDs remain DNS-safe. +func SessionIDFromHost(rawHost string) (domain.SessionID, bool) { + host := rawHost + if parsedHost, _, err := net.SplitHostPort(rawHost); err == nil { + host = parsedHost + } + labels := strings.Split(strings.TrimSuffix(strings.ToLower(host), "."), ".") + if len(labels) < 3 || labels[0] != previewHostLabel || labels[len(labels)-1] != "localhost" { + return "", false + } + encoded := strings.Join(labels[1:len(labels)-1], "") + decoded, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(encoded)) + if err != nil || len(decoded) == 0 || !utf8.Valid(decoded) { + return "", false + } + return domain.SessionID(decoded), true +} + +func previewHost(u url.URL, id domain.SessionID) string { + encoded := strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString([]byte(id))) + labels := []string{previewHostLabel} + const maxChunk = 50 + for len(encoded) > 0 { + n := min(len(encoded), maxChunk) + labels = append(labels, encoded[:n]) + encoded = encoded[n:] + } + labels = append(labels, "localhost") + host := strings.Join(labels, ".") + if port := u.Port(); port != "" { + return host + ":" + port + } + return host +} + func normalizedBaseURL(raw string) url.URL { raw = strings.TrimRight(strings.TrimSpace(raw), "/") if raw == "" { @@ -178,11 +226,3 @@ func normalizedBaseURL(raw string) url.URL { } return *u } - -func escapePath(raw string) string { - parts := strings.Split(raw, "/") - for i, part := range parts { - parts[i] = url.PathEscape(part) - } - return strings.Join(parts, "/") -} diff --git a/backend/internal/preview/entry_test.go b/backend/internal/preview/entry_test.go index 705f6f1697..96eb02f27f 100644 --- a/backend/internal/preview/entry_test.go +++ b/backend/internal/preview/entry_test.go @@ -1,10 +1,14 @@ package preview import ( + "net/url" "os" "path/filepath" + "strings" "testing" "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" ) func writeEntryFile(t *testing.T, path, contents string, mod time.Time) { @@ -120,3 +124,65 @@ func TestIsMarkdownPath(t *testing.T) { } } } + +func TestFileURLUsesIsolatedLocalhostOrigin(t *testing.T) { + id := domain.SessionID("ao-1") + raw := FileURL("http://127.0.0.1:3001", id, "dist/index.html") + parsed, err := url.Parse(raw) + if err != nil { + t.Fatalf("parse FileURL: %v", err) + } + if parsed.Scheme != "http" || parsed.Port() != "3001" { + t.Fatalf("FileURL = %q, want http on daemon port", raw) + } + if !strings.HasSuffix(parsed.Hostname(), ".localhost") { + t.Fatalf("FileURL host = %q, want isolated .localhost origin", parsed.Hostname()) + } + if parsed.Path != "/dist/index.html" { + t.Fatalf("FileURL path = %q, want /dist/index.html", parsed.Path) + } + decoded, ok := SessionIDFromHost(parsed.Host) + if !ok || decoded != id { + t.Fatalf("SessionIDFromHost(%q) = %q, %v; want %q, true", parsed.Host, decoded, ok, id) + } +} + +func TestSessionIDFromHostSupportsLongUnicodeIDs(t *testing.T) { + id := domain.SessionID(strings.Repeat("worker-", 12) + "雪") + raw := FileURL("http://localhost:4321", id, "index.html") + parsed, err := url.Parse(raw) + if err != nil { + t.Fatalf("parse FileURL: %v", err) + } + for _, label := range strings.Split(parsed.Hostname(), ".") { + if len(label) > 63 { + t.Fatalf("hostname label length = %d, want <= 63", len(label)) + } + } + decoded, ok := SessionIDFromHost(parsed.Host) + if !ok || decoded != id { + t.Fatalf("round trip = %q, %v; want %q, true", decoded, ok, id) + } +} + +func TestFileURLPreservesSpecialCharactersInEntryPath(t *testing.T) { + raw := FileURL("http://127.0.0.1:3001", "ao-1", "dist/my report #1.html") + parsed, err := url.Parse(raw) + if err != nil { + t.Fatalf("parse FileURL: %v", err) + } + if parsed.Path != "/dist/my report #1.html" { + t.Fatalf("FileURL path = %q, want decoded workspace path", parsed.Path) + } + if strings.Contains(raw, "%2520") || strings.Contains(raw, "%2523") { + t.Fatalf("FileURL = %q, path was double-escaped", raw) + } +} + +func TestSessionIDFromHostRejectsOrdinaryHosts(t *testing.T) { + for _, host := range []string{"127.0.0.1:3001", "localhost:3001", "ao-preview.invalid.localhost:3001", "example.com"} { + if id, ok := SessionIDFromHost(host); ok { + t.Errorf("SessionIDFromHost(%q) = %q, true; want false", host, id) + } + } +} diff --git a/backend/internal/preview/poller.go b/backend/internal/preview/poller.go index b06c82a099..8b8b13848f 100644 --- a/backend/internal/preview/poller.go +++ b/backend/internal/preview/poller.go @@ -172,6 +172,9 @@ func isWorkspacePreviewURL(raw string, id domain.SessionID) bool { if err != nil { return false } + if originID, ok := SessionIDFromHost(parsed.Host); ok { + return originID == id + } previewPath := parsed.Path if previewPath == "" { previewPath = raw diff --git a/backend/internal/preview/poller_test.go b/backend/internal/preview/poller_test.go index 3090383b65..d5226a2d85 100644 --- a/backend/internal/preview/poller_test.go +++ b/backend/internal/preview/poller_test.go @@ -50,7 +50,7 @@ func TestPollerSetsPreviewWhenActiveWorkerEntryAppears(t *testing.T) { assertSets(t, svc.sets, previewSet{ id: "ao-1", - url: "http://127.0.0.1:3001/api/v1/sessions/ao-1/preview/files/index.html", + url: FileURL("http://127.0.0.1:3001", "ao-1", "index.html"), }) } @@ -66,7 +66,7 @@ func TestPollerUsesFirstExistingEntrypoint(t *testing.T) { assertSets(t, svc.sets, previewSet{ id: "ao-1", - url: "http://127.0.0.1:3001/api/v1/sessions/ao-1/preview/files/dist/index.html", + url: FileURL("http://127.0.0.1:3001", "ao-1", "dist/index.html"), }) } @@ -83,7 +83,7 @@ func TestPollerPreservesEntrypointPriority(t *testing.T) { assertSets(t, svc.sets, previewSet{ id: "ao-1", - url: "http://127.0.0.1:3001/api/v1/sessions/ao-1/preview/files/public/index.html", + url: FileURL("http://127.0.0.1:3001", "ao-1", "public/index.html"), }) } @@ -129,7 +129,7 @@ func TestPollerRediscoverEntryAfterDeleteAndRecreate(t *testing.T) { if err := poller.Poll(context.Background()); err != nil { t.Fatalf("first Poll: %v", err) } - wantURL := "http://127.0.0.1:3001/api/v1/sessions/ao-1/preview/files/index.html" + wantURL := FileURL("http://127.0.0.1:3001", "ao-1", "index.html") assertSets(t, svc.sets, previewSet{id: "ao-1", url: wantURL}) // Delete the entry — poller must clear the preview and mark the session cleared. @@ -191,6 +191,23 @@ func TestPollerDoesNotOverrideExplicitPreviewTarget(t *testing.T) { } } +func TestPollerMigratesLegacyWorkspacePreviewURL(t *testing.T) { + workspace := t.TempDir() + writeFile(t, filepath.Join(workspace, "index.html"), "
hello
") + legacy := "http://127.0.0.1:3001/api/v1/sessions/ao-1/preview/files/index.html" + svc := &fakePreviewSessions{sessions: []domain.SessionRecord{workerSession("ao-1", workspace, legacy)}} + poller := NewPoller(svc, svc, "http://127.0.0.1:3001", PollerConfig{Logger: discardLogger()}) + + if err := poller.Poll(context.Background()); err != nil { + t.Fatalf("Poll: %v", err) + } + + assertSets(t, svc.sets, previewSet{ + id: "ao-1", + url: FileURL("http://127.0.0.1:3001", "ao-1", "index.html"), + }) +} + func TestPollerSkipsNonWorkerSessions(t *testing.T) { workspace := t.TempDir() writeFile(t, filepath.Join(workspace, "index.html"), "
hello
") From 597dd75fa4ae5422d9b8ab4c60bb228909a78638 Mon Sep 17 00:00:00 2001 From: aprv10 <1apoorvindia@gmail.com> Date: Sun, 19 Jul 2026 08:47:40 +0530 Subject: [PATCH 2/4] fix(preview): fix lint errors --- backend/internal/preview/entry.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/internal/preview/entry.go b/backend/internal/preview/entry.go index c26b0f0bd7..c0f3d9281a 100644 --- a/backend/internal/preview/entry.go +++ b/backend/internal/preview/entry.go @@ -199,7 +199,7 @@ func previewHost(u url.URL, id domain.SessionID) string { encoded := strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString([]byte(id))) labels := []string{previewHostLabel} const maxChunk = 50 - for len(encoded) > 0 { + for encoded != "" { n := min(len(encoded), maxChunk) labels = append(labels, encoded[:n]) encoded = encoded[n:] From e7efd7a1aec66c4b0a0d3e4398c4125872f361e7 Mon Sep 17 00:00:00 2001 From: aprv10 <1apoorvindia@gmail.com> Date: Mon, 20 Jul 2026 14:35:54 +0530 Subject: [PATCH 3/4] fix(preview): harden isolated asset serving --- backend/internal/daemon/daemon.go | 3 +- backend/internal/httpd/addr_in_use_unix.go | 12 ++ backend/internal/httpd/addr_in_use_windows.go | 13 ++ .../internal/httpd/controllers/sessions.go | 109 +++++++-------- .../httpd/controllers/sessions_test.go | 125 +++++++++++++++++- backend/internal/httpd/server.go | 3 +- backend/internal/preview/entry.go | 75 +++++++---- backend/internal/preview/entry_test.go | 81 +++++++++++- backend/internal/preview/poller.go | 58 +++----- backend/internal/preview/poller_test.go | 48 ++++++- backend/internal/preview/stored_entry.go | 58 ++++++++ backend/internal/preview/workspace_file.go | 70 ++++++++++ 12 files changed, 516 insertions(+), 139 deletions(-) create mode 100644 backend/internal/httpd/addr_in_use_unix.go create mode 100644 backend/internal/httpd/addr_in_use_windows.go create mode 100644 backend/internal/preview/stored_entry.go create mode 100644 backend/internal/preview/workspace_file.go diff --git a/backend/internal/daemon/daemon.go b/backend/internal/daemon/daemon.go index 4facb17dd3..d2c25c6188 100644 --- a/backend/internal/daemon/daemon.go +++ b/backend/internal/daemon/daemon.go @@ -133,7 +133,6 @@ func Run() error { return fmt.Errorf("wire session service: %w", err) } lcStack.trackerDone = startTrackerIntake(ctx, store, sessionSvc, log) - previewDone := preview.NewPoller(store, sessionSvc, "http://"+cfg.Addr(), preview.PollerConfig{Logger: log}).Start(ctx) agentSvc := agentsvc.New() go func() { if _, err := agentSvc.Refresh(ctx); err != nil { @@ -169,13 +168,13 @@ func Run() error { }) if err != nil { stop() - <-previewDone lcStack.Stop() if cdcErr := cdcPipe.Stop(); cdcErr != nil { log.Error("cdc pipeline shutdown", "err", cdcErr) } return err } + previewDone := preview.NewPoller(store, sessionSvc, "http://"+srv.Addr().String(), preview.PollerConfig{Logger: log}).Start(ctx) // Late-bind: the LAN listener shares the exact loopback router instance so // the LAN surface and loopback surface never drift apart. diff --git a/backend/internal/httpd/addr_in_use_unix.go b/backend/internal/httpd/addr_in_use_unix.go new file mode 100644 index 0000000000..c45bd4b89c --- /dev/null +++ b/backend/internal/httpd/addr_in_use_unix.go @@ -0,0 +1,12 @@ +//go:build !windows + +package httpd + +import ( + "errors" + "syscall" +) + +func isAddrInUse(err error) bool { + return errors.Is(err, syscall.EADDRINUSE) +} diff --git a/backend/internal/httpd/addr_in_use_windows.go b/backend/internal/httpd/addr_in_use_windows.go new file mode 100644 index 0000000000..61250239d0 --- /dev/null +++ b/backend/internal/httpd/addr_in_use_windows.go @@ -0,0 +1,13 @@ +//go:build windows + +package httpd + +import ( + "errors" + + "golang.org/x/sys/windows" +) + +func isAddrInUse(err error) bool { + return errors.Is(err, windows.WSAEADDRINUSE) +} diff --git a/backend/internal/httpd/controllers/sessions.go b/backend/internal/httpd/controllers/sessions.go index 49769393ae..792b6efa52 100644 --- a/backend/internal/httpd/controllers/sessions.go +++ b/backend/internal/httpd/controllers/sessions.go @@ -3,6 +3,7 @@ package controllers import ( "context" "errors" + "io" "net/http" "net/url" "os" @@ -173,7 +174,11 @@ func (c *SessionsController) preview(w http.ResponseWriter, r *http.Request) { res := SessionPreviewResponse{SessionID: sessionID(r)} if ok { res.Entry = entry - res.PreviewURL = previewFileURL(r, sessionID(r), entry) + res.PreviewURL, err = previewFileURL(r, sessionID(r), entry) + if err != nil { + writePreviewResolveError(w, r, err) + return + } } envelope.WriteJSON(w, http.StatusOK, res) } @@ -188,16 +193,7 @@ func (c *SessionsController) previewFile(w http.ResponseWriter, r *http.Request) envelope.WriteError(w, r, err) return } - file, ok := confinedPreviewPath(sess.Metadata.WorkspacePath, chi.URLParam(r, "*")) - if !ok { - envelope.WriteAPIError(w, r, http.StatusNotFound, "not_found", "PREVIEW_FILE_NOT_FOUND", "Preview file not found", nil) - return - } - if previewutil.IsMarkdownPath(file) { - c.servePreviewMarkdown(w, r, file) - return - } - http.ServeFile(w, r, file) + c.serveWorkspacePreviewFile(w, r, sess.Metadata.WorkspacePath, chi.URLParam(r, "*")) } // PreviewOrigin serves a workspace preview from its isolated *.localhost @@ -234,32 +230,14 @@ func (c *SessionsController) PreviewOrigin(w http.ResponseWriter, r *http.Reques return true } asset := previewOriginAssetPath(entry, r.URL.Path) - file, ok := confinedPreviewPath(sess.Metadata.WorkspacePath, asset) - if !ok { - envelope.WriteAPIError(w, r, http.StatusNotFound, "not_found", "PREVIEW_FILE_NOT_FOUND", "Preview file not found", nil) - return true - } - if info, statErr := os.Stat(file); statErr != nil || info.IsDir() { - envelope.WriteAPIError(w, r, http.StatusNotFound, "not_found", "PREVIEW_FILE_NOT_FOUND", "Preview file not found", nil) - return true - } - if previewutil.IsMarkdownPath(file) { - c.servePreviewMarkdown(w, r, file) - return true - } - http.ServeFile(w, r, file) + c.serveWorkspacePreviewFile(w, r, sess.Metadata.WorkspacePath, asset) return true } func previewOriginEntry(sess domain.Session) (string, bool) { - if parsed, err := url.Parse(strings.TrimSpace(sess.Metadata.PreviewURL)); err == nil { - if id, ok := previewutil.SessionIDFromHost(parsed.Host); ok && id == sess.ID { - entry := strings.TrimPrefix(path.Clean("/"+parsed.Path), "/") - if file, confined := confinedPreviewPath(sess.Metadata.WorkspacePath, entry); confined { - if info, statErr := os.Stat(file); statErr == nil && !info.IsDir() { - return entry, true - } - } + if entry, ok := previewutil.StoredWorkspaceEntry(sess.Metadata.PreviewURL, sess.ID); ok { + if stored, exists := previewutil.EntryAtPath(sess.Metadata.WorkspacePath, entry); exists { + return stored.Path, true } } return discoverPreviewEntry(sess.Metadata.WorkspacePath) @@ -281,23 +259,35 @@ func previewOriginAssetPath(entry, requestPath string) string { return path.Join(root, requested) } -// servePreviewMarkdown renders a workspace Markdown file to a self-contained -// HTML document so the browser panel displays formatted content instead of raw -// source. -func (c *SessionsController) servePreviewMarkdown(w http.ResponseWriter, r *http.Request, file string) { - source, err := os.ReadFile(file) +// serveWorkspacePreviewFile is the single serving path for both the legacy API +// route and isolated preview origins. OpenRoot keeps symlink traversal and the +// subsequent read on the same workspace-confined file handle. +func (c *SessionsController) serveWorkspacePreviewFile(w http.ResponseWriter, r *http.Request, workspacePath, assetPath string) { + file, info, clean, err := previewutil.OpenWorkspaceFile(workspacePath, assetPath) if err != nil { envelope.WriteAPIError(w, r, http.StatusNotFound, "not_found", "PREVIEW_FILE_NOT_FOUND", "Preview file not found", nil) return } - rendered, err := previewutil.RenderMarkdown(source, filepath.Base(file)) + defer func() { _ = file.Close() }() + if !previewutil.IsMarkdownPath(clean) { + http.ServeContent(w, r, info.Name(), info.ModTime(), file) + return + } + + source, err := io.ReadAll(file) + if err != nil { + envelope.WriteAPIError(w, r, http.StatusNotFound, "not_found", "PREVIEW_FILE_NOT_FOUND", "Preview file not found", nil) + return + } + rendered, err := previewutil.RenderMarkdown(source, filepath.Base(clean)) if err != nil { envelope.WriteError(w, r, err) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") - - _, _ = w.Write(rendered) //nolint:gosec // G705: preview content is workspace-local and agent-trusted + if r.Method != http.MethodHead { + _, _ = w.Write(rendered) //nolint:gosec // G705: preview content is workspace-local and agent-trusted + } } func (c *SessionsController) listWorkspaceFiles(w http.ResponseWriter, r *http.Request) { @@ -365,7 +355,11 @@ func (c *SessionsController) setPreview(w http.ResponseWriter, r *http.Request) previewURL := strings.TrimSpace(in.URL) if previewURL == "" { if entry, ok := discoverPreviewEntry(sess.Metadata.WorkspacePath); ok { - previewURL = previewFileURL(r, sessionID(r), entry) + previewURL, err = previewFileURL(r, sessionID(r), entry) + if err != nil { + writePreviewResolveError(w, r, err) + return + } } else if existing := strings.TrimSpace(sess.Metadata.PreviewURL); existing != "" { var resolveErr error previewURL, resolveErr = resolvePreviewTarget(r, sessionID(r), sess.Metadata.WorkspacePath, existing) @@ -746,21 +740,16 @@ func discoverPreviewEntry(workspacePath string) (string, bool) { // that already looks like a URL (an http(s)/file scheme, or a host:port dev // server) and for paths that escape the workspace or do not point at a file, so // the caller keeps those targets verbatim. -func resolveLocalPreview(r *http.Request, id domain.SessionID, workspacePath, raw string) (string, bool) { - raw = strings.TrimSpace(raw) +func resolveLocalPreview(r *http.Request, id domain.SessionID, workspacePath, raw string) (string, bool, error) { if raw == "" || hasURLScheme(raw) { - return "", false + return "", false, nil } - file, ok := confinedPreviewPath(workspacePath, raw) + entry, ok := previewutil.EntryAtPath(workspacePath, raw) if !ok { - return "", false - } - info, err := os.Stat(file) - if err != nil || info.IsDir() { - return "", false + return "", false, nil } - entry := strings.TrimPrefix(path.Clean("/"+raw), "/") - return previewFileURL(r, id, entry), true + resolved, err := previewFileURL(r, id, entry.Path) + return resolved, true, err } func resolvePreviewTarget(r *http.Request, id domain.SessionID, workspacePath, raw string) (string, error) { @@ -768,8 +757,8 @@ func resolvePreviewTarget(r *http.Request, id domain.SessionID, workspacePath, r if isAbsolutePreviewPath(raw) { return absolutePreviewFileURL(raw) } - if resolved, ok := resolveLocalPreview(r, id, workspacePath, raw); ok { - return resolved, nil + if resolved, ok, err := resolveLocalPreview(r, id, workspacePath, raw); ok || err != nil { + return resolved, err } return raw, nil } @@ -803,6 +792,10 @@ func writePreviewResolveError(w http.ResponseWriter, r *http.Request, err error) envelope.WriteAPIError(w, r, http.StatusNotFound, "not_found", "PREVIEW_FILE_NOT_FOUND", "Preview file not found", nil) return } + if errors.Is(err, previewutil.ErrPreviewHostUnsupported) { + envelope.WriteAPIError(w, r, http.StatusUnprocessableEntity, "unprocessable", "PREVIEW_SESSION_ID_UNSUPPORTED", "Session ID is too long for an isolated preview hostname", nil) + return + } envelope.WriteError(w, r, err) } @@ -824,11 +817,7 @@ func hasURLScheme(raw string) bool { return false } -func confinedPreviewPath(workspacePath, assetPath string) (string, bool) { - return previewutil.ConfinedPath(workspacePath, assetPath) -} - -func previewFileURL(r *http.Request, id domain.SessionID, entry string) string { +func previewFileURL(r *http.Request, id domain.SessionID, entry string) (string, error) { return previewutil.FileURL("http://"+r.Host, id, entry) } diff --git a/backend/internal/httpd/controllers/sessions_test.go b/backend/internal/httpd/controllers/sessions_test.go index cb42edbd67..69ce830ec0 100644 --- a/backend/internal/httpd/controllers/sessions_test.go +++ b/backend/internal/httpd/controllers/sessions_test.go @@ -19,6 +19,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/httpd" "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apierr" "github.com/aoagents/agent-orchestrator/backend/internal/ports" + previewutil "github.com/aoagents/agent-orchestrator/backend/internal/preview" sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session" ) @@ -254,12 +255,16 @@ func newSessionTestServer(t *testing.T, svc *fakeSessionService) *httptest.Serve } func doPreviewOriginRequest(t *testing.T, srv *httptest.Server, previewURL, requestPath string) ([]byte, int, http.Header) { + return doPreviewOriginMethod(t, srv, http.MethodGet, previewURL, requestPath) +} + +func doPreviewOriginMethod(t *testing.T, srv *httptest.Server, method, previewURL, requestPath string) ([]byte, int, http.Header) { t.Helper() preview, err := url.Parse(previewURL) if err != nil { t.Fatalf("parse preview URL: %v", err) } - req, err := http.NewRequest(http.MethodGet, srv.URL+requestPath, nil) + req, err := http.NewRequest(method, srv.URL+requestPath, nil) if err != nil { t.Fatalf("new preview request: %v", err) } @@ -437,6 +442,20 @@ func TestSessionsAPI_PreviewDiscoversAndServesStaticIndex(t *testing.T) { } } +func TestSessionsAPI_PreviewRejectsSessionIDTooLongForHostname(t *testing.T) { + svc := newFakeSessionService() + id := domain.SessionID(strings.Repeat("x", 143)) + workspace := t.TempDir() + if err := os.WriteFile(filepath.Join(workspace, "index.html"), []byte("preview"), 0o644); err != nil { + t.Fatalf("write index: %v", err) + } + svc.sessions[id] = domain.Session{SessionRecord: domain.SessionRecord{ID: id, Kind: domain.KindWorker, Metadata: domain.SessionMetadata{WorkspacePath: workspace}}} + srv := newSessionTestServer(t, svc) + + body, status, _ := doRequest(t, srv, http.MethodGet, "/api/v1/sessions/"+string(id)+"/preview", "") + assertErrorCode(t, body, status, http.StatusUnprocessableEntity, "PREVIEW_SESSION_ID_UNSUPPORTED") +} + func TestSessionsAPI_SetPreviewExplicitURLPersists(t *testing.T) { svc := newFakeSessionService() srv := newSessionTestServer(t, svc) @@ -670,6 +689,110 @@ func TestSessionsAPI_PreviewOriginResolvesRootRelativeAssetsFromEntryDirectory(t } } +func TestSessionsAPI_PreviewOriginErrorContract(t *testing.T) { + svc := newFakeSessionService() + workspace := t.TempDir() + if err := os.WriteFile(filepath.Join(workspace, "index.html"), []byte("preview"), 0o644); err != nil { + t.Fatalf("write index: %v", err) + } + s := svc.sessions["ao-1"] + s.Metadata = domain.SessionMetadata{WorkspacePath: workspace} + svc.sessions["ao-1"] = s + srv := newSessionTestServer(t, svc) + validURL := mustPreviewFileURL(t, srv, "ao-1", "index.html") + + tests := []struct { + name string + method string + previewURL string + path string + wantStatus int + wantCode string + wantAllow string + }{ + {name: "method", method: http.MethodPost, previewURL: validURL, path: "/", wantStatus: http.StatusMethodNotAllowed, wantCode: "METHOD_NOT_ALLOWED", wantAllow: "GET, HEAD"}, + {name: "unknown session", method: http.MethodGet, previewURL: mustPreviewFileURL(t, srv, "ao-missing", "index.html"), path: "/", wantStatus: http.StatusNotFound, wantCode: "SESSION_NOT_FOUND"}, + {name: "missing asset", method: http.MethodGet, previewURL: validURL, path: "/missing.css", wantStatus: http.StatusNotFound, wantCode: "PREVIEW_FILE_NOT_FOUND"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + body, status, headers := doPreviewOriginMethod(t, srv, tc.method, tc.previewURL, tc.path) + if status != tc.wantStatus { + t.Fatalf("status = %d, want %d; body=%s", status, tc.wantStatus, body) + } + if got := headers.Get("Allow"); got != tc.wantAllow { + t.Fatalf("Allow = %q, want %q", got, tc.wantAllow) + } + var got struct { + Code string `json:"code"` + RequestID string `json:"requestId"` + } + mustJSON(t, body, &got) + if got.Code != tc.wantCode || got.RequestID == "" { + t.Fatalf("error = %#v, want code %q and requestId", got, tc.wantCode) + } + }) + } + + empty := t.TempDir() + s = svc.sessions["ao-1"] + s.Metadata = domain.SessionMetadata{WorkspacePath: empty} + svc.sessions["ao-1"] = s + body, status, _ := doPreviewOriginRequest(t, srv, validURL, "/") + assertErrorCode(t, body, status, http.StatusNotFound, "NO_PREVIEW_ENTRY") +} + +func TestSessionsAPI_PreviewRoutesRejectSymlinkOutsideWorkspace(t *testing.T) { + workspace := t.TempDir() + outside := filepath.Join(t.TempDir(), "secret.css") + if err := os.WriteFile(filepath.Join(workspace, "index.html"), []byte("preview"), 0o644); err != nil { + t.Fatalf("write index: %v", err) + } + if err := os.WriteFile(outside, []byte("must-not-leak"), 0o644); err != nil { + t.Fatalf("write outside file: %v", err) + } + link := filepath.Join(workspace, "escape.css") + if err := os.Symlink(outside, link); err != nil { + t.Skipf("symlinks unavailable on this platform: %v", err) + } + + svc := newFakeSessionService() + s := svc.sessions["ao-1"] + s.Metadata = domain.SessionMetadata{WorkspacePath: workspace} + svc.sessions["ao-1"] = s + srv := newSessionTestServer(t, svc) + previewURL := mustPreviewFileURL(t, srv, "ao-1", "index.html") + + for _, tc := range []struct { + name string + do func() ([]byte, int, http.Header) + }{ + {name: "isolated origin", do: func() ([]byte, int, http.Header) { + return doPreviewOriginRequest(t, srv, previewURL, "/escape.css") + }}, + {name: "legacy route", do: func() ([]byte, int, http.Header) { + return doRequest(t, srv, http.MethodGet, "/api/v1/sessions/ao-1/preview/files/escape.css", "") + }}, + } { + t.Run(tc.name, func(t *testing.T) { + body, status, _ := tc.do() + assertErrorCode(t, body, status, http.StatusNotFound, "PREVIEW_FILE_NOT_FOUND") + if strings.Contains(string(body), "must-not-leak") { + t.Fatalf("response leaked file outside workspace: %s", body) + } + }) + } +} + +func mustPreviewFileURL(t *testing.T, srv *httptest.Server, id domain.SessionID, entry string) string { + t.Helper() + raw, err := previewutil.FileURL(srv.URL, id, entry) + if err != nil { + t.Fatalf("FileURL: %v", err) + } + return raw +} + func TestSessionsAPI_PreviewOriginsIsolateConcurrentSessionsAndSurviveRouterRestart(t *testing.T) { svc := newFakeSessionService() previewURLs := make(map[domain.SessionID]string) diff --git a/backend/internal/httpd/server.go b/backend/internal/httpd/server.go index 4dbbe93b1e..0a6f8d0a1a 100644 --- a/backend/internal/httpd/server.go +++ b/backend/internal/httpd/server.go @@ -9,7 +9,6 @@ import ( "net/http" "os" "sync" - "syscall" "time" "github.com/aoagents/agent-orchestrator/backend/internal/config" @@ -46,7 +45,7 @@ func NewWithDeps(cfg config.Config, log *slog.Logger, termMgr *terminal.Manager, log = loggerOrDefault(log) ln, err := net.Listen("tcp", cfg.Addr()) if err != nil { - if !errors.Is(err, syscall.EADDRINUSE) { + if !isAddrInUse(err) { return nil, fmt.Errorf("bind %s: %w", cfg.Addr(), err) } // Configured port is taken by a non-AO process: retry on an ephemeral port. diff --git a/backend/internal/preview/entry.go b/backend/internal/preview/entry.go index c0f3d9281a..05e62534d5 100644 --- a/backend/internal/preview/entry.go +++ b/backend/internal/preview/entry.go @@ -2,10 +2,11 @@ package preview import ( "encoding/base32" + "errors" + "fmt" "io/fs" "net" "net/url" - "os" "path" "path/filepath" "strings" @@ -17,6 +18,10 @@ import ( const previewHostLabel = "ao-preview" +// ErrPreviewHostUnsupported indicates that a session ID cannot be represented +// by a standards-compliant localhost hostname. +var ErrPreviewHostUnsupported = errors.New("session ID is too long for a preview hostname") + var entryCandidates = []string{"index.html", "public/index.html", "dist/index.html", "build/index.html"} // previewableExts are the file extensions the browser panel can render: HTML @@ -50,13 +55,8 @@ func DiscoverEntry(workspacePath string) (Entry, bool) { return Entry{}, false } for _, candidate := range entryCandidates { - file, ok := ConfinedPath(workspacePath, candidate) - if !ok { - continue - } - info, err := os.Stat(file) - if err == nil && !info.IsDir() { - return Entry{Path: candidate, AbsPath: file, ModTime: info.ModTime(), Size: info.Size()}, true + if entry, ok := EntryAtPath(workspacePath, candidate); ok { + return entry, true } } return mostRecentPreviewable(workspacePath) @@ -92,19 +92,18 @@ func mostRecentPreviewable(workspacePath string) (Entry, bool) { if seen > maxPreviewWalkFiles { return filepath.SkipAll } - info, err := d.Info() - if err != nil { - //nolint:nilerr // skip this file, keep scanning the rest of the workspace - return nil - } rel, err := filepath.Rel(root, p) if err != nil { //nolint:nilerr // skip this file, keep scanning the rest of the workspace return nil } relSlash := filepath.ToSlash(rel) - if !found || newerPreviewable(info, relSlash, best) { - best = Entry{Path: relSlash, AbsPath: p, ModTime: info.ModTime(), Size: info.Size()} + entry, ok := EntryAtPath(root, relSlash) + if !ok { + return nil + } + if !found || newerPreviewable(entry, relSlash, best) { + best = entry found = true } return nil @@ -112,8 +111,8 @@ func mostRecentPreviewable(workspacePath string) (Entry, bool) { return best, found } -func newerPreviewable(info fs.FileInfo, relSlash string, best Entry) bool { - mod := info.ModTime() +func newerPreviewable(entry Entry, relSlash string, best Entry) bool { + mod := entry.ModTime if mod.After(best.ModTime) { return true } @@ -144,8 +143,11 @@ func ConfinedPath(workspacePath, assetPath string) (string, bool) { if err != nil || root == "" { return "", false } - clean := strings.TrimPrefix(path.Clean("/"+strings.TrimSpace(assetPath)), "/") - if clean == "" || clean == "." { + clean, ok := CleanWorkspacePath(assetPath) + if !ok { + if path.Clean("/"+filepath.ToSlash(assetPath)) != "/" { + return "", false + } clean = "index.html" } file := filepath.Join(root, filepath.FromSlash(clean)) @@ -164,16 +166,20 @@ func ConfinedPath(workspacePath, assetPath string) (string, bool) { // Mounting the entry directory at the origin root makes both relative and // root-relative browser requests resolve inside the preview instead of falling // through to the daemon's API origin. -func FileURL(baseURL string, id domain.SessionID, entry string) string { +func FileURL(baseURL string, id domain.SessionID, entry string) (string, error) { u := normalizedBaseURL(baseURL) - u.Host = previewHost(u, id) + host, err := previewHost(u, id) + if err != nil { + return "", err + } + u.Host = host // URL.String escapes Path exactly once. Supplying an already-escaped path // here would turn spaces into %2520 and make otherwise valid workspace files // impossible to resolve. - u.Path = path.Clean("/" + strings.TrimSpace(entry)) + u.Path = path.Clean("/" + entry) u.RawQuery = "" u.Fragment = "" - return u.String() + return u.String(), nil } // SessionIDFromHost decodes the session identity carried by a FileURL host. @@ -183,10 +189,19 @@ func SessionIDFromHost(rawHost string) (domain.SessionID, bool) { if parsedHost, _, err := net.SplitHostPort(rawHost); err == nil { host = parsedHost } - labels := strings.Split(strings.TrimSuffix(strings.ToLower(host), "."), ".") + host = strings.TrimSuffix(strings.ToLower(host), ".") + if host == "" || len(host) > 253 { + return "", false + } + labels := strings.Split(host, ".") if len(labels) < 3 || labels[0] != previewHostLabel || labels[len(labels)-1] != "localhost" { return "", false } + for _, label := range labels { + if label == "" || len(label) > 63 { + return "", false + } + } encoded := strings.Join(labels[1:len(labels)-1], "") decoded, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(encoded)) if err != nil || len(decoded) == 0 || !utf8.Valid(decoded) { @@ -195,7 +210,10 @@ func SessionIDFromHost(rawHost string) (domain.SessionID, bool) { return domain.SessionID(decoded), true } -func previewHost(u url.URL, id domain.SessionID) string { +func previewHost(u url.URL, id domain.SessionID) (string, error) { + if id == "" { + return "", fmt.Errorf("%w: empty session ID", ErrPreviewHostUnsupported) + } encoded := strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString([]byte(id))) labels := []string{previewHostLabel} const maxChunk = 50 @@ -206,10 +224,13 @@ func previewHost(u url.URL, id domain.SessionID) string { } labels = append(labels, "localhost") host := strings.Join(labels, ".") + if len(host) > 253 { + return "", fmt.Errorf("%w: encoded hostname is %d characters", ErrPreviewHostUnsupported, len(host)) + } if port := u.Port(); port != "" { - return host + ":" + port + return host + ":" + port, nil } - return host + return host, nil } func normalizedBaseURL(raw string) url.URL { diff --git a/backend/internal/preview/entry_test.go b/backend/internal/preview/entry_test.go index 96eb02f27f..3e6400d731 100644 --- a/backend/internal/preview/entry_test.go +++ b/backend/internal/preview/entry_test.go @@ -1,6 +1,7 @@ package preview import ( + "errors" "net/url" "os" "path/filepath" @@ -109,6 +110,14 @@ func TestDiscoverEntryEmptyWorkspace(t *testing.T) { } } +func TestCleanWorkspacePathRejectsTraversal(t *testing.T) { + for _, raw := range []string{"../secret.html", "assets/../../secret.html", `assets\..\..\secret.html`} { + if got, ok := CleanWorkspacePath(raw); ok { + t.Errorf("CleanWorkspacePath(%q) = %q, true; want false", raw, got) + } + } +} + func TestIsMarkdownPath(t *testing.T) { cases := map[string]bool{ "a.md": true, @@ -127,7 +136,7 @@ func TestIsMarkdownPath(t *testing.T) { func TestFileURLUsesIsolatedLocalhostOrigin(t *testing.T) { id := domain.SessionID("ao-1") - raw := FileURL("http://127.0.0.1:3001", id, "dist/index.html") + raw := mustFileURL(t, "http://127.0.0.1:3001", id, "dist/index.html") parsed, err := url.Parse(raw) if err != nil { t.Fatalf("parse FileURL: %v", err) @@ -149,7 +158,7 @@ func TestFileURLUsesIsolatedLocalhostOrigin(t *testing.T) { func TestSessionIDFromHostSupportsLongUnicodeIDs(t *testing.T) { id := domain.SessionID(strings.Repeat("worker-", 12) + "雪") - raw := FileURL("http://localhost:4321", id, "index.html") + raw := mustFileURL(t, "http://localhost:4321", id, "index.html") parsed, err := url.Parse(raw) if err != nil { t.Fatalf("parse FileURL: %v", err) @@ -166,7 +175,7 @@ func TestSessionIDFromHostSupportsLongUnicodeIDs(t *testing.T) { } func TestFileURLPreservesSpecialCharactersInEntryPath(t *testing.T) { - raw := FileURL("http://127.0.0.1:3001", "ao-1", "dist/my report #1.html") + raw := mustFileURL(t, "http://127.0.0.1:3001", "ao-1", "dist/my report #1.html") parsed, err := url.Parse(raw) if err != nil { t.Fatalf("parse FileURL: %v", err) @@ -179,6 +188,72 @@ func TestFileURLPreservesSpecialCharactersInEntryPath(t *testing.T) { } } +func TestFileURLPreservesLeadingAndTrailingSpacesInEntryPath(t *testing.T) { + raw := mustFileURL(t, "http://127.0.0.1:3001", "ao-1", "dist/ report.html ") + parsed, err := url.Parse(raw) + if err != nil { + t.Fatalf("parse FileURL: %v", err) + } + if parsed.Path != "/dist/ report.html " { + t.Fatalf("FileURL path = %q, want exact filename spaces preserved", parsed.Path) + } +} + +func TestFileURLRejectsHostnameOverDNSLimit(t *testing.T) { + accepted := domain.SessionID(strings.Repeat("x", 142)) + raw := mustFileURL(t, "http://127.0.0.1:3001", accepted, "index.html") + parsed, err := url.Parse(raw) + if err != nil { + t.Fatalf("parse boundary FileURL: %v", err) + } + if len(parsed.Hostname()) != 253 { + t.Fatalf("boundary hostname length = %d, want 253", len(parsed.Hostname())) + } + if decoded, ok := SessionIDFromHost(parsed.Host); !ok || decoded != accepted { + t.Fatalf("boundary hostname round trip = %q, %v; want %q, true", decoded, ok, accepted) + } + + _, err = FileURL("http://127.0.0.1:3001", domain.SessionID(strings.Repeat("x", 143)), "index.html") + if !errors.Is(err, ErrPreviewHostUnsupported) { + t.Fatalf("FileURL error = %v, want ErrPreviewHostUnsupported", err) + } +} + +func TestSessionIDFromHostRejectsHostnameOverDNSLimit(t *testing.T) { + host := "ao-preview." + strings.Repeat("a.", 120) + "localhost:3001" + if id, ok := SessionIDFromHost(host); ok { + t.Fatalf("SessionIDFromHost(overlong) = %q, true; want false", id) + } +} + +func TestStoredWorkspaceEntryPreservesLegacyAndRelativeTargets(t *testing.T) { + for _, tc := range []struct { + raw string + want string + }{ + {raw: "http://127.0.0.1:3001/api/v1/sessions/ao-1/preview/files/docs/report.html", want: "docs/report.html"}, + {raw: "docs/report.html", want: "docs/report.html"}, + {raw: " docs/report.html ", want: " docs/report.html "}, + } { + got, ok := StoredWorkspaceEntry(tc.raw, "ao-1") + if !ok || got != tc.want { + t.Errorf("StoredWorkspaceEntry(%q) = %q, %v; want %q, true", tc.raw, got, ok, tc.want) + } + } + if got, ok := StoredWorkspaceEntry("https://example.com/api/v1/sessions/ao-1/preview/files/docs/report.html", "ao-1"); ok { + t.Fatalf("external lookalike URL = %q, true; want false", got) + } +} + +func mustFileURL(t *testing.T, baseURL string, id domain.SessionID, entry string) string { + t.Helper() + raw, err := FileURL(baseURL, id, entry) + if err != nil { + t.Fatalf("FileURL: %v", err) + } + return raw +} + func TestSessionIDFromHostRejectsOrdinaryHosts(t *testing.T) { for _, host := range []string{"127.0.0.1:3001", "localhost:3001", "ao-preview.invalid.localhost:3001", "example.com"} { if id, ok := SessionIDFromHost(host); ok { diff --git a/backend/internal/preview/poller.go b/backend/internal/preview/poller.go index 8b8b13848f..782620dafe 100644 --- a/backend/internal/preview/poller.go +++ b/backend/internal/preview/poller.go @@ -4,8 +4,6 @@ import ( "context" "fmt" "log/slog" - "net/url" - "path/filepath" "strings" "time" @@ -114,9 +112,16 @@ func (p *Poller) Poll(ctx context.Context) error { if sess.Kind != domain.KindWorker { continue } - entry, ok := DiscoverEntry(sess.Metadata.WorkspacePath) + storedEntry, workspaceOwned := StoredWorkspaceEntry(sess.Metadata.PreviewURL, sess.ID) + entry, ok := Entry{}, false + if workspaceOwned { + entry, ok = EntryAtPath(sess.Metadata.WorkspacePath, storedEntry) + } + if !ok { + entry, ok = DiscoverEntry(sess.Metadata.WorkspacePath) + } if !ok { - if isWorkspacePreviewURL(sess.Metadata.PreviewURL, sess.ID) { + if workspaceOwned { if _, err := p.setter.SetPreview(ctx, sess.ID, ""); err != nil { p.logger.Error("preview poller: failed to clear stale preview", "session", sess.ID, "err", err) @@ -130,8 +135,13 @@ func (p *Poller) Poll(ctx context.Context) error { if seenBefore && previous == state { continue } - target := FileURL(p.baseURL, sess.ID, entry.Path) - if !p.shouldRefresh(sess, target, seenBefore) { + target, err := FileURL(p.baseURL, sess.ID, entry.Path) + if err != nil { + p.logger.Error("preview poller: cannot build isolated preview URL", "session", sess.ID, "err", err) + p.seen[sess.ID] = state + continue + } + if !p.shouldRefresh(sess, target, seenBefore, workspaceOwned) { p.seen[sess.ID] = state continue } @@ -148,7 +158,7 @@ func (p *Poller) Poll(ctx context.Context) error { return nil } -func (p *Poller) shouldRefresh(sess domain.SessionRecord, target string, seenBefore bool) bool { +func (p *Poller) shouldRefresh(sess domain.SessionRecord, target string, seenBefore, workspaceOwned bool) bool { current := strings.TrimSpace(sess.Metadata.PreviewURL) if current == "" { if !seenBefore { @@ -157,40 +167,12 @@ func (p *Poller) shouldRefresh(sess domain.SessionRecord, target string, seenBef previous := p.seen[sess.ID] return previous.cleared } - if current == target || isWorkspacePreviewURL(current, sess.ID) { - return true + if current == target { + return seenBefore } - return isStaleWorkspacePath(current) + return workspaceOwned } func stateFor(entry Entry) entryState { return entryState{path: entry.Path, modUnix: entry.ModTime.UnixNano(), size: entry.Size} } - -func isWorkspacePreviewURL(raw string, id domain.SessionID) bool { - parsed, err := url.Parse(strings.TrimSpace(raw)) - if err != nil { - return false - } - if originID, ok := SessionIDFromHost(parsed.Host); ok { - return originID == id - } - previewPath := parsed.Path - if previewPath == "" { - previewPath = raw - } - prefix := "/api/v1/sessions/" + url.PathEscape(string(id)) + "/preview/files/" - return strings.HasPrefix(previewPath, prefix) -} - -func isStaleWorkspacePath(raw string) bool { - raw = strings.TrimSpace(raw) - if raw == "" || strings.Contains(raw, "://") || filepath.IsAbs(raw) || isWindowsAbs(raw) { - return false - } - return !strings.Contains(raw, ":") -} - -func isWindowsAbs(raw string) bool { - return len(raw) >= 3 && ((raw[0] >= 'a' && raw[0] <= 'z') || (raw[0] >= 'A' && raw[0] <= 'Z')) && raw[1] == ':' && (raw[2] == '\\' || raw[2] == '/') -} diff --git a/backend/internal/preview/poller_test.go b/backend/internal/preview/poller_test.go index d5226a2d85..68455ecf9c 100644 --- a/backend/internal/preview/poller_test.go +++ b/backend/internal/preview/poller_test.go @@ -50,7 +50,7 @@ func TestPollerSetsPreviewWhenActiveWorkerEntryAppears(t *testing.T) { assertSets(t, svc.sets, previewSet{ id: "ao-1", - url: FileURL("http://127.0.0.1:3001", "ao-1", "index.html"), + url: mustFileURL(t, "http://127.0.0.1:3001", "ao-1", "index.html"), }) } @@ -66,7 +66,7 @@ func TestPollerUsesFirstExistingEntrypoint(t *testing.T) { assertSets(t, svc.sets, previewSet{ id: "ao-1", - url: FileURL("http://127.0.0.1:3001", "ao-1", "dist/index.html"), + url: mustFileURL(t, "http://127.0.0.1:3001", "ao-1", "dist/index.html"), }) } @@ -83,7 +83,7 @@ func TestPollerPreservesEntrypointPriority(t *testing.T) { assertSets(t, svc.sets, previewSet{ id: "ao-1", - url: FileURL("http://127.0.0.1:3001", "ao-1", "public/index.html"), + url: mustFileURL(t, "http://127.0.0.1:3001", "ao-1", "public/index.html"), }) } @@ -129,7 +129,7 @@ func TestPollerRediscoverEntryAfterDeleteAndRecreate(t *testing.T) { if err := poller.Poll(context.Background()); err != nil { t.Fatalf("first Poll: %v", err) } - wantURL := FileURL("http://127.0.0.1:3001", "ao-1", "index.html") + wantURL := mustFileURL(t, "http://127.0.0.1:3001", "ao-1", "index.html") assertSets(t, svc.sets, previewSet{id: "ao-1", url: wantURL}) // Delete the entry — poller must clear the preview and mark the session cleared. @@ -194,7 +194,8 @@ func TestPollerDoesNotOverrideExplicitPreviewTarget(t *testing.T) { func TestPollerMigratesLegacyWorkspacePreviewURL(t *testing.T) { workspace := t.TempDir() writeFile(t, filepath.Join(workspace, "index.html"), "
hello
") - legacy := "http://127.0.0.1:3001/api/v1/sessions/ao-1/preview/files/index.html" + writeFile(t, filepath.Join(workspace, "docs", "report.html"), "
chosen report
") + legacy := "http://127.0.0.1:3001/api/v1/sessions/ao-1/preview/files/docs/report.html" svc := &fakePreviewSessions{sessions: []domain.SessionRecord{workerSession("ao-1", workspace, legacy)}} poller := NewPoller(svc, svc, "http://127.0.0.1:3001", PollerConfig{Logger: discardLogger()}) @@ -204,7 +205,42 @@ func TestPollerMigratesLegacyWorkspacePreviewURL(t *testing.T) { assertSets(t, svc.sets, previewSet{ id: "ao-1", - url: FileURL("http://127.0.0.1:3001", "ao-1", "index.html"), + url: mustFileURL(t, "http://127.0.0.1:3001", "ao-1", "docs/report.html"), + }) +} + +func TestPollerPreservesStoredRelativeEntry(t *testing.T) { + workspace := t.TempDir() + writeFile(t, filepath.Join(workspace, "index.html"), "
default
") + writeFile(t, filepath.Join(workspace, "docs", "report.html"), "
chosen report
") + svc := &fakePreviewSessions{sessions: []domain.SessionRecord{workerSession("ao-1", workspace, "docs/report.html")}} + poller := NewPoller(svc, svc, "http://127.0.0.1:3001", PollerConfig{Logger: discardLogger()}) + + if err := poller.Poll(context.Background()); err != nil { + t.Fatalf("Poll: %v", err) + } + + assertSets(t, svc.sets, previewSet{ + id: "ao-1", + url: mustFileURL(t, "http://127.0.0.1:3001", "ao-1", "docs/report.html"), + }) +} + +func TestPollerRewritesStoredOriginToActualDaemonPortWithoutChangingEntry(t *testing.T) { + workspace := t.TempDir() + writeFile(t, filepath.Join(workspace, "index.html"), "
default
") + writeFile(t, filepath.Join(workspace, "docs", "report.html"), "
chosen report
") + old := mustFileURL(t, "http://127.0.0.1:3001", "ao-1", "docs/report.html") + svc := &fakePreviewSessions{sessions: []domain.SessionRecord{workerSession("ao-1", workspace, old)}} + poller := NewPoller(svc, svc, "http://127.0.0.1:49152", PollerConfig{Logger: discardLogger()}) + + if err := poller.Poll(context.Background()); err != nil { + t.Fatalf("Poll: %v", err) + } + + assertSets(t, svc.sets, previewSet{ + id: "ao-1", + url: mustFileURL(t, "http://127.0.0.1:49152", "ao-1", "docs/report.html"), }) } diff --git a/backend/internal/preview/stored_entry.go b/backend/internal/preview/stored_entry.go new file mode 100644 index 0000000000..e3ead44455 --- /dev/null +++ b/backend/internal/preview/stored_entry.go @@ -0,0 +1,58 @@ +package preview + +import ( + "net" + "net/url" + "path/filepath" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +// StoredWorkspaceEntry extracts a workspace-relative entry from every preview +// format persisted by released versions: isolated-origin URLs, legacy API +// URLs, and plain relative paths. +func StoredWorkspaceEntry(raw string, id domain.SessionID) (string, bool) { + if strings.TrimSpace(raw) == "" { + return "", false + } + parsed, err := url.Parse(raw) + if err == nil { + if originID, ok := SessionIDFromHost(parsed.Host); ok { + if originID != id { + return "", false + } + entry, err := url.PathUnescape(strings.TrimPrefix(parsed.EscapedPath(), "/")) + if err != nil { + return "", false + } + return CleanWorkspacePath(entry) + } + + prefix := "/api/v1/sessions/" + url.PathEscape(string(id)) + "/preview/files/" + if isLegacyPreviewHost(parsed.Hostname()) && strings.HasPrefix(parsed.EscapedPath(), prefix) { + entry, err := url.PathUnescape(strings.TrimPrefix(parsed.EscapedPath(), prefix)) + if err != nil { + return "", false + } + return CleanWorkspacePath(entry) + } + } + + if strings.Contains(raw, "://") || filepath.IsAbs(raw) || isWindowsAbsolute(raw) || strings.Contains(raw, ":") { + return "", false + } + return CleanWorkspacePath(raw) +} + +func isLegacyPreviewHost(host string) bool { + if host == "" || strings.EqualFold(host, "localhost") { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +func isWindowsAbsolute(raw string) bool { + return len(raw) >= 3 && ((raw[0] >= 'a' && raw[0] <= 'z') || (raw[0] >= 'A' && raw[0] <= 'Z')) && raw[1] == ':' && (raw[2] == '\\' || raw[2] == '/') +} diff --git a/backend/internal/preview/workspace_file.go b/backend/internal/preview/workspace_file.go new file mode 100644 index 0000000000..3f3707739d --- /dev/null +++ b/backend/internal/preview/workspace_file.go @@ -0,0 +1,70 @@ +package preview + +import ( + "io/fs" + "os" + "path" + "path/filepath" + "strings" +) + +// CleanWorkspacePath normalizes a browser/workspace path without discarding +// meaningful whitespace from valid Unix filenames. The leading slash used by +// browser requests is treated as the preview root, not as a host filesystem +// root. +func CleanWorkspacePath(raw string) (string, bool) { + raw = filepath.ToSlash(raw) + for _, segment := range strings.Split(raw, "/") { + if segment == ".." { + return "", false + } + } + clean := strings.TrimPrefix(path.Clean("/"+raw), "/") + if clean == "" || clean == "." { + return "", false + } + return clean, true +} + +// OpenWorkspaceFile opens a regular file beneath workspacePath using os.Root. +// os.Root follows symlinks that remain inside the workspace and rejects links +// that escape it, so callers can safely serve the returned handle without a +// second path lookup. +func OpenWorkspaceFile(workspacePath, assetPath string) (*os.File, fs.FileInfo, string, error) { + clean, ok := CleanWorkspacePath(assetPath) + if !ok { + return nil, nil, "", fs.ErrNotExist + } + root, err := os.OpenRoot(workspacePath) + if err != nil { + return nil, nil, "", err + } + defer func() { _ = root.Close() }() + + file, err := root.Open(filepath.FromSlash(clean)) + if err != nil { + return nil, nil, "", err + } + info, err := file.Stat() + if err != nil { + _ = file.Close() + return nil, nil, "", err + } + if !info.Mode().IsRegular() { + _ = file.Close() + return nil, nil, "", fs.ErrNotExist + } + return file, info, clean, nil +} + +// EntryAtPath resolves an existing regular workspace file with the same rooted +// confinement used by the HTTP serving path. +func EntryAtPath(workspacePath, assetPath string) (Entry, bool) { + file, info, clean, err := OpenWorkspaceFile(workspacePath, assetPath) + if err != nil { + return Entry{}, false + } + _ = file.Close() + absPath, _ := ConfinedPath(workspacePath, clean) + return Entry{Path: clean, AbsPath: absPath, ModTime: info.ModTime(), Size: info.Size()}, true +} From d59ff209293e61c6d2819f43b0077c4ee8e9e155 Mon Sep 17 00:00:00 2001 From: aprv10 <1apoorvindia@gmail.com> Date: Mon, 20 Jul 2026 14:57:51 +0530 Subject: [PATCH 4/4] fix build errors --- backend/internal/preview/workspace_file.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/internal/preview/workspace_file.go b/backend/internal/preview/workspace_file.go index 3f3707739d..261263373d 100644 --- a/backend/internal/preview/workspace_file.go +++ b/backend/internal/preview/workspace_file.go @@ -13,7 +13,7 @@ import ( // browser requests is treated as the preview root, not as a host filesystem // root. func CleanWorkspacePath(raw string) (string, bool) { - raw = filepath.ToSlash(raw) + raw = strings.ReplaceAll(raw, `\`, "/") for _, segment := range strings.Split(raw, "/") { if segment == ".." { return "", false