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:
parent
59f5c1a3c7
commit
b73da288e3
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user