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:
Hongming Wang 2026-04-16 08:43:01 -07:00
parent de9f3d179c
commit 25bd9241d1
3 changed files with 86 additions and 15 deletions

View File

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

View File

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

View File

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