forked from molecule-ai/molecule-core
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) <noreply@anthropic.com>
This commit is contained in:
parent
de9f3d179c
commit
25bd9241d1
@ -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<HTMLDivElement>(null);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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://<host>/ or http://<host>/
|
||||
return strings.HasPrefix(referer, "https://"+host+"/") ||
|
||||
strings.HasPrefix(referer, "http://"+host+"/") ||
|
||||
strings.HasPrefix(referer, "https://"+host) ||
|
||||
strings.HasPrefix(referer, "http://"+host)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user