From 25bd9241d1c85262b7ccb96b4fc74bf9cde129fe Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Thu, 16 Apr 2026 08:43:01 -0700 Subject: [PATCH] fix(tenant): WebSocket URL derivation + AdminAuth same-origin for tenant image Two bugs on the combined tenant image (canvas + API same-origin): 1. WebSocket URL: NEXT_PUBLIC_WS_URL="" (empty string for same-origin) was preserved by ?? operator, producing an invalid WS URL. Now derives from window.location when both env vars are empty. Same fix applied to TerminalTab. 2. AdminAuth blocking canvas: same-origin requests have no Origin header, so neither AdminAuth nor CanvasOrBearer could authenticate the canvas. Added isSameOriginCanvas() that checks Referer against request Host, gated behind CANVAS_PROXY_URL (only active on tenant image). This lets the canvas create/list workspaces, view events, etc. without a bearer token when served from the same Go process. Co-Authored-By: Claude Opus 4.6 (1M context) --- canvas/src/components/tabs/TerminalTab.tsx | 13 +++- canvas/src/store/socket.ts | 27 ++++++-- .../internal/middleware/wsauth_middleware.go | 61 ++++++++++++++++--- 3 files changed, 86 insertions(+), 15 deletions(-) diff --git a/canvas/src/components/tabs/TerminalTab.tsx b/canvas/src/components/tabs/TerminalTab.tsx index 371a5638..f306b63a 100644 --- a/canvas/src/components/tabs/TerminalTab.tsx +++ b/canvas/src/components/tabs/TerminalTab.tsx @@ -6,7 +6,18 @@ interface Props { workspaceId: string; } -const WS_URL = (process.env.NEXT_PUBLIC_WS_URL ?? "ws://localhost:8080").replace("/ws", ""); +// Derive base WebSocket URL (without /ws path) for terminal connections. +const WS_URL = (() => { + const explicit = process.env.NEXT_PUBLIC_WS_URL; + if (explicit) return explicit.replace("/ws", ""); + const platform = process.env.NEXT_PUBLIC_PLATFORM_URL; + if (platform) return platform.replace(/^http/, "ws"); + if (typeof window !== "undefined") { + const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; + return `${proto}//${window.location.host}`; + } + return "ws://localhost:8080"; +})(); export function TerminalTab({ workspaceId }: Props) { const containerRef = useRef(null); diff --git a/canvas/src/store/socket.ts b/canvas/src/store/socket.ts index 3362bcad..7c05018e 100644 --- a/canvas/src/store/socket.ts +++ b/canvas/src/store/socket.ts @@ -1,10 +1,27 @@ import { useCanvasStore } from "./canvas"; -export const WS_URL = - process.env.NEXT_PUBLIC_WS_URL ?? - (process.env.NEXT_PUBLIC_PLATFORM_URL ?? "http://localhost:8080") - .replace(/^http/, "ws") - .concat("/ws"); +// Derive WebSocket URL. Priority: +// 1. Explicit NEXT_PUBLIC_WS_URL (non-empty) +// 2. Derived from NEXT_PUBLIC_PLATFORM_URL (http→ws + /ws) +// 3. Derived from window.location (for same-origin tenant image) +// 4. Fallback to localhost +function deriveWsUrl(): string { + const explicit = process.env.NEXT_PUBLIC_WS_URL; + if (explicit) return explicit; + + const platform = process.env.NEXT_PUBLIC_PLATFORM_URL; + if (platform) return platform.replace(/^http/, "ws").concat("/ws"); + + // Same-origin tenant: derive from browser location + if (typeof window !== "undefined") { + const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; + return `${proto}//${window.location.host}/ws`; + } + + return "ws://localhost:8080/ws"; +} + +export const WS_URL = deriveWsUrl(); export interface WSMessage { event: string; diff --git a/platform/internal/middleware/wsauth_middleware.go b/platform/internal/middleware/wsauth_middleware.go index 825320e6..47ca268e 100644 --- a/platform/internal/middleware/wsauth_middleware.go +++ b/platform/internal/middleware/wsauth_middleware.go @@ -76,15 +76,28 @@ func AdminAuth(database *sql.DB) gin.HandlerFunc { return } if hasLive { + // Bearer token path — agents, CLI, and API clients. tok := wsauth.BearerTokenFromHeader(c.GetHeader("Authorization")) - if tok == "" { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "admin auth required"}) + if tok != "" { + if err := wsauth.ValidateAnyToken(ctx, database, tok); err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid admin auth token"}) + return + } + c.Next() return } - if err := wsauth.ValidateAnyToken(ctx, database, tok); err != nil { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid admin auth token"}) + // Canvas origin path — cross-origin canvas (CORS_ORIGINS match). + if canvasOriginAllowed(c.GetHeader("Origin")) { + c.Next() return } + // Same-origin canvas path — tenant image where canvas + API share a host. + if isSameOriginCanvas(c) { + c.Next() + return + } + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "admin auth required"}) + return } c.Next() } @@ -135,16 +148,18 @@ func CanvasOrBearer(database *sql.DB) gin.HandlerFunc { return } - // Path 2: canvas origin match. Read CORS_ORIGINS at request time so - // tests can override via t.Setenv. canvasOriginAllowed returns true - // iff Origin is non-empty AND exactly matches one of the configured - // origins. Empty Origin (same-origin / server-to-server) does NOT - // pass this check — those callers must use the bearer path. + // Path 2: canvas origin match (cross-origin canvas). if canvasOriginAllowed(c.GetHeader("Origin")) { c.Next() return } + // Path 3: same-origin canvas (tenant image). + if isSameOriginCanvas(c) { + c.Next() + return + } + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "admin auth required"}) } } @@ -173,3 +188,31 @@ func canvasOriginAllowed(origin string) bool { } return false } + +// isSameOriginCanvas returns true when the request appears to come from the +// canvas UI served by the same Go process (tenant image). In this topology, +// the browser sends same-origin requests with an empty Origin header but a +// Referer matching the request Host. We accept these requests because the +// canvas is the trusted frontend — same as if Origin matched CORS_ORIGINS. +// +// This only fires when CANVAS_PROXY_URL is set (i.e. the combined tenant +// image is active), so self-hosted / dev setups with separate canvas and +// platform origins are unaffected. +func isSameOriginCanvas(c *gin.Context) bool { + if os.Getenv("CANVAS_PROXY_URL") == "" { + return false + } + referer := c.GetHeader("Referer") + if referer == "" { + return false + } + host := c.Request.Host + if host == "" { + return false + } + // Referer starts with https:/// or http:/// + return strings.HasPrefix(referer, "https://"+host+"/") || + strings.HasPrefix(referer, "http://"+host+"/") || + strings.HasPrefix(referer, "https://"+host) || + strings.HasPrefix(referer, "http://"+host) +}