diff --git a/.env.example b/.env.example index a6ea9efd..e8bcaf78 100644 --- a/.env.example +++ b/.env.example @@ -17,3 +17,7 @@ AGENTFLOW_ASSISTANT_MODELS= AGENTFLOW_ASSISTANT_MODEL= # Worker separation, installed runners and preview settings retain AGENTFLOW_ names. # See docs/deployment.md. Production HTTPS: AGENTFLOW_SECURE_COOKIES=true. + +# Archived previews require a separate hostname; see docs/deployment.md#isolated-preview-host. +# AGENTFLOW_PREVIEW_PUBLIC_URL=https://preview.example.net +# AGENTFLOW_PREVIEW_FRAME_ANCESTORS=https://studio.example.com diff --git a/.github/workflows/platform.yml b/.github/workflows/platform.yml index 31c0a55c..d40c4959 100644 --- a/.github/workflows/platform.yml +++ b/.github/workflows/platform.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 env: - REAL_BROWSER_TESTS: '^(TestMainBotBrowserStartsAndDrivesRunBrowser|TestBrowserStreamPushesFramesAndRunsCommands|TestRunnerStartsPanelBrowserInRunSandbox|TestBrowserAgentOperationsDriveRealChromium)$' + REAL_BROWSER_TESTS: '^(TestMainBotBrowserStartsAndDrivesRunBrowser|TestBrowserStreamPushesFramesAndRunsCommands|TestRunnerStartsPanelBrowserInRunSandbox|TestBrowserAgentOperationsDriveRealChromium|TestPreviewIsolationBrowser)$' steps: - uses: actions/checkout@v7.0.1 - uses: actions/setup-go@v6 diff --git a/compose.yaml b/compose.yaml index 7b01d42d..c2201cee 100644 --- a/compose.yaml +++ b/compose.yaml @@ -37,6 +37,7 @@ services: AGENTFLOW_ASSISTANT_MODEL: ${AGENTFLOW_ASSISTANT_MODEL:-} AGENTFLOW_ASSISTANT_MODELS: ${AGENTFLOW_ASSISTANT_MODELS:-} AGENTFLOW_PREVIEW_PUBLIC_URL: ${AGENTFLOW_PREVIEW_PUBLIC_URL:-} + AGENTFLOW_PREVIEW_FRAME_ANCESTORS: ${AGENTFLOW_PREVIEW_FRAME_ANCESTORS:-} AGENTFLOW_PLATFORM_MCP_URL: ${AGENTFLOW_PLATFORM_MCP_URL:-http://platform:8080} AGENTFLOW_DOCKER_NETWORK: ${COMPOSE_PROJECT_NAME:-loopforge}_default AGENTFLOW_LOCAL_SANDBOX: docker diff --git a/docs/deployment.md b/docs/deployment.md index ace3f09e..c37e0c62 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -99,6 +99,33 @@ A platform administrator can share one CodeBuddy API Key under **Platform admini Configure provider credentials in the platform UI, not the host's global CLI configuration. Credentials are bound to the responsible actor by the upstream runtime. The home composer and SOP roles support CodeBuddy, Codex and Claude Code independently. Select the executor beside the home model selector; its API key (and optional Codex/Claude base URL) comes from your personal settings. Changing the executor preserves the draft; saved conversations record the executor used. Main Bot writes still pass through the platform tool gateway and its approval checks. Requirement clarification and notifications continue to use CodeBuddy. A scheduled assistant task can select any supported CLI and runs as an ordinary chat turn; Codex and Claude Code still need that account's own credential. Live model tests are opt-in; set `AGENTFLOW_LIVE_TESTS=1` only for the acceptance suites that need to load local credentials. +## Isolated preview host + +Archived frontend previews execute agent-produced HTML and JavaScript. Serve them on a dedicated hostname, separate from every studio hostname: + +```dotenv +AGENTFLOW_PREVIEW_PUBLIC_URL=https://preview.example.net +AGENTFLOW_PREVIEW_FRAME_ANCESTORS=https://studio.example.com +``` + +`FRAME_ANCESTORS` is a space-separated list of exact studio origins, including schemes and non-default ports. Wildcards and `'self'` are rejected. A different port on the same hostname is insufficient because cookies are not port-scoped. Prefer a separate registrable domain and keep studio cookies host-only. Do not configure studio APIs, authentication proxies, or other trusted applications on the preview hostname. + +Route both hostnames to the platform, preserving the original `Host`. For example, a Caddy reverse proxy on the platform host can use: + +```caddyfile +studio.example.com, preview.example.net { + reverse_proxy 127.0.0.1:8090 +} +``` + +Configure DNS and TLS for both names. Compose forwards both variables; restart with `./start.sh` after changing `.env`. Native deployments export the same variables. For local HTTP development, use `http://localhost:8090` for previews and `http://127.0.0.1:8090` for the studio, listing the latter in `FRAME_ANCESTORS`. + +The studio's iframe policy allows the validated preview origin. Preview frames, copied links and standalone windows use the server-issued URL unchanged. When the studio uses HTTPS, configure an HTTPS preview URL too; browsers block HTTP frames as mixed content. + +The server serves preview tokens only on the preview hostname and returns 404 for platform pages/APIs there. Session creation fails with a configuration error until an isolated origin and the requesting studio hostname are configured. Archiving and the authenticated ZIP download API remain available. Existing same-origin preview links stop working; reopen the preview after configuring the isolated host. No SQL migration or runner upgrade is needed. + +Preview bundles can use ES modules, local styles/assets and the existing History API routing shim. Bundle external scripts, styles and fonts locally. CSP blocks nested frames, workers/service workers, and resource requests to the studio; backend connections require a configured backend URL on a non-studio hostname and that backend's CORS support. Preview/backend cookies and storage are separate from studio credentials. The optional backend interceptor is a convenience, not an isolation boundary. Previews share their dedicated origin with each other; this does not provide isolation between preview producers. + ## Observability schema After the base tables have been created, explicitly run `bash scripts/install-observability-schema.sh` for the Compose database. It installs absent `run_telemetry.adapter` and `run_logs.level` columns and their indexes from upstream migration `001_run_obs_generated_columns.sql`. The local development installer calls this step before installing templates. It can be repeated without reapplying existing columns. Application startup does not run data migrations. diff --git a/docs/proposals/frontend-preview-delivery-lifecycle.md b/docs/proposals/frontend-preview-delivery-lifecycle.md index c667e962..d1986786 100644 --- a/docs/proposals/frontend-preview-delivery-lifecycle.md +++ b/docs/proposals/frontend-preview-delivery-lifecycle.md @@ -375,17 +375,17 @@ Runner 离线时,preview 显示“等待执行机器上线”。超过 finaliz ## 13. Preview Gateway 与安全边界 -### 13.1 路径托管,不用独立 Origin +### 13.1 独立 Origin 下的路径托管 -未配置 wildcard DNS 的部署不能使用 `https://.preview.example.com/`。当前 MVP 使用同 Host 路径: +无需 wildcard DNS;配置一个独立于工作台的 hostname,令牌仍放在路径中: ```text -https://loopforge.example.com/preview// +https://preview.example.net/preview// ``` -不要再引入 `AGENTFLOW_PREVIEW_ENABLED`、`AGENTFLOW_PREVIEW_ROOT`、`AGENTFLOW_PREVIEW_BASE_DOMAIN`。预览默认开启;静态包进 MySQL;`AGENTFLOW_PREVIEW_PUBLIC_URL` 只覆盖对外 origin(例如反代后的 `https://loopforge.example.com`)。 +不要再引入 `AGENTFLOW_PREVIEW_ENABLED`、`AGENTFLOW_PREVIEW_ROOT`、`AGENTFLOW_PREVIEW_BASE_DOMAIN`。归档默认开启,静态包进 MySQL。在线预览必须配置独立的 `AGENTFLOW_PREVIEW_PUBLIC_URL`(如 `https://preview.example.net`),并在 `AGENTFLOW_PREVIEW_FRAME_ANCESTORS` 中列出完整工作台 origin(如 `https://studio.example.com`)。禁止同一 hostname 换端口、通配符或 `self`;部署配置见 [deployment](../deployment.md#isolated-preview-host)。未配置时不签发预览会话,归档和鉴权 ZIP 下载 API 仍可用。 -预览 HTML 仍是不可信内容。用短期 token、CSP `sandbox` + `connect-src`、以及运行时拦截 fetch/XHR 来约束它能访问的后端。主站 API 不在预览 token 路径下。 +预览 HTML 仍是不可信内容。隔离依赖独立 origin、预览域名的平台路由拒绝和浏览器 CSP。短期 token 控制预览访问;运行时拦截 fetch/XHR 仅提供后端路径映射。 ### 13.2 访问会话 @@ -404,15 +404,17 @@ Studio 调用 `POST /api/runs/:id/preview/session`。服务端验证用户对 Ru ```html ``` -路径托管下必须开 `allow-same-origin`,否则 Vite ESM 无法加载。不要添加 `allow-top-navigation`。剪贴板、摄像头、麦克风和定位默认不授权。 +预览使用独立 hostname;`allow-same-origin` 保留 ES modules 和 History API 兼容性。主站上的 `/preview//` 返回 404,预览 hostname 上所有平台页面与 API 返回 404。不要添加 `allow-top-navigation`。剪贴板、摄像头、麦克风和定位默认不授权。 -代价是预览与平台同源:预览脚本可以操作外层 Studio 页面(读写 DOM、借 `parent.fetch` 以当前登录用户身份调平台接口),CSP 的 `connect-src` 只约束预览文档自身。 +威胁模型:同项目成员或 Agent 构建产物可包含不可信脚本,查看者可能已登录工作台。浏览器同源策略阻止预览读写工作台父窗口或调用 `parent.fetch`;CSP 限制 fetch、表单、图片、脚本等资源请求,后端 URL 禁止指向任何配置的工作台 hostname。资源必须打包在静态包内,子 iframe 和 worker/service worker 禁用。已有会话也在读取时重新校验后端地址。后端拦截脚本不承担安全边界。预览之间仍共享同一个独立 origin,并不提供预览间租户隔离。 + +`TestPreviewIsolationBrowser` 通过实际归档、网关与 Chromium 验证带签名登录 Cookie 的查看者、父窗口访问、原生 fetch/no-cors/图片请求、模块与样式加载、History API 路由,以及独立窗口。 ### 13.4 静态响应 @@ -430,10 +432,10 @@ Studio 调用 `POST /api/runs/:id/preview/session`。服务端验证用户对 Ru 路径托管下 `location.pathname` 只有一个值:应用要的是 `/about`,地址栏、刷新和 Referer 要的是 `/preview//about`。垫片只能在一段时间内二选一,已知限制: - 业务代码在启动后直接读 `location.pathname`,读到的是带前缀的路径。 -- 有路由的包在路由启动前,独立窗口地址栏显示去掉前缀的地址,这时用浏览器刷新或复制地址会落到平台;load 后 10 秒仍未启动的路由会读到带前缀的路径。 -- 启动期间在新标签页打开链接、提交 POST 表单,以及不支持 Navigation API 的浏览器里的脚本跳转,仍会落到平台。 +- 有路由的包在路由启动前,独立窗口地址栏显示去掉前缀的地址,这时用浏览器刷新或复制地址会在预览域名返回 404;load 后 10 秒仍未启动的路由会读到带前缀的路径。 +- 启动期间在新标签页打开链接、提交 POST 表单,以及不支持 Navigation API 的浏览器里的脚本跳转,仍会在预览域名返回 404。 -独立 preview host 可以自然解决绝对 `/assets/*`、前端路由和 service worker base path 问题,比 path-based proxy 兼容性更高。 +当前独立 hostname 仍使用带令牌的路径,保留上述路由限制;service worker 禁用。后续若采用每个预览独立的 origin,需另行定义路由和存储隔离契约。 ## 14. Studio 交互 @@ -649,7 +651,7 @@ preview.deleted - 实现本地 Preview Finalizer。 - 实现 Runner `archive_preview` 和流式上传。 - 增加 cleanup hold、超时和启动恢复。 -- 实现路径 `/preview/{token}/` 静态服务,不用独立 origin。 +- 在独立预览 hostname 实现 `/preview/{token}/` 静态服务,主站不托管可执行预览。 - Studio 增加“前端预览”Tab。 - 暂不截图、不支持运行时容器。 diff --git a/internal/engine/preview_finalizer_test.go b/internal/engine/preview_finalizer_test.go index 055e7ece..97f85f08 100644 --- a/internal/engine/preview_finalizer_test.go +++ b/internal/engine/preview_finalizer_test.go @@ -1,6 +1,7 @@ package engine import ( + "net/http/httptest" "net/url" "os" "path/filepath" @@ -16,8 +17,9 @@ import ( func enablePreview(t *testing.T, engine *Engine, store *repo.Store) *preview.Service { t.Helper() svc := preview.NewService(store, preview.NewDBStore(store), preview.Config{ - PublicScheme: "http", - FrameAncestors: "'self'", + PublicScheme: "https", + PublicHost: "preview.example", + FrameAncestors: "https://studio.example", MaxFiles: 50, MaxCompressed: 1 << 20, MaxExpanded: 4 << 20, @@ -372,7 +374,7 @@ func TestDeleteRunRevokesPreviewSession(t *testing.T) { if ready.Status != domain.PreviewReady { t.Fatalf("preview not ready: %+v", ready) } - session, urlStr, err := svc.CreateSession(ready, nil, "") + session, urlStr, err := svc.CreateSession(ready, httptest.NewRequest("POST", "https://studio.example/", nil), "") if err != nil || urlStr == "" || session.ID == "" { t.Fatalf("session %v %s", err, urlStr) } diff --git a/internal/httpapi/auth_binary_cases_test.go b/internal/httpapi/auth_binary_cases_test.go index 1e2c1534..0fa8a208 100644 --- a/internal/httpapi/auth_binary_cases_test.go +++ b/internal/httpapi/auth_binary_cases_test.go @@ -141,7 +141,7 @@ func TestGinMiddlewareAndSmallViews(t *testing.T) { } router := gin.New() - router.Use(securityHeaders(), gzipMaybe()) + router.Use(securityHeaders(nil), gzipMaybe()) router.GET("/asset.js", func(c *gin.Context) { c.String(http.StatusOK, "javascript") }) router.GET("/api/example/stream", func(c *gin.Context) { c.String(http.StatusOK, "event") }) req := httptest.NewRequest(http.MethodGet, "/asset.js?v=1", nil) diff --git a/internal/httpapi/gin_helpers.go b/internal/httpapi/gin_helpers.go index 5c652744..4f15f7cc 100644 --- a/internal/httpapi/gin_helpers.go +++ b/internal/httpapi/gin_helpers.go @@ -11,6 +11,7 @@ import ( "agentflow/internal/apperr" "agentflow/internal/engine" + "agentflow/internal/preview" "github.com/gin-gonic/gin" ) @@ -117,7 +118,8 @@ func cors() gin.HandlerFunc { } } -func securityHeaders() gin.HandlerFunc { +func securityHeaders(previews *preview.Service) gin.HandlerFunc { + previewOrigin := previews.PublicOrigin() return func(c *gin.Context) { c.Header("X-Content-Type-Options", "nosniff") c.Header("Referrer-Policy", "strict-origin-when-cross-origin") @@ -125,6 +127,11 @@ func securityHeaders() gin.HandlerFunc { path := c.Request.URL.Path frameOptions := "DENY" csp := "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; worker-src 'self' blob:; img-src 'self' data: blob:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'; frame-src 'self' blob:" + if (path == "/" || path == "/index.html") && previewOrigin != "" { + // The studio must allow the same origin the preview gateway serves. + // Other resource policies and the preview's sandbox remain unchanged. + csp += " " + previewOrigin + } if strings.HasPrefix(path, "/chatbot/") { frameOptions = "SAMEORIGIN" csp = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; frame-src 'self' blob:; form-action 'self'" diff --git a/internal/httpapi/preview_isolation_browser_test.go b/internal/httpapi/preview_isolation_browser_test.go new file mode 100644 index 00000000..9013aa2b --- /dev/null +++ b/internal/httpapi/preview_isolation_browser_test.go @@ -0,0 +1,192 @@ +package httpapi + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "agentflow/internal/auth" + "agentflow/internal/domain" + "agentflow/internal/engine" + "agentflow/internal/preview" + "agentflow/internal/repo" + "agentflow/internal/runtime" + + "github.com/gin-gonic/gin" +) + +// This exercises a real archived bundle through the platform's preview +// middleware. The fixture parent uses the studio root's production middleware +// (including its CSP) and a real signed viewer cookie, not a mock API. +func TestPreviewIsolationBrowser(t *testing.T) { + if testing.Short() { + t.Skip("short mode") + } + if _, err := runtime.FindChromium(); err != nil { + t.Skip("chromium not installed") + } + t.Setenv("AGENTFLOW_LOCAL_SANDBOX", "local") + t.Setenv("AGENTFLOW_AUTH_REQUIRED", "true") + t.Setenv("AGENTFLOW_SESSION_SECRET", "preview-isolation-test-secret") + t.Setenv("AGENTFLOW_SCHEDULER_ENABLED", "false") + st, err := repo.OpenTest() + if err != nil { + t.Fatal(err) + } + t.Cleanup(st.Close) + if err := st.PutUser(domain.User{LoginName: "preview-viewer"}); err != nil { + t.Fatal(err) + } + viewer, _ := st.UserByLoginName("preview-viewer") + signed, err := auth.NewClientFromEnv().SignUser(&auth.User{ID: viewer.ID, LoginName: viewer.LoginName, AuthTime: time.Now().UTC()}) + if err != nil { + t.Fatal(err) + } + server := httptest.NewUnstartedServer(nil) + t.Cleanup(server.Close) + studio := "http://" + server.Listener.Addr().String() + previewHost := strings.Replace(server.Listener.Addr().String(), "127.0.0.1", "localhost", 1) + en := engine.New(st, runtime.NewManager(t.TempDir())) + svc := preview.NewService(st, nil, preview.Config{PublicScheme: "http", PublicHost: previewHost, + FrameAncestors: studio, SessionTTL: time.Hour, DefaultRetention: time.Hour}) + en.SetPreview(svc) + run := domain.Run{ID: "preview-isolation", Status: "succeeded", Nodes: map[string]*domain.NodeRun{}} + if err := st.PutRun(run); err != nil { + t.Fatal(err) + } + p := domain.RunPreview{ID: run.ID, RunID: run.ID, Status: domain.PreviewPending, + SourcePath: "dist", EntryFile: "index.html", SPAFallback: true} + if err := st.PutPreview(p); err != nil { + t.Fatal(err) + } + workspace := t.TempDir() + if err := os.Mkdir(filepath.Join(workspace, "dist"), 0o700); err != nil { + t.Fatal(err) + } + studioJSON, _ := json.Marshal(studio) + files := map[string]string{ + // The producer can suppress the convenience backend interceptor. The + // boundary must hold for native fetch, not depend on an injected shim. + "index.html": `
pending
`, + "style.css": `body { --preview-loaded: yes; }`, + "dep.js": `export const loaded = true;`, + "app.js": `import { loaded } from './dep.js'; +const studio = ` + string(studioJSON) + `; +const result = {module: loaded, css: getComputedStyle(document.body).getPropertyValue('--preview-loaded').trim() === 'yes'}; +if (parent !== window) { + try { void parent.document.body; result.parent = false; } catch { result.parent = true; } + try { await parent.fetch(studio + '/api/auth/tokens', {method:'POST', headers:{'Content-Type':'application/json'}, body:'{"name":"preview-attack"}'}); result.parentFetch = false; } catch { result.parentFetch = true; } +} else { result.parent = true; result.parentFetch = true; } +try { await fetch(studio + '/api/auth/tokens', {method:'POST', credentials:'include', headers:{'Content-Type':'application/json'}, body:'{"name":"preview-attack"}'}); result.fetch = false; } catch { result.fetch = true; } +try { await fetch(studio + '/api/auth/tokens', {method:'POST', credentials:'include', mode:'no-cors', body:'attack'}); result.noCors = false; } catch { result.noCors = true; } +const image = new Image(); +result.image = await new Promise(resolve => { image.onload = () => resolve(false); image.onerror = () => resolve(true); image.src = studio + '/api/auth/tokens?image=1'; }); +history.pushState({}, '', '/nested'); +await new Promise(resolve => setTimeout(resolve, 20)); +history.pushState({}, '', '/other'); +await new Promise(resolve => setTimeout(resolve, 20)); +await new Promise(resolve => { addEventListener('popstate', () => setTimeout(resolve, 20), {once:true}); history.back(); }); +result.back = location.pathname.endsWith('/nested') && location.pathname.startsWith('/preview/'); +result.route = location.pathname.endsWith('/nested') && location.pathname.startsWith('/preview/'); +document.querySelector('#result').textContent = JSON.stringify(result); +parent.postMessage(result, studio);`, + } + for name, content := range files { + if err := os.WriteFile(filepath.Join(workspace, "dist", name), []byte(content), 0o600); err != nil { + t.Fatal(err) + } + } + if err := svc.ArchiveLocal(context.Background(), run, p, workspace); err != nil { + t.Fatal(err) + } + p, _ = st.Preview(p.ID) + handler := New(st, en, nil, "", "") + var previewURL string + var currentPreviewURL atomic.Value + var tokenRequests atomic.Int32 + // Register the fixture on New's router so it cannot bypass securityHeaders. + // Use an external script, as the studio CSP deliberately forbids inline JS. + handler.GET("/", func(c *gin.Context) { + http.SetCookie(c.Writer, &http.Cookie{Name: authCookieName, Value: signed, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode}) + c.Data(http.StatusOK, "text/html", []byte(`
pending
`)) + }) + handler.GET("/preview-test.js", func(c *gin.Context) { + urlJSON, _ := json.Marshal(currentPreviewURL.Load().(string)) + c.Data(http.StatusOK, "application/javascript", []byte(fmt.Sprintf(` +(async () => { + addEventListener('securitypolicyviolation', e => { + document.querySelector('#result').textContent = 'parent CSP blocked ' + e.effectiveDirective; + }); + const current = await fetch('/api/auth/current').then(r => r.json()); + if (current.user?.loginName !== 'preview-viewer') { document.querySelector('#result').textContent = 'viewer not authenticated'; return; } + const url = %s; + addEventListener('message', e => { + if (e.origin !== new URL(url).origin || e.source !== document.querySelector('iframe')?.contentWindow) return; + document.querySelector('#result').textContent = JSON.stringify(e.data); + }); + const frame = document.createElement('iframe'); + frame.sandbox = 'allow-scripts allow-forms allow-modals allow-downloads allow-same-origin'; + frame.src = url; + document.body.append(frame); +})();`, urlJSON))) + }) + server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Host == server.Listener.Addr().String() && r.URL.Path == "/api/auth/tokens" { + tokenRequests.Add(1) + } + handler.ServeHTTP(w, r) + }) + server.Start() + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + browserID := fmt.Sprintf("preview-isolation-%d", time.Now().UnixNano()) + sb, err := runtime.NewManager(t.TempDir()).Create(ctx, browserID, "test", domain.SandboxProfile{Driver: "local"}) + if err != nil { + t.Fatal(err) + } + defer sb.Close(context.Background()) + if runtime.EnsureSandboxBrowser(ctx, sb, sb.Workspace(), browserID, nil) == nil { + t.Fatal("Chromium could not start") + } + for _, backend := range []string{"", "https://api.example.test"} { + _, previewURL, err = svc.CreateSession(p, httptest.NewRequest("POST", studio+"/api/runs/"+run.ID+"/preview/session", nil), backend) + if err != nil { + t.Fatal(err) + } + currentPreviewURL.Store(previewURL) + for _, target := range []string{studio + "/", previewURL} { + state, err := runtime.HandleBrowser(ctx, browserID, domain.BrowserRequest{Operation: "navigate", URL: target, WithSnapshot: true}) + if err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(10 * time.Second) + for (state.Snapshot == nil || !strings.Contains(state.Snapshot.Text, `"route":`)) && time.Now().Before(deadline) { + time.Sleep(100 * time.Millisecond) + state, err = runtime.HandleBrowser(ctx, browserID, domain.BrowserRequest{Operation: "snapshot"}) + if err != nil { + t.Fatal(err) + } + } + if state.Snapshot == nil { + t.Fatalf("missing snapshot: %+v", state) + } + out := state.Snapshot.Text + for _, key := range []string{"module", "css", "parent", "parentFetch", "fetch", "noCors", "image", "route", "back"} { + if !strings.Contains(out, `"`+key+`":true`) || strings.Contains(out, `"`+key+`":false`) { + t.Fatalf("backend=%q target=%s: %s not verified\n%s", backend, target, key, out) + } + } + } + } + if got := tokenRequests.Load(); got != 0 { + t.Fatalf("preview reached the studio token API %d times", got) + } +} diff --git a/internal/httpapi/preview_security_headers_test.go b/internal/httpapi/preview_security_headers_test.go new file mode 100644 index 00000000..9ff06ab7 --- /dev/null +++ b/internal/httpapi/preview_security_headers_test.go @@ -0,0 +1,85 @@ +package httpapi + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "agentflow/internal/engine" + "agentflow/internal/preview" + "agentflow/internal/repo" + "agentflow/internal/runtime" +) + +func TestPreviewStudioCSP(t *testing.T) { + t.Setenv("AGENTFLOW_SCHEDULER_ENABLED", "false") + t.Setenv("AGENTFLOW_AUTH_REQUIRED", "false") + st, err := repo.OpenTest() + if err != nil { + t.Fatal(err) + } + t.Cleanup(st.Close) + for _, tc := range []struct { + name, publicURL, parents, origin string + }{ + {"disabled", "", "", ""}, + {"unconfigured", "", "https://studio.example", ""}, + {"https", "https://preview.example", "https://studio.example", "https://preview.example"}, + {"http with port", "http://preview.example:8090", "http://studio.example:8090", "http://preview.example:8090"}, + {"origin only", "https://preview.example:8443/ignored?query=1#fragment", "https://studio.example http://localhost:8090", "https://preview.example:8443"}, + {"same hostname", "https://studio.example", "https://studio.example", ""}, + {"same hostname other port", "http://studio.example:8443", "https://studio.example", ""}, + {"same hostname canonicalized", "https://STUDIO.example.", "https://studio.example", ""}, + {"missing parents", "https://preview.example", "", ""}, + {"wildcard parent", "https://preview.example", "https://*.example", ""}, + {"wildcard preview", "https://*.example", "https://studio.example", ""}, + {"credentials", "https://user@preview.example", "https://studio.example", ""}, + {"invalid scheme", "javascript://preview.example", "https://studio.example", ""}, + {"directive injection", "https://preview.example;frame-src *", "https://studio.example", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("AGENTFLOW_PREVIEW_PUBLIC_URL", tc.publicURL) + t.Setenv("AGENTFLOW_PREVIEW_FRAME_ANCESTORS", tc.parents) + en := engine.New(st, runtime.NewManager(t.TempDir())) + if tc.name != "disabled" { + en.SetPreview(preview.NewService(st, nil, preview.LoadConfig())) + } + // The middleware must use the service's config, not reload the env + // independently of the gateway and session URL signer. + t.Setenv("AGENTFLOW_PREVIEW_PUBLIC_URL", "https://unrelated.example") + handler := New(st, en, nil, "", "") + for _, path := range []string{"/", "/index.html", "/?view=runs", "/login.html", "/chatbot/", "/chatbot/preview.html", "/vscode.html", "/vscode-web/", "/extensions/", "/api/health"} { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "https://studio.example"+path, nil) + req.Header.Set("X-Forwarded-Host", "spoofed.example") + req.Header.Set("X-Forwarded-Proto", "http") + handler.ServeHTTP(rec, req) + policy := rec.Header().Get("Content-Security-Policy") + directives := make(map[string]string) + for _, directive := range strings.Split(policy, ";") { + name, value, _ := strings.Cut(strings.TrimSpace(directive), " ") + directives[name] = value + } + studio := req.URL.Path == "/" || req.URL.Path == "/index.html" + wantFrames := "'self' blob:" + if studio && tc.origin != "" { + wantFrames += " " + tc.origin + } + wantConnect := "'self'" + if path == "/chatbot/preview.html" { + wantFrames = "" // This document falls back to default-src 'none'. + wantConnect = "'none'" + } + if directives["frame-src"] != wantFrames || directives["connect-src"] != wantConnect { + t.Errorf("%s: unexpected frame/connect policy: %s", path, policy) + } + if studio { + if rec.Code != http.StatusOK || directives["script-src"] != "'self'" || directives["frame-ancestors"] != "'none'" || rec.Header().Get("X-Frame-Options") != "DENY" { + t.Errorf("%s: studio headers changed: status=%d headers=%v", path, rec.Code, rec.Header()) + } + } + } + }) + } +} diff --git a/internal/httpapi/run_preview.go b/internal/httpapi/run_preview.go index 47c36092..da7f3bee 100644 --- a/internal/httpapi/run_preview.go +++ b/internal/httpapi/run_preview.go @@ -5,6 +5,7 @@ import ( "log" "mime" "net/http" + "strings" "agentflow/internal/domain" "agentflow/internal/preview" @@ -27,11 +28,16 @@ func (h *previewsHandlers) previewHost() gin.HandlerFunc { return } if _, _, ok := preview.ParsePreviewPath(c.Request.URL.Path); !ok { - if target, stray := svc.StrayNavigationTarget(c.Request); stray { + if target, stray := svc.StrayNavigationTarget(c.Request); svc.IsPreviewHost(c.Request) && stray { c.Redirect(http.StatusSeeOther, target) c.Abort() return } + if svc.IsPreviewHost(c.Request) || strings.HasPrefix(c.Request.URL.Path, preview.HTTPPathPrefix) { + // The preview hostname must never expose the studio or its APIs. + c.AbortWithStatus(http.StatusNotFound) + return + } c.Next() return } diff --git a/internal/httpapi/run_preview_test.go b/internal/httpapi/run_preview_test.go index d4229e72..b41c5de7 100644 --- a/internal/httpapi/run_preview_test.go +++ b/internal/httpapi/run_preview_test.go @@ -29,7 +29,7 @@ func TestPreviewAPIAndPathGateway(t *testing.T) { root := t.TempDir() eng := engine.New(store, runtime.NewManager(filepath.Join(root, "workspaces"))) svc := preview.NewService(store, preview.NewDBStore(store), preview.Config{ - PublicScheme: "http", FrameAncestors: "http://studio.test", MaxFiles: 20, + PublicScheme: "http", PublicHost: "preview.test", FrameAncestors: "http://studio.test", MaxFiles: 20, MaxCompressed: 1 << 20, MaxExpanded: 1 << 20, DefaultRetention: time.Hour, FinalizationTimeout: time.Minute, SessionTTL: time.Hour, }) @@ -105,13 +105,30 @@ func TestPreviewAPIAndPathGateway(t *testing.T) { } pageReq := httptest.NewRequest(http.MethodGet, "/preview/"+token+"/", nil) - pageReq.Host = "studio.test" + pageReq.Host = "preview.test" pageReq.Header.Set("Accept", "text/html") pageRec := httptest.NewRecorder() h.ServeHTTP(pageRec, pageReq) if pageRec.Code != 200 || !strings.Contains(pageRec.Body.String(), "hello") { t.Fatalf("preview page %d %s", pageRec.Code, pageRec.Body.String()) } + // A token is valid only on the isolated host; conversely that host must + // never fall through to platform routes, including malformed preview URLs. + for _, target := range []string{ + "http://studio.test" + u.Path, + "http://preview.test/", "http://preview.test/api/bootstrap", + "http://preview.test/api/auth/current", "http://preview.test/mcp", + "http://preview.test/preview/not-a-token/", + "http://preview.test:8090/api/bootstrap", + } { + r := httptest.NewRequest(http.MethodGet, target, nil) + r.Header.Set("X-Forwarded-Host", "studio.test") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != http.StatusNotFound { + t.Errorf("origin boundary %s = %d, want 404", target, w.Code) + } + } if !strings.Contains(pageRec.Body.String(), `/preview/`+token+`/assets/app.js`) { t.Fatalf("path hosting should prefix root-relative assets: %s", pageRec.Body.String()) } @@ -125,7 +142,7 @@ func TestPreviewAPIAndPathGateway(t *testing.T) { t.Fatalf("a bundle without a router must keep the preview path from the start: %s", pageRec.Body.String()) } routeReq := httptest.NewRequest(http.MethodGet, "/preview/"+token+"/skills/42", nil) - routeReq.Host = "studio.test" + routeReq.Host = "preview.test" routeReq.Header.Set("Accept", "text/html") routeRec := httptest.NewRecorder() h.ServeHTTP(routeRec, routeReq) @@ -139,10 +156,10 @@ func TestPreviewAPIAndPathGateway(t *testing.T) { t.Fatalf("referrer %q", got) } strayReq := httptest.NewRequest(http.MethodGet, "/login?next=%2Fskills", nil) - strayReq.Host = "studio.test" + strayReq.Host = "preview.test" strayReq.Header.Set("Sec-Fetch-Mode", "navigate") strayReq.Header.Set("Sec-Fetch-Site", "same-origin") - strayReq.Header.Set("Referer", "http://studio.test/preview/"+token+"/skills") + strayReq.Header.Set("Referer", "http://preview.test/preview/"+token+"/skills") strayRec := httptest.NewRecorder() h.ServeHTTP(strayRec, strayReq) if strayRec.Code != http.StatusSeeOther || strayRec.Header().Get("Location") != "/preview/"+token+"/login?next=%2Fskills" { @@ -160,7 +177,7 @@ func TestPreviewAPIAndPathGateway(t *testing.T) { } assetReq := httptest.NewRequest(http.MethodGet, "/preview/"+token+"/assets/app.js", nil) - assetReq.Host = "studio.test" + assetReq.Host = "preview.test" assetRec := httptest.NewRecorder() h.ServeHTTP(assetRec, assetReq) if assetRec.Code != 200 || !strings.Contains(assetRec.Body.String(), "console.log") { @@ -168,7 +185,7 @@ func TestPreviewAPIAndPathGateway(t *testing.T) { } guessReq := httptest.NewRequest(http.MethodGet, "/preview/deadbeefdeadbeefdeadbeefdeadbeef/", nil) - guessReq.Host = "studio.test" + guessReq.Host = "preview.test" guessRec := httptest.NewRecorder() h.ServeHTTP(guessRec, guessReq) if guessRec.Code == 200 { @@ -183,6 +200,18 @@ func TestPreviewAPIAndPathGateway(t *testing.T) { if badRec.Code != http.StatusBadRequest { t.Fatalf("invalid backend %d %s", badRec.Code, badRec.Body.String()) } + for _, endpoint := range []struct{ method, path string }{ + {http.MethodPost, "/session"}, {http.MethodPut, "/backend"}, + } { + r := httptest.NewRequest(endpoint.method, "http://studio.test/api/runs/run-preview-api/preview"+endpoint.path, + strings.NewReader(`{"backendUrl":"http://studio.test:8090/api"}`)) + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != http.StatusBadRequest { + t.Fatalf("studio backend accepted on %s: %d %s", endpoint.path, w.Code, w.Body.String()) + } + } backendBody, _ := json.Marshal(map[string]string{"backendUrl": "https://api.example.com/v1"}) backendReq := httptest.NewRequest(http.MethodPost, "/api/runs/run-preview-api/preview/session", bytes.NewReader(backendBody)) @@ -208,7 +237,7 @@ func TestPreviewAPIAndPathGateway(t *testing.T) { t.Fatalf("backend session url %s", backendSession.URL) } backedReq := httptest.NewRequest(http.MethodGet, "/preview/"+backendToken+"/", nil) - backedReq.Host = "studio.test" + backedReq.Host = "preview.test" backedReq.Header.Set("Accept", "text/html") backedRec := httptest.NewRecorder() h.ServeHTTP(backedRec, backedReq) @@ -290,7 +319,7 @@ func TestPreviewAPIAndPathGateway(t *testing.T) { t.Fatalf("parse token failed from %s", otherSession.URL) } otherPageReq := httptest.NewRequest(http.MethodGet, "/preview/"+otherToken+"/", nil) - otherPageReq.Host = "studio.test" + otherPageReq.Host = "preview.test" otherPageReq.Header.Set("Accept", "text/html") otherPageRec := httptest.NewRecorder() h.ServeHTTP(otherPageRec, otherPageReq) diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index dfb2d882..c0f7b9b3 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -210,7 +210,7 @@ func newWithNotifications(s *repo.Store, e *engine.Engine, syncSvc *tracker.Serv } c.JSON(http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) }) - g.Use(logging(), securityHeaders(), cors(), gzipMaybe()) + g.Use(logging(), securityHeaders(e.Preview()), cors(), gzipMaybe()) g.Use(api.tokens.userAccessAuth()) if basicUser != "" && basicPass != "" { g.Use(basicAuth(basicUser, basicPass)) diff --git a/internal/preview/config.go b/internal/preview/config.go index 1de41ec3..4c3b467a 100644 --- a/internal/preview/config.go +++ b/internal/preview/config.go @@ -64,9 +64,6 @@ func LoadConfig() Config { if cfg.PublicScheme == "" { cfg.PublicScheme = "https" } - if cfg.FrameAncestors == "" { - cfg.FrameAncestors = "'self'" - } if scheme, host, ok := SplitPublicURL(cfg.PublicURL); ok { cfg.PublicScheme = scheme cfg.PublicHost = host @@ -113,8 +110,8 @@ func durationFromEnv(key string, fallback time.Duration) time.Duration { return d } -// SplitPublicURL parses an optional preview serving origin such as -// https://loopforge.example.com. Path and query are ignored; the gateway still +// SplitPublicURL parses the dedicated preview serving origin, for example +// https://preview.example.net. Path and query are ignored; the gateway still // serves /preview/{token}/ on that host. func SplitPublicURL(raw string) (scheme, host string, ok bool) { raw = strings.TrimSpace(raw) diff --git a/internal/preview/gateway.go b/internal/preview/gateway.go index c2824e42..421eb5fd 100644 --- a/internal/preview/gateway.go +++ b/internal/preview/gateway.go @@ -41,6 +41,10 @@ var jsRootAssetRe = regexp.MustCompile("([`'\"])(/(?:assets|static|chunks|media| var referrerMetaRe = regexp.MustCompile(`(?i)]*?\sname\s*=\s*(?:"referrer"|'referrer'|referrer\b)[^>]*>`) func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if !s.IsPreviewHost(r) { + http.NotFound(w, r) + return + } token, rel, ok := ParsePreviewPath(r.URL.Path) if !ok { http.NotFound(w, r) @@ -56,6 +60,12 @@ func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.Error(w, "preview session expired", http.StatusForbidden) return } + // Revalidate persisted sessions too, including those minted before an + // upgrade or before the studio origins were reconfigured. + if err := s.validateBackendOrigin(session.BackendURL); err != nil { + http.Error(w, "preview backend uses a studio hostname", http.StatusForbidden) + return + } s.RenewSession(session) if rel == "" || strings.HasSuffix(r.URL.Path, "/") { rel = preview.EntryFile @@ -198,6 +208,7 @@ func (s *Service) writePreviewHeaders(w http.ResponseWriter, name, backend strin w.Header().Set("Content-Type", ctype) w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Referrer-Policy", "same-origin") + w.Header().Set("Origin-Agent-Cluster", "?1") w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), clipboard-read=(), clipboard-write=()") w.Header().Set("Content-Security-Policy", s.previewCSP(backend)) w.Header().Set("Cache-Control", "no-cache") @@ -207,17 +218,21 @@ func (s *Service) writePreviewHeaders(w http.ResponseWriter, name, backend strin } func (s *Service) previewCSP(backend string) string { - ancestors := strings.TrimSpace(s.cfg.FrameAncestors) - if ancestors == "" { - ancestors = "'self'" - } else if !strings.Contains(ancestors, "'self'") { - ancestors = "'self' " + ancestors + ancestors := "'none'" + if _, parents, err := s.isolatedOrigins(); err == nil { + var origins []string + for _, parent := range parents { + origins = append(origins, parent.String()) + } + ancestors = strings.Join(origins, " ") } connect := "'none'" if origin := backendOrigin(backend); origin != "" { connect = "'self' " + origin } - return "sandbox allow-scripts allow-forms allow-modals allow-downloads allow-same-origin; frame-ancestors " + ancestors + "; base-uri 'self'; connect-src " + connect + "; form-action 'self'" + // Resource directives also block credentialed no-cors requests (images, + // scripts, nested frames) to the studio. Bundles must carry their assets. + return "default-src 'none'; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; media-src 'self' data: blob:; worker-src 'none'; frame-src 'none'; sandbox allow-scripts allow-forms allow-modals allow-downloads allow-same-origin; frame-ancestors " + ancestors + "; base-uri 'self'; connect-src " + connect + "; form-action 'self'" } func backendOrigin(raw string) string { diff --git a/internal/preview/origin.go b/internal/preview/origin.go new file mode 100644 index 00000000..097612a8 --- /dev/null +++ b/internal/preview/origin.go @@ -0,0 +1,107 @@ +package preview + +import ( + "errors" + "fmt" + "net/http" + "net/url" + "strings" + + "agentflow/internal/domain" +) + +var ErrIsolatedOriginRequired = errors.New("preview requires AGENTFLOW_PREVIEW_PUBLIC_URL on a separate hostname and explicit studio origins in AGENTFLOW_PREVIEW_FRAME_ANCESTORS") + +// PublicOrigin returns the validated serving origin for the studio's frame-src +// policy. Reuse the gateway's isolation checks; invalid config must not widen CSP. +func (s *Service) PublicOrigin() string { + if !s.Enabled() { + return "" + } + public, _, err := s.isolatedOrigins() + if err != nil { + return "" + } + return public.Scheme + "://" + public.Host +} + +// isolatedOrigins accepts only explicit origins. In particular, a different +// port on the studio hostname does not isolate its host-only session cookie. +func (s *Service) isolatedOrigins() (*url.URL, []*url.URL, error) { + public := parseOrigin(s.cfg.PublicScheme + "://" + s.cfg.PublicHost) + if public == nil { + return nil, nil, ErrIsolatedOriginRequired + } + var parents []*url.URL + for _, raw := range strings.Fields(s.cfg.FrameAncestors) { + parent := parseOrigin(raw) + if parent == nil || sameHostname(parent.Host, public.Host) { + return nil, nil, ErrIsolatedOriginRequired + } + parents = append(parents, parent) + } + if len(parents) == 0 { + return nil, nil, ErrIsolatedOriginRequired + } + return public, parents, nil +} + +func parseOrigin(raw string) *url.URL { + u, err := url.Parse(raw) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" || + u.User != nil || (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" || + strings.ContainsAny(u.Host, "*;\\\t\r\n ") { + return nil + } + u.Path = "" + return u +} + +func sameHostname(a, b string) bool { + hostname := func(raw string) string { + u, err := url.Parse("http://" + raw) + if err != nil { + return "" + } + return strings.TrimSuffix(strings.ToLower(u.Hostname()), ".") + } + a, b = hostname(a), hostname(b) + return a != "" && a == b +} + +// IsPreviewHost also reserves other ports on the configured hostname. No +// platform page or API may be served there, even with a valid studio cookie. +func (s *Service) IsPreviewHost(req *http.Request) bool { + public, _, err := s.isolatedOrigins() + return err == nil && req != nil && sameHostname(req.Host, public.Host) +} + +func (s *Service) validateSessionOrigin(req *http.Request) error { + _, parents, err := s.isolatedOrigins() + if err != nil { + return err + } + for _, parent := range parents { + if req != nil && sameHostname(req.Host, parent.Host) { + return nil + } + } + return ErrIsolatedOriginRequired +} + +func (s *Service) validateBackendOrigin(backend string) error { + _, parents, err := s.isolatedOrigins() + if err != nil { + return err + } + u, err := url.Parse(backend) + if err != nil { + return domain.ErrInvalidPreviewBackendURL + } + for _, parent := range parents { + if sameHostname(u.Host, parent.Host) { + return fmt.Errorf("%w: preview backend must not use a studio hostname", domain.ErrInvalidPreviewBackendURL) + } + } + return nil +} diff --git a/internal/preview/origin_test.go b/internal/preview/origin_test.go new file mode 100644 index 00000000..c9b149f2 --- /dev/null +++ b/internal/preview/origin_test.go @@ -0,0 +1,95 @@ +package preview + +import ( + "errors" + "net/http/httptest" + "strings" + "testing" + + "agentflow/internal/domain" +) + +func TestPreviewRequiresIsolatedOrigin(t *testing.T) { + for _, tc := range []struct { + name, host, ancestors string + }{ + {"unconfigured", "", ""}, + {"same host", "studio.example", "https://studio.example"}, + {"same host different port", "studio.example:8443", "https://studio.example"}, + {"same host canonicalized", "STUDIO.example.", "https://studio.example"}, + {"self", "preview.example", "'self'"}, + {"wildcard", "preview.example", "https://*.example"}, + {"parent path", "preview.example", "https://studio.example/app"}, + {"parent credentials", "preview.example", "https://user@studio.example"}, + {"parent query", "preview.example", "https://studio.example?x=1"}, + {"parent missing", "preview.example", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + s := &Service{cfg: Config{PublicScheme: "https", PublicHost: tc.host, FrameAncestors: tc.ancestors}} + req := httptest.NewRequest("GET", "https://studio.example/", nil) + if err := s.validateSessionOrigin(req); !errors.Is(err, ErrIsolatedOriginRequired) { + t.Fatalf("unsafe configuration accepted: %v", err) + } + if got := s.PublicURL(strings.Repeat("a", 32), req); got != "" { + t.Fatalf("unsafe public URL: %s", got) + } + }) + } +} + +func TestPreviewOriginAndBackendBoundaries(t *testing.T) { + s := &Service{cfg: Config{PublicScheme: "https", PublicHost: "preview.example:8443", FrameAncestors: "https://studio.example http://localhost:8090"}} + req := httptest.NewRequest("GET", "http://studio.example/", nil) + req.Header.Set("X-Forwarded-Proto", "http") + req.Header.Set("X-Forwarded-Host", "studio.example") + if err := s.validateSessionOrigin(req); err != nil { + t.Fatal(err) + } + if got := s.PublicURL("token", req); got != "https://preview.example:8443/preview/token/" { + t.Fatalf("request changed configured origin: %s", got) + } + if err := s.validateSessionOrigin(httptest.NewRequest("GET", "https://other.example/", nil)); err == nil { + t.Fatal("unlisted studio accepted") + } + for _, backend := range []string{"https://studio.example/api", "http://STUDIO.example.:8080", "http://localhost:9999"} { + if err := s.validateBackendOrigin(backend); !errors.Is(err, domain.ErrInvalidPreviewBackendURL) { + t.Errorf("studio backend %q accepted: %v", backend, err) + } + } + for _, backend := range []string{"", "https://api.example/v1"} { + if err := s.validateBackendOrigin(backend); err != nil { + t.Errorf("backend %q rejected: %v", backend, err) + } + } + for _, target := range []string{"https://preview.example:8443/", "http://PREVIEW.example.:8090/"} { + if !s.IsPreviewHost(httptest.NewRequest("GET", target, nil)) { + t.Errorf("preview hostname not reserved: %s", target) + } + } +} + +func TestPreviewRevalidatesStoredBackend(t *testing.T) { + s, st := cvSPreviewService(t) + run := domain.Run{ID: "stored-origin"} + if err := st.PutRun(run); err != nil { + t.Fatal(err) + } + p := domain.RunPreview{ID: "stored-origin", RunID: run.ID, Status: domain.PreviewReady, StorageKey: "blob"} + if err := st.PutPreview(p); err != nil { + t.Fatal(err) + } + token, hash, err := newOpaqueToken() + if err != nil { + t.Fatal(err) + } + if err := st.PutPreviewSession(domain.PreviewSession{PreviewID: p.ID, TokenHash: hash, + BackendURL: "https://studio.example", ExpiresAt: s.clock().Add(s.cfg.SessionTTL)}); err != nil { + t.Fatal(err) + } + r := httptest.NewRequest("GET", "https://preview.example"+PreviewBasePath(token)+"/", nil) + w := httptest.NewRecorder() + s.ServeHTTP(w, r) + if w.Code != 403 || !strings.Contains(w.Body.String(), "studio hostname") { + t.Fatalf("old unsafe session was not rejected: %d %s", w.Code, w.Body.String()) + } +} diff --git a/internal/preview/service.go b/internal/preview/service.go index a25088f2..c08e093d 100644 --- a/internal/preview/service.go +++ b/internal/preview/service.go @@ -597,6 +597,12 @@ func (s *Service) CreateSession(preview domain.RunPreview, req *http.Request, ba if err != nil { return domain.PreviewSession{}, "", err } + if err := s.validateSessionOrigin(req); err != nil { + return domain.PreviewSession{}, "", err + } + if err := s.validateBackendOrigin(backendURL); err != nil { + return domain.PreviewSession{}, "", err + } token, hash, err := newOpaqueToken() if err != nil { return domain.PreviewSession{}, "", err @@ -629,6 +635,9 @@ func (s *Service) SetPreviewBackend(previewID, backendURL string) (domain.RunPre if err != nil { return domain.RunPreview{}, err } + if err := s.validateBackendOrigin(normalized); err != nil { + return domain.RunPreview{}, err + } if err := s.repo.UpdatePreviewBackendURL(preview.ID, normalized); err != nil { return domain.RunPreview{}, err } @@ -694,31 +703,13 @@ func (s *Service) DeleteForRun(runID string) error { return s.DeletePreview(preview) } -func (s *Service) PublicURL(token string, req *http.Request) string { - scheme, host := s.publicOrigin(req) - return scheme + "://" + host + PreviewBasePath(token) + "/" -} - -func (s *Service) publicOrigin(req *http.Request) (scheme, host string) { - scheme = s.cfg.PublicScheme - if scheme == "" { - scheme = "https" - } - if req != nil { - if proto := strings.TrimSpace(req.Header.Get("X-Forwarded-Proto")); proto != "" { - scheme = strings.TrimSpace(strings.Split(proto, ",")[0]) - } else if req.TLS == nil { - scheme = "http" - } - } - host = strings.TrimSpace(s.cfg.PublicHost) - if host == "" && req != nil { - host = req.Host - } - if host == "" { - host = "127.0.0.1" +func (s *Service) PublicURL(token string, _ *http.Request) string { + origin := s.PublicOrigin() + if origin == "" { + return "" } - return scheme, host + // The serving origin is deployment configuration, never a request header. + return origin + PreviewBasePath(token) + "/" } func (s *Service) event(runID, typ, msg string) { diff --git a/internal/preview/service_cases_test.go b/internal/preview/service_cases_test.go index 14948ade..774ed91d 100644 --- a/internal/preview/service_cases_test.go +++ b/internal/preview/service_cases_test.go @@ -171,7 +171,7 @@ func cvSPreviewService(t *testing.T) (*Service, *repo.Store) { } t.Cleanup(st.Close) svc := NewService(st, nil, Config{ - PublicScheme: "https", PublicHost: "preview.example", + PublicScheme: "https", PublicHost: "preview.example", FrameAncestors: "https://studio.example", MaxFiles: 20, MaxCompressed: 1 << 20, MaxExpanded: 1 << 20, DefaultRetention: time.Hour, FinalizationTimeout: time.Minute, SessionTTL: time.Minute, }) @@ -463,7 +463,7 @@ func TestPreviewArchiveAndServe(t *testing.T) { if preserved, _ := st.Preview(preview.ID); preserved.Status != domain.PreviewReady { t.Fatalf("ready preview was skipped: %#v", preserved) } - _, publicURL, err := svc.CreateSession(ready, httptest.NewRequest("GET", "https://preview.example/", nil), "") + _, publicURL, err := svc.CreateSession(ready, httptest.NewRequest("GET", "https://studio.example/", nil), "") if err != nil { t.Fatal(err) } @@ -723,7 +723,7 @@ func TestPreviewSessionsAndDelete(t *testing.T) { if err := st.PutPreview(preview); err != nil { t.Fatal(err) } - req := httptest.NewRequest("GET", "http://internal/x", nil) + req := httptest.NewRequest("GET", "http://studio.example/x", nil) if _, _, err := svc.CreateSession(domain.RunPreview{Status: domain.PreviewPending}, req, ""); err == nil { t.Fatal("pending preview session created") } @@ -809,7 +809,7 @@ func TestPreviewReconcileAndHelpers(t *testing.T) { t.Fatal("run without static contract finalized") } fallback := NewService(st, nil, Config{}) - if got := fallback.PublicURL(strings.Repeat("a", 32), nil); !strings.HasPrefix(got, "https://127.0.0.1/") { + if got := fallback.PublicURL(strings.Repeat("a", 32), nil); got != "" { t.Fatalf("fallback public URL = %q", got) } now := time.Now().UTC() @@ -847,7 +847,7 @@ func TestPreviewReconcileAndHelpers(t *testing.T) { req := httptest.NewRequest("GET", "http://host.example/x", nil) req.Host = "host.example" other := NewService(st, nil, Config{}) - if got := other.PublicURL("t", req); got != "http://host.example/preview/t/" { + if got := other.PublicURL("t", req); got != "" { t.Fatalf("public URL = %q", got) } } diff --git a/web/app/runs.js b/web/app/runs.js index f3d1d4e1..c4003a37 100644 --- a/web/app/runs.js +++ b/web/app/runs.js @@ -662,50 +662,6 @@ function shouldPatchRunLive(run){ if(runPreviewDeclared(run)&&!host.querySelector('[data-run-tab="preview"]'))return false; return true; } -function previewCandidateHttpsURL(rawUrl){ - if(!rawUrl)return ''; - try{ - const u=new URL(rawUrl,window.location.href); - if(u.protocol==='http:'){ - u.protocol='https:'; - if(u.port==='80')u.port=''; - } - return u.href; - }catch(e){ - return rawUrl; - } -} -async function resolvePreviewSessionURL(rawUrl){ - if(!rawUrl)return ''; - try{ - const parsed=new URL(rawUrl,window.location.href); - if(parsed.protocol==='https:')return parsed.href; - const httpsCandidate=previewCandidateHttpsURL(rawUrl); - if(httpsCandidate===rawUrl)return rawUrl; - - const controller=typeof AbortController!=='undefined'?new AbortController():null; - const timer=controller?setTimeout(()=>controller.abort(),2500):null; - try{ - const resp=await fetch(httpsCandidate,{ - method:'HEAD', - signal:controller?.signal, - credentials:'omit' - }); - if(timer)clearTimeout(timer); - if(resp.ok||resp.status<500){ - return httpsCandidate; - } - }catch(err){ - if(timer)clearTimeout(timer); - if(window.location.protocol!=='https:'){ - return rawUrl; - } - } - return httpsCandidate; - }catch(e){ - return rawUrl; - } -} function previewPaneHTML(run){ const pv=state.runPreview; if(!runPreviewDeclared(run)&&!(pv&&pv.runId===run.id&&pv.info)){ @@ -734,7 +690,8 @@ function previewPaneHTML(run){ const backend=String(session.backendUrl||pv.backendUrl||pv.info?.backendUrl||'').trim(); const backendLabel=backend?`${escapeHTML(backend)}`:'未设置'; const backendAction=backend?'更改':'设置'; - const frameUrl=window.location.protocol==='https:'?previewCandidateHttpsURL(session.url):session.url; + // The server signs a URL on the configured preview origin; do not probe or rewrite it. + const frameUrl=session.url; return `
预览 · ${escapeHTML(info.entryFile||'index.html')} · ${info.fileCount||0} 个文件
当前后端${backendLabel}
`; } function stopPreviewPoll(){ @@ -784,9 +741,6 @@ async function loadRunPreview(runId,opts){ if(info.status==='ready'&&needRenew){ session=await api(`/api/runs/${encodeURIComponent(runId)}/preview/session`,{method:'POST',body:JSON.stringify({backendUrl:backend}),silent:!!silent}); } - if(session?.url){ - session.url=await resolvePreviewSessionURL(session.url); - } if(state.activeRun?.id!==runId)return; persistPreviewBackend(runId, session?.backendUrl||backend); state.runPreview={runId,loading:false,error:'',info,session,backendUrl:session?.backendUrl||backend}; @@ -873,10 +827,7 @@ async function editPreviewBackend(run){ body:JSON.stringify({backendUrl:next}) }); persistPreviewBackend(run.id, next); - let session=res?.session||null; - if(session?.url){ - session.url=await resolvePreviewSessionURL(session.url); - } + const session=res?.session||null; const info=res?.preview||state.runPreview?.info||null; state.runPreview={ runId:run.id, diff --git a/web/preview_session_test.js b/web/preview_session_test.js new file mode 100644 index 00000000..1791f241 --- /dev/null +++ b/web/preview_session_test.js @@ -0,0 +1,96 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); +const code = fs.readFileSync(require('node:path').join(__dirname, 'app/runs.js'), 'utf8'); +function extract(name) { + const start = code.indexOf(`function ${name}(`); + assert.ok(start >= 0, name); + const end = code.indexOf('\n}', start) + 2; + return (code.slice(start - 6, start) === 'async ' ? 'async ' : '') + code.slice(start, end); +} + +async function testSession(studioProtocol, sessionURL) { + const run = {id: 'preview-run', workflowSnapshot: {preview: {enabled: true}}}; + const calls = [], probes = [], copied = [], opened = [], notifications = []; + const renewedURL = sessionURL.replace('/token/', '/renewed/'); + const clicks = {}; + let frame = null; + const pane = { + closest: () => ({dataset: {runId: run.id}}), + querySelector: selector => selector.includes('iframe') ? frame : { + addEventListener: (event, callback) => { clicks[selector] = callback; } + }, + set innerHTML(html) { + const src = html.match(/]+src="([^"]+)"/); + frame = src ? { + getAttribute: name => name === 'src' ? src[1] : run.id, + replaceWith: existing => { frame = existing; } + } : null; + } + }; + const context = { + state: {activeRun: run}, + window: {location: {protocol: studioProtocol, href: studioProtocol + '//studio.example/'}, open: (...args) => opened.push(args)}, + $: () => ({querySelector: () => pane}), + escapeHTML: value => String(value), + readPreviewBackend: () => '', persistPreviewBackend() {}, schedulePreviewPoll() {}, + copyText: value => copied.push(value), + afPrompt: async () => 'https://backend.example', + toast: (...args) => notifications.push(args), + // A real studio page only allows connect-src 'self'; probes must not occur. + fetch: async url => { probes.push(url); throw new Error('cross-origin connection denied'); }, + api: async (path, options) => { + calls.push([path, options]); + if (path.endsWith('/session')) return {previewId: 'p', url: sessionURL, backendUrl: ''}; + if (path.endsWith('/backend')) return { + preview: {id: 'p', status: 'ready', backendUrl: 'https://backend.example'}, + session: {previewId: 'p', url: renewedURL, backendUrl: 'https://backend.example'} + }; + return {id: 'p', status: 'ready', fileCount: 2, backendUrl: ''}; + } + }; + vm.createContext(context); + for (const name of ['runPreviewDeclared', 'previewPaneHTML', 'loadRunPreview', 'renderPreviewPane', 'bindPreviewPane', 'editPreviewBackend']) { + vm.runInContext(extract(name), context); + } + await context.loadRunPreview(run.id); + assert.equal(context.state.runPreview.error, ''); + assert.deepEqual(probes, []); + assert.equal(context.state.runPreview.session.url, sessionURL); + assert.equal(frame.getAttribute('src'), sessionURL); + assert.deepEqual(calls.map(([path]) => path), ['/api/runs/preview-run/preview', '/api/runs/preview-run/preview/session']); + assert.equal(calls[1][1].method, 'POST'); + clicks['[data-preview-copy]'](); + clicks['[data-preview-open]'](); + assert.deepEqual(copied, [sessionURL]); + assert.deepEqual(opened, [[sessionURL, '_blank', 'noopener']]); + + // Refreshing metadata should preserve the mounted iframe and its state. + const mounted = frame; + await context.loadRunPreview(run.id, {silent: true}); + assert.equal(frame, mounted); + assert.equal(calls.length, 3, 'the existing session should be reused'); + assert.deepEqual(probes, []); + + await context.editPreviewBackend(run); + assert.equal(context.state.runPreview.session.url, renewedURL); + assert.equal(frame.getAttribute('src'), renewedURL); + assert.notEqual(frame, mounted, 'a renewed session must load a new frame'); + assert.equal(calls[3][0], '/api/runs/preview-run/preview/backend'); + assert.equal(calls[3][1].method, 'PUT'); + assert.deepEqual(JSON.parse(calls[3][1].body), {backendUrl: 'https://backend.example'}); + assert.deepEqual(probes, []); + assert.equal(notifications[0][1], 'ok'); + clicks['[data-preview-copy]'](); + clicks['[data-preview-open]'](); + assert.equal(copied[1], renewedURL); + assert.deepEqual(opened[1], [renewedURL, '_blank', 'noopener']); +} + +(async () => { + for (const studio of ['http:', 'https:']) { + for (const preview of ['http://preview.example:8090/preview/token/', 'https://preview.example:8443/preview/token/']) { + await testSession(studio, preview); + } + } +})().catch(error => { console.error(error); process.exit(1); }); diff --git a/web/syntax_test.go b/web/syntax_test.go index ef7d70e9..9dca93ba 100644 --- a/web/syntax_test.go +++ b/web/syntax_test.go @@ -2147,35 +2147,16 @@ assert.ok(code.includes("r.status==='failed'&&!gongfengIssueClosed(ctx.issue)")) } } -func TestPreviewIframeHttpsFallback(t *testing.T) { +func TestPreviewSessionURL(t *testing.T) { node, err := exec.LookPath("node") if err != nil { - t.Skip("node not installed; skipping preview https fallback test") + t.Skip("node not installed") } - script := ` -const assert = require('node:assert/strict'); -const code = require('node:fs').readFileSync('` + appBundlePath(t) + `', 'utf8'); -assert.ok(code.includes('function previewCandidateHttpsURL('), 'must declare previewCandidateHttpsURL'); -assert.ok(code.includes('async function resolvePreviewSessionURL('), 'must declare resolvePreviewSessionURL'); -assert.ok(code.includes('session.url=await resolvePreviewSessionURL(session.url)'), 'loadRunPreview must resolve session url'); - -// Test previewCandidateHttpsURL logic -const start = code.indexOf('function previewCandidateHttpsURL('); -const end = code.indexOf('async function resolvePreviewSessionURL('); -assert.ok(start >= 0 && end > start); -eval(code.slice(start, end)); - -global.window = { location: { href: 'https://loopforge-test.example.com/runs', protocol: 'https:' } }; -assert.equal(previewCandidateHttpsURL('http://loopforge-test.example.com/preview/token/'), 'https://loopforge-test.example.com/preview/token/'); -assert.equal(previewCandidateHttpsURL('http://loopforge-test.example.com:80/preview/token/'), 'https://loopforge-test.example.com/preview/token/'); -assert.equal(previewCandidateHttpsURL('https://loopforge-test.example.com/preview/token/'), 'https://loopforge-test.example.com/preview/token/'); -assert.equal(previewCandidateHttpsURL(''), ''); -` - out, err := exec.Command(node, "-e", script).CombinedOutput() - if err != nil { - t.Fatalf("preview https fallback check failed: %v\n%s", err, out) + if out, err := exec.Command(node, "preview_session_test.js").CombinedOutput(); err != nil { + t.Fatalf("preview session URL interaction: %v\n%s", err, out) } } + func TestClarifyOptionToggleLogic(t *testing.T) { app, err := readAppBundle() if err != nil { diff --git a/webtest/tests/runs-actions.test.mjs b/webtest/tests/runs-actions.test.mjs index 84892297..25f069e0 100644 --- a/webtest/tests/runs-actions.test.mjs +++ b/webtest/tests/runs-actions.test.mjs @@ -100,8 +100,7 @@ test('run helpers merge streams and format timing, output, logs, telemetry, prev outputs,logs,workspace,preview,statuses,gate,compact, adapters:[matchTelemetryAdapter({source:{adapter:'agent-hooks-v1'}},'hooks'), matchTelemetryAdapter({source:{adapter:'codebuddy-otel-v1'}},'live')], - urls:[vscodeWorkbenchURL('run x',{embed:true}),vscodeWindowName('run x!'), - previewCandidateHttpsURL('http://example.com:80/path')], + urls:[vscodeWorkbenchURL('run x',{embed:true}),vscodeWindowName('run x!')], clean:[runWorkspaceWasCleaned({workspaceCleanedAt:'1970-01-01'}),runWorkspaceWasCleaned({workspaceCleanedAt:'2026-01-01'})], page:[runPageTarget(run,'a'),runDisplayStatus({...run,status:'paused'}),runAwaitsConfirmation({...run,status:'waiting'})], evalApply:[runEvalApplyable({action:'workflow.update_node'}),runEvalApplyable({action:'noop'})] @@ -123,6 +122,7 @@ test('run helpers merge streams and format timing, output, logs, telemetry, prev assert.deepEqual(result.logs, ['Bash npm test','Read\\npackage main.go','fatal failure','']); assert.match(result.workspace, /vscode\.html/); assert.match(result.preview, /index\.html/); + assert.match(result.preview, /src="http:\/\/preview\.example\/x"/); assert.match(result.statuses[0], /正在生成/); assert.match(result.statuses[1], /生成失败/); assert.match(result.statuses[2], /已跳过/); @@ -131,7 +131,7 @@ test('run helpers merge streams and format timing, output, logs, telemetry, prev assert.match(result.gate, /command failed/); assert.equal(result.compact[0].message, 'AB'); assert.deepEqual(result.adapters, [true,false]); - assert.deepEqual(result.urls, ['/vscode.html?run=run+x&embed=1','agentflow-vscode-runx','https://example.com/path']); + assert.deepEqual(result.urls, ['/vscode.html?run=run+x&embed=1','agentflow-vscode-runx']); assert.deepEqual(result.clean, [false,true]); assert.equal(result.page[0].runId, 'run-x'); assert.equal(result.page[1], '已暂停'); @@ -170,7 +170,7 @@ test('run helpers merge streams and format timing, output, logs, telemetry, prev app.evaluate(`window.afPrompt=async()=> 'https://new-api.example'`); await app.evaluate(`editPreviewBackend(state.activeRun)`); assert.equal(app.evaluate(`state.runPreview.backendUrl`), 'https://new-api.example'); - assert.equal(await app.evaluate(`resolvePreviewSessionURL('https://preview.example/probe')`), 'https://preview.example/probe'); + assert.equal(app.evaluate(`state.runPreview.session.url`), 'https://preview.example/new'); app.router.get('/api/runs/run-x', { ...app.evaluate('structuredClone(state.activeRun)'), status: 'cancelled' }); app.router.post('/api/runs/run-x/cancel', { ok: true });