From b73da288e35ebd4be17a8682e079f66573b8c062 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Thu, 16 Apr 2026 18:22:23 -0700 Subject: [PATCH] fix(auth): TenantGuard same-origin bypass for EC2 tenant Canvas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On EC2 tenant instances, Caddy serves Canvas (:3000) and API (:8080) under the same domain. Canvas makes same-origin requests without X-Molecule-Org-Id or Fly-Replay-Src headers, causing TenantGuard to 404 every API route. - Add isSameOriginCanvas() as tertiary check in TenantGuard — when CANVAS_PROXY_URL is set and Referer/Origin matches Host, pass through. - Enhance isSameOriginCanvas() to also check Origin header (WebSocket upgrade requests send Origin but may not send Referer). - Add 3 new tests: Referer bypass, Origin bypass (WS), inactive without env. Fixes all 404s on /workspaces, /templates, /org/templates, /approvals/pending, /canvas/viewport, and /ws WebSocket on tenant EC2 instances. Co-Authored-By: Claude Opus 4.6 (1M context) --- platform/internal/middleware/tenant_guard.go | 7 +++ .../internal/middleware/tenant_guard_test.go | 58 +++++++++++++++++++ .../internal/middleware/wsauth_middleware.go | 28 +++++---- 3 files changed, 82 insertions(+), 11 deletions(-) diff --git a/platform/internal/middleware/tenant_guard.go b/platform/internal/middleware/tenant_guard.go index 3bcd010d..78309bba 100644 --- a/platform/internal/middleware/tenant_guard.go +++ b/platform/internal/middleware/tenant_guard.go @@ -81,6 +81,13 @@ func TenantGuardWithOrgID(configuredOrgID string) gin.HandlerFunc { c.Next() return } + // Tertiary: same-origin Canvas requests on tenant EC2 instances where + // Caddy serves Canvas (:3000) and API (:8080) under the same domain. + // CANVAS_PROXY_URL is set → Referer/Origin matches Host → trusted. + if isSameOriginCanvas(c) { + c.Next() + return + } // 404 not 403 — existence of this tenant must not be inferable by // probing other orgs' machines. c.AbortWithStatus(404) diff --git a/platform/internal/middleware/tenant_guard_test.go b/platform/internal/middleware/tenant_guard_test.go index f82f75ad..01341c25 100644 --- a/platform/internal/middleware/tenant_guard_test.go +++ b/platform/internal/middleware/tenant_guard_test.go @@ -133,6 +133,64 @@ func TestOrgIDFromReplaySrc(t *testing.T) { } } +// Same-origin Canvas bypass: when CANVAS_PROXY_URL is set and Referer matches +// Host, the request is from the co-served Canvas and should pass through. +func TestTenantGuard_SameOriginCanvasBypass(t *testing.T) { + origActive := canvasProxyActive + canvasProxyActive = true + defer func() { canvasProxyActive = origActive }() + + r := newGuardedRouter("org-abc") + + req := httptest.NewRequest("GET", "/workspaces", nil) + req.Host = "molecule1.moleculesai.app" + req.Header.Set("Referer", "https://molecule1.moleculesai.app/") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != 200 { + t.Errorf("same-origin canvas: expected 200, got %d", w.Code) + } +} + +// Same-origin Canvas bypass via Origin header (WebSocket upgrade path). +func TestTenantGuard_SameOriginCanvasViaOrigin(t *testing.T) { + origActive := canvasProxyActive + canvasProxyActive = true + defer func() { canvasProxyActive = origActive }() + + r := newGuardedRouter("org-abc") + + req := httptest.NewRequest("GET", "/workspaces", nil) + req.Host = "molecule1.moleculesai.app" + req.Header.Set("Origin", "https://molecule1.moleculesai.app") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != 200 { + t.Errorf("same-origin canvas via Origin: expected 200, got %d", w.Code) + } +} + +// Same-origin Canvas bypass must NOT work when CANVAS_PROXY_URL is unset. +func TestTenantGuard_SameOriginCanvasInactiveWithoutEnv(t *testing.T) { + origActive := canvasProxyActive + canvasProxyActive = false + defer func() { canvasProxyActive = origActive }() + + r := newGuardedRouter("org-abc") + + req := httptest.NewRequest("GET", "/workspaces", nil) + req.Host = "molecule1.moleculesai.app" + req.Header.Set("Referer", "https://molecule1.moleculesai.app/") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != 404 { + t.Errorf("same-origin canvas without CANVAS_PROXY_URL: expected 404, got %d", w.Code) + } +} + // The allowlist is exact-match, not prefix. "/health/debug" must NOT bypass. func TestTenantGuard_AllowlistIsExactMatch(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/platform/internal/middleware/wsauth_middleware.go b/platform/internal/middleware/wsauth_middleware.go index 5b06c576..f1c0711c 100644 --- a/platform/internal/middleware/wsauth_middleware.go +++ b/platform/internal/middleware/wsauth_middleware.go @@ -220,19 +220,25 @@ func isSameOriginCanvas(c *gin.Context) bool { if !canvasProxyActive { return false } - referer := c.GetHeader("Referer") - if referer == "" { - return false - } host := c.Request.Host if host == "" { return false } - // Referer must start with https:/// or http:/// (trailing - // slash required to prevent hongming-wang.moleculesai.app.evil.com from - // matching hongming-wang.moleculesai.app). - return strings.HasPrefix(referer, "https://"+host+"/") || - strings.HasPrefix(referer, "http://"+host+"/") || - referer == "https://"+host || - referer == "http://"+host + // Check Referer first (standard browser requests). + referer := c.GetHeader("Referer") + if referer != "" { + // Referer must start with https:/// or http:/// (trailing + // slash required to prevent hongming-wang.moleculesai.app.evil.com from + // matching hongming-wang.moleculesai.app). + if strings.HasPrefix(referer, "https://"+host+"/") || + strings.HasPrefix(referer, "http://"+host+"/") || + referer == "https://"+host || + referer == "http://"+host { + return true + } + } + // Fallback: check Origin header (WebSocket upgrade requests may not have + // Referer but always send Origin). + origin := c.GetHeader("Origin") + return origin == "https://"+host || origin == "http://"+host }