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 }