fix(auth): TenantGuard same-origin bypass for EC2 tenant Canvas

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) <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-04-16 18:22:23 -07:00
parent 59f5c1a3c7
commit b73da288e3
3 changed files with 82 additions and 11 deletions

View File

@ -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)

View File

@ -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)

View File

@ -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://<host>/ or http://<host>/ (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://<host>/ or http://<host>/ (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
}