package websocket import ( "context" "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "fmt" "net/http" "net/http/httputil" "net/url" "path" "strconv" "strings" "sync" "time" "github.com/gin-gonic/gin" "go.uber.org/zap" "github.com/kandev/kandev/internal/agent/runtime/lifecycle" "github.com/kandev/kandev/internal/auth/authn" "github.com/kandev/kandev/internal/common/logger" ) // Port-proxy capability credential. // // With authentication enforced, every request to /port-proxy/:sessionId/:port/* // requires a credential (session cookie, PAT, or ?token=). The preview document // carries one, but the browser's subresource fetches do not always: fetches are made with credentials omitted, so they never send // the session cookie, and headerless contexts (sandboxed iframes, ?token= URLs) // drop it from other requests too. A document that authenticated would 200 // while every asset 401s — the preview appears broken. // // The handler therefore mints a short-lived, path-scoped capability after the // document authenticates and propagates it two ways: as a cookie scoped to the // proxy subtree (covers cookie-sending subresource fetches) and appended to the // rewritten asset URLs in proxied HTML/CSS (covers the credential-less manifest // fetch and cookie-less contexts). requireConnectionAuth accepts either form // for the matching /port-proxy/ subtree and reconstructs the identity, so // CheckSessionAccess still gates on the real owner. const ( // proxyCapabilityCookieName is the cookie carrying the subtree capability. proxyCapabilityCookieName = "kandev_port_proxy" // proxyCapabilityQueryParam is the query parameter appended to rewritten // asset URLs by rewriteProxyResponse. proxyCapabilityQueryParam = "kandev_cap" // proxyCapabilityTTL is how long a minted capability stays valid. Sliding: // every proxied response mints a fresh one. Previews opened with a durable // session cookie re-authenticate indefinitely; a preview that relies // solely on the capability (no cookie storage) is deliberately short-lived // — after an idle proxyCapabilityTTL the capability expires and the // subtree requires a fresh credential. proxyCapabilityTTL = 15 * time.Minute ) // proxyCapabilityContextKey carries the capability for the current request from // the handler into the cached proxy's ModifyResponse, which appends it to // rewritten asset URLs. type proxyCapabilityContextKey struct{} // proxyCSSBaseContextKey carries the PUBLIC directory (proxy prefix + app // path directory, trailing slash) that a standalone CSS response resolves // relative references against. The request path is rewritten to the // agentctl form before the proxy runs, so the public path must be captured // at the handler. type proxyCSSBaseContextKey struct{} // publicCSSBase returns the public directory (trailing slash) that a // standalone CSS file at the given public path resolves relative references // against: proxyPrefix + the directory of the app-relative path. func publicCSSBase(proxyPrefix, remaining string) string { if remaining == "" { return "" } dir := path.Dir(remaining) if dir == "." || dir == "/" { return proxyPrefix + "/" } return proxyPrefix + "/" + strings.TrimPrefix(dir, "/") + "/" } // proxyTokenConsumedKey marks that requireConnectionAuth authenticated this // request via ?token=, so the port proxy strips the token from the // forwarded query. When absent, a ?token= parameter belongs to the app and is // forwarded untouched. type proxyTokenConsumedKey struct{} // portProxyEntry caches a reverse proxy and its target for a session:port pair. type portProxyEntry struct { proxy *httputil.ReverseProxy target string } // PortProxyHandler reverse-proxies HTTP and WebSocket traffic to arbitrary // localhost ports inside a remote executor via agentctl. type PortProxyHandler struct { lifecycleMgr *lifecycle.Manager logger *logger.Logger // capabilitySecret signs the subtree capability cookie/query credentials. // Per-process random: capabilities are short-lived, so a backend restart // simply invalidates them. capabilitySecret [32]byte mu sync.Mutex proxies map[string]*portProxyEntry // key: "sessionId:port" } // NewPortProxyHandler creates a new port proxy handler. func NewPortProxyHandler(lifecycleMgr *lifecycle.Manager, log *logger.Logger) *PortProxyHandler { secret := [32]byte{} if _, err := rand.Read(secret[:]); err != nil { // crypto/rand never fails on supported platforms; a deterministic // fallback would make capabilities forgeable, so fail loudly instead // of serving an auth bypass. panic(fmt.Sprintf("port proxy: generate capability secret: %v", err)) } return &PortProxyHandler{ lifecycleMgr: lifecycleMgr, logger: log.WithFields(zap.String("component", "port-proxy")), capabilitySecret: secret, proxies: make(map[string]*portProxyEntry), } } // HandlePortProxy handles all HTTP/WS requests to /port-proxy/:sessionId/:port/*path. func (h *PortProxyHandler) HandlePortProxy(c *gin.Context) { sessionID := c.Param("sessionId") if sessionID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "sessionId is required"}) return } // Per-user scoping (opt-in auth): authorize session ownership before the // bare execution lookup / per-session proxy cache (see vscode_proxy.go). if err := h.lifecycleMgr.CheckSessionExecAccess(c.Request.Context(), sessionID); err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "session not found"}) return } portStr := c.Param("port") port, err := strconv.Atoi(portStr) if err != nil || port < 1024 || port > 65535 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid port: must be 1024-65535"}) return } // After the request authenticated (session cookie, PAT, ?token=, or a // previously issued subtree capability), mint a fresh short-lived // capability for this session:port subtree. It authorizes the browser's // subresource fetches — which cannot always carry the session credential — // for the next proxyCapabilityTTL, sliding with every proxied response. // Synthetic identities (auth disabled) skip it: with no auth anywhere the // subtree needs no credential, and the proxied bodies stay byte-identical // to the pre-auth behavior. capability := "" if identity, ok := authn.FromGin(c); ok && !identity.Synthetic { capability = h.issueCapability(sessionID, port, identity) h.setCapabilityCookie(c, sessionID, port, capability) ctx := context.WithValue(c.Request.Context(), proxyCapabilityContextKey{}, capability) c.Request = c.Request.WithContext(ctx) } proxy, err := h.resolveProxy(c, sessionID, port) if err != nil { return // error already written to response } // Rewrite path: strip /port-proxy/:sessionId/:port prefix, // forward as /api/v1/port-proxy/:port/{remainingPath} to agentctl. originalPath := c.Request.URL.Path prefix := "/port-proxy/" + sessionID + "/" + portStr remaining := strings.TrimPrefix(originalPath, prefix) if remaining == "" { remaining = "/" } // The runtime shim is served by the gateway itself (same-origin, so an // app Content-Security-Policy that allows 'self' scripts lets it load), // not by the proxied app. if remaining == "/"+runtimeShimPath { h.serveRuntimeShim(c, prefix, capability) return } c.Request.URL.Path = "/api/v1/port-proxy/" + portStr + remaining c.Request.URL.RawPath = "" // The request path is rewritten to the agentctl form above, so the // public path is unrecoverable at ModifyResponse time. Stash the PUBLIC // stylesheet directory for standalone CSS rewriting: relative CSS // references resolve against the stylesheet file's public directory. if dir := publicCSSBase(prefix, remaining); dir != "" { c.Request = c.Request.WithContext(context.WithValue(c.Request.Context(), proxyCSSBaseContextKey{}, dir)) } h.logger.Debug("port proxy forwarding", zap.String("session_id", sessionID), zap.Int("port", port), zap.String("original_path", originalPath), zap.String("rewritten_path", c.Request.URL.Path)) defer func() { if r := recover(); r != nil { if r == http.ErrAbortHandler { h.logger.Debug("port proxy: client disconnected", zap.String("session_id", sessionID), zap.Int("port", port)) return } panic(r) } }() proxy.ServeHTTP(c.Writer, c.Request) } func (h *PortProxyHandler) resolveProxy(c *gin.Context, sessionID string, port int) (*httputil.ReverseProxy, error) { cacheKey := sessionID + ":" + strconv.Itoa(port) h.mu.Lock() defer h.mu.Unlock() if entry, ok := h.proxies[cacheKey]; ok { return entry.proxy, nil } execution, ok := h.lifecycleMgr.GetExecutionBySessionID(sessionID) if !ok { c.JSON(http.StatusNotFound, gin.H{"error": "session not found or no active execution"}) return nil, fmt.Errorf("session not found") } agentctlClient, releaseClient := execution.AcquireAgentCtlClient() defer releaseClient() if agentctlClient == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "agentctl client not available"}) return nil, fmt.Errorf("agentctl client not available") } baseURL := agentctlClient.BaseURL() target, err := url.Parse(baseURL) if err != nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "failed to resolve agentctl target"}) return nil, err } authToken := agentctlClient.AuthToken() // Public path that fronts this proxy on the gateway. Used by `ModifyResponse` // to rewrite root-absolute URLs in proxied HTML/CSS so iframe asset requests // stay on the same proxy chain instead of escaping to the host origin. proxyPrefix := "/port-proxy/" + sessionID + "/" + strconv.Itoa(port) proxy := h.createProxy(cacheKey, target, authToken, proxyPrefix) h.proxies[cacheKey] = &portProxyEntry{proxy: proxy, target: baseURL} h.logger.Info("created port proxy", zap.String("session_id", sessionID), zap.Int("port", port), zap.String("execution_id", execution.ID), zap.String("target", baseURL)) return proxy, nil } func (h *PortProxyHandler) createProxy(cacheKey string, target *url.URL, authToken, proxyPrefix string) *httputil.ReverseProxy { proxy := &httputil.ReverseProxy{} proxy.Rewrite = func(r *httputil.ProxyRequest) { r.SetURL(target) r.Out.URL.Path = r.In.URL.Path r.Out.URL.RawPath = "" // Never forward the browser's Accept-Encoding: Go's Transport adds // its own and transparently decompresses, so ModifyResponse (and the // HTML/CSS rewriter) always sees identity bytes. Forwarding gzip/br // would deliver compressed bytes to the rewriter, which then strips // Content-Encoding while keeping the compressed body. r.Out.Header.Del("Accept-Encoding") // The subtree capability is consumed by requireConnectionAuth at the // gateway; strip it (in any encoding) before forwarding so it never // lands in agentctl or application logs, redirects, or analytics. Only // the reserved pairs are removed — every other parameter keeps its // original bytes and order. The ?token= PAT is stripped only when the // gateway actually consumed it for authentication; a cookie-authenticated // app's own token parameter passes through. if consumed, _ := r.In.Context().Value(proxyTokenConsumedKey{}).(bool); consumed { r.Out.URL.RawQuery = stripReservedProxyParams(r.Out.URL.RawQuery) } else { r.Out.URL.RawQuery = stripCapabilityParam(r.Out.URL.RawQuery) } // The browser may send a rewritten asset URL (which embeds the // capability, possibly percent-encoded) as the Referer of a later // request; sanitize every Referer. Capability-free Referers are left // byte-identical; malformed ones are dropped rather than forwarded // with their query intact. if ref := r.Out.Header.Get("Referer"); ref != "" { if parsed, err := url.Parse(ref); err == nil { cleaned := stripReservedProxyParams(parsed.RawQuery) if cleaned != parsed.RawQuery { parsed.RawQuery = cleaned r.Out.Header.Set("Referer", parsed.String()) } } else { r.Out.Header.Del("Referer") } } // Inject agentctl auth token if authToken != "" { r.Out.Header.Set("Authorization", "Bearer "+authToken) } if r.Out.Header.Get("Upgrade") != "" { r.Out.Header.Set("Connection", "Upgrade") } } proxy.ModifyResponse = func(resp *http.Response) error { filterReservedCookies(resp.Header) capability, _ := resp.Request.Context().Value(proxyCapabilityContextKey{}).(string) if capability != "" { // Every response in an authenticated proxy subtree carries the // per-user capability cookie, and rewritten bodies embed the // capability: a shared intermediary must not retain or replay them // for another user, and the browser must not leak the embedded // capability to external origins through the Referer header. This // applies to every response class (JS/JSON/redirects/upgrades // included), not just the HTML/CSS bodies the rewriter touches. resp.Header.Set("Cache-Control", "private, no-store") resp.Header.Set("Referrer-Policy", "no-referrer") } // Redirect Location headers are root-relative to the proxied app; the // browser resolves them against the gateway origin, so they must be // re-anchored on the proxy subtree (navigation: no capability). if loc := resp.Header.Get("Location"); loc != "" { resp.Header.Set("Location", rewriteURLReference(loc, proxyPrefix, "")) } if resp.StatusCode == http.StatusSwitchingProtocols { resp.Header.Set("Connection", "Upgrade") return nil } if resp.Request.Method == http.MethodHead { // HEAD responses carry no body; rewriting would synthesize a // misleading Content-Length for the would-be GET body. return nil } return rewriteProxyResponse(resp, proxyPrefix, capability) } proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { h.logger.Error("port proxy error", zap.String("cache_key", cacheKey), zap.String("request_path", r.URL.Path), zap.Error(err)) h.invalidateProxy(cacheKey) http.Error(w, "port proxy error", http.StatusBadGateway) } // Flush immediately for SSE/streaming responses. proxy.FlushInterval = -1 return proxy } func (h *PortProxyHandler) invalidateProxy(cacheKey string) { h.mu.Lock() defer h.mu.Unlock() delete(h.proxies, cacheKey) } // InvalidateSession removes all cached proxies for a session. func (h *PortProxyHandler) InvalidateSession(sessionID string) { h.mu.Lock() defer h.mu.Unlock() prefix := sessionID + ":" for key := range h.proxies { if strings.HasPrefix(key, prefix) { delete(h.proxies, key) } } } // capabilityPayload is the signed, tamper-evident body of a subtree capability. // The identity travels inside the payload so requireConnectionAuth can restore // it and CheckSessionAccess still runs as the real owner. type capabilityPayload struct { UserID string `json:"u"` Role string `json:"r"` SessionID string `json:"s"` Port int `json:"p"` ExpiresAt int64 `json:"e"` // unix seconds } // issueCapability mints a signed capability for the session:port subtree with // the default TTL. func (h *PortProxyHandler) issueCapability(sessionID string, port int, identity authn.Identity) string { return h.issueCapabilityWithTTL(sessionID, port, identity, proxyCapabilityTTL) } // issueCapabilityWithTTL mints a signed capability with an explicit TTL (test // seam for expiry coverage). func (h *PortProxyHandler) issueCapabilityWithTTL(sessionID string, port int, identity authn.Identity, ttl time.Duration) string { payload := capabilityPayload{ UserID: identity.UserID, Role: string(identity.Role), SessionID: sessionID, Port: port, ExpiresAt: time.Now().Add(ttl).Unix(), } raw, err := json.Marshal(payload) if err != nil { // The payload is a fixed shape; marshal cannot fail. return "" } encoded := base64.RawURLEncoding.EncodeToString(raw) mac := h.signCapability(raw) return encoded + "." + mac } // validateCapability verifies the signature, expiry, and session:port target of // a capability and returns the identity it was issued to. func (h *PortProxyHandler) validateCapability(raw, sessionID string, port int) (authn.Identity, bool) { dot := strings.IndexByte(raw, '.') if dot <= 0 || dot == len(raw)-1 { return authn.Identity{}, false } encoded, mac := raw[:dot], raw[dot+1:] payload, err := base64.RawURLEncoding.DecodeString(encoded) if err != nil { return authn.Identity{}, false } if !hmac.Equal([]byte(h.signCapability(payload)), []byte(mac)) { return authn.Identity{}, false } var parsed capabilityPayload if err := json.Unmarshal(payload, &parsed); err != nil { return authn.Identity{}, false } if parsed.SessionID != sessionID || parsed.Port != port { return authn.Identity{}, false } // Standard expiry semantics: the capability is valid strictly before // ExpiresAt; at the exact boundary (now == ExpiresAt) it is expired. if time.Now().Unix() >= parsed.ExpiresAt { return authn.Identity{}, false } return authn.Identity{UserID: parsed.UserID, Role: authn.Role(parsed.Role)}, true } // signCapability computes the HMAC-SHA256 of the capability payload. func (h *PortProxyHandler) signCapability(payload []byte) string { mac := hmac.New(sha256.New, h.capabilitySecret[:]) _, _ = mac.Write(payload) return hex.EncodeToString(mac.Sum(nil)) } // cookieName extracts the cookie name from a Set-Cookie header value (the // token before the first '='), trimming leading/trailing whitespace. An // empty or malformed value yields "". func cookieName(setCookie string) string { name, _, _ := strings.Cut(setCookie, "=") return strings.TrimSpace(name) } // filterReservedCookies removes every upstream Set-Cookie whose name is the // reserved capability cookie: a proxied app must never control // kandev_port_proxy, since an app value with the same or a narrower Path // could override the gateway's capability and 401 the authenticated subtree. func filterReservedCookies(h http.Header) { cookies := h["Set-Cookie"] if len(cookies) == 0 { return } kept := cookies[:0] for _, c := range cookies { if cookieName(c) == proxyCapabilityCookieName { continue } kept = append(kept, c) } if len(kept) == 0 { h.Del("Set-Cookie") return } h["Set-Cookie"] = kept } // setCapabilityCookie sets the subtree-scoped capability cookie on the response // so the browser's cookie-sending subresource fetches stay authorized. The // cookie's Path is exactly the proxy subtree, so it is never presented to (or // accepted for) any other path. func (h *PortProxyHandler) setCapabilityCookie(c *gin.Context, sessionID string, port int, capability string) { if capability == "" { return } path := "/port-proxy/" + sessionID + "/" + strconv.Itoa(port) c.SetSameSite(http.SameSiteLaxMode) c.SetCookie(proxyCapabilityCookieName, capability, int(proxyCapabilityTTL.Seconds()), path, "", requestIsTLS(c), true) } // requestIsTLS reports whether the request arrived over TLS, directly or via a // reverse proxy setting X-Forwarded-Proto. Mirrors the auth cookie helper so // the Secure flag tracks the session cookie. func requestIsTLS(c *gin.Context) bool { if c.Request.TLS != nil { return true } return strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https") } // proxyTokenQueryParam is the ?token= query credential requireConnectionAuth // accepts on proxy routes. Like the capability, it is consumed at the gateway // and must never be forwarded to the proxied app. const proxyTokenQueryParam = "token" // stripCapabilityParam removes the capability query parameter from a raw query // string. See stripReservedProxyParams. func stripCapabilityParam(rawQuery string) string { return stripQueryParam(rawQuery, proxyCapabilityQueryParam) } // stripQueryParam removes the named query parameter from a raw query string, // matching the decoded key so percent-encoded spellings are caught too. func stripQueryParam(rawQuery, name string) string { if rawQuery == "" { return rawQuery } parts := strings.Split(rawQuery, "&") out := make([]string, 0, len(parts)) for _, part := range parts { key, _, _ := strings.Cut(part, "=") if decoded, err := url.QueryUnescape(key); err == nil { key = decoded } if key != name { out = append(out, part) } } return strings.Join(out, "&") } // stripReservedProxyParams removes every reserved gateway credential pair // (kandev_cap, token) from a raw query string, matching the decoded key so // percent-encoded spellings (%6bandev_cap=) are caught too. Every other // parameter keeps its original bytes and order — nothing is re-encoded. // Returns the input unchanged when no reserved pair is present. // // The grammar is "&"-separated pairs, which is the only form the gateway ever // issues (the rewriter and runtime shim append with "?"/"&") and the only form // the gateway's own query parser accepts as a credential; semicolon- or // space-separated spellings cannot carry a gateway-issued capability, so they // are out of scope here. func stripReservedProxyParams(rawQuery string) string { if rawQuery == "" { return rawQuery } parts := strings.Split(rawQuery, "&") out := make([]string, 0, len(parts)) for _, part := range parts { key, _, _ := strings.Cut(part, "=") if decoded, err := url.QueryUnescape(key); err == nil { key = decoded } if key != proxyCapabilityQueryParam && key != proxyTokenQueryParam { out = append(out, part) } } return strings.Join(out, "&") } // portProxyTarget extracts the session:port subtree from a request handled by // the /port-proxy/:sessionId/:port(/...) routes. func portProxyTarget(c *gin.Context) (sessionID string, port int, ok bool) { sessionID = c.Param("sessionId") portStr := c.Param("port") if sessionID == "" || portStr == "" { return "", 0, false } port, err := strconv.Atoi(portStr) if err != nil || port < 1024 || port > 65535 { return "", 0, false } return sessionID, port, true } // serveRuntimeShim answers the reserved per-subtree runtime shim path with the // shim JavaScript, prefix and capability baked in. Served same-origin by the // gateway so an app Content-Security-Policy allowing 'self' scripts does not // block the proxy's runtime fixes; never cached (per-user body). func (h *PortProxyHandler) serveRuntimeShim(c *gin.Context, prefix, capability string) { js := runtimeShim(prefix, capability) c.Header("Content-Type", "application/javascript; charset=utf-8") c.Header("Cache-Control", "private, no-store") c.Header("Referrer-Policy", "no-referrer") c.Data(http.StatusOK, "application/javascript; charset=utf-8", []byte(js)) }