Description
When using the ingress in URI mode to proxy WebSocket connections to execd's PTY endpoint (/pty/{sessionId}/ws), the ingress WebSocketProxy drops the X-EXECD-ACCESS-TOKEN header. Execd's accessTokenMiddleware then rejects the upgrade with 401, and the ingress logs "WebSocketProxy: couldn't dial to remote backend" with error "websocket: bad handshake".
Environment
- Ingress mode: URI (wildcard gateway)
- Execd port: 44772
- Execd configured with
EXECD_ACCESS_TOKEN
Steps to Reproduce
- Create a sandbox with execd, set
EXECD_ACCESS_TOKEN=my-token
- Create a PTY session via
POST /pty (this works — httputil.ReverseProxy forwards all headers)
- Try to connect WebSocket through ingress:
ws://ingress/{sandboxId}/44772/pty/{sessionId}/ws?since=0
Header: X-EXECD-ACCESS-TOKEN: my-token
Expected Behavior
WebSocket upgrade succeeds, bidirectional PTY relay works.
Actual Behavior
Ingress logs:
{"level":"error","msg":"WebSocketProxy: couldn't dial to remote backend","error":"websocket: bad handshake"}
Execd rejects because X-EXECD-ACCESS-TOKEN never reaches it.
Root Cause
components/ingress/pkg/proxy/websocket.go ~lines 86–112 only forwards a hardcoded whitelist of headers to the backend:
requestHeader := http.Header{}
if origin := r.Header.Get(Origin); origin != "" {
requestHeader.Add(Origin, origin)
}
for _, prot := range r.Header[SecWebSocketProtocol] {
requestHeader.Add(SecWebSocketProtocol, prot)
}
for _, cokiee := range r.Header[Cookie] {
requestHeader.Add(Cookie, cokiee)
}
// Host, X-Forwarded-For, X-Forwarded-Proto
// ⚠️ X-EXECD-ACCESS-TOKEN and all other custom headers are SILENTLY DROPPED
Meanwhile components/execd/pkg/web/router.go:121–135 requires this header on every request:
func accessTokenMiddleware(token string) gin.HandlerFunc {
return func(ctx *gin.Context) {
if token == "" { ctx.Next(); return }
requestedToken := ctx.GetHeader("X-EXECD-ACCESS-TOKEN")
if requestedToken == "" || requestedToken != token {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, ...)
return
}
ctx.Next()
}
}
The HTTP proxy path works correctly because httputil.ReverseProxy forwards all non-hop-by-hop headers by default. Only the WebSocket proxy path has this issue.
Suggested Fix
In components/ingress/pkg/proxy/proxy.go serve(), use the WebSocketProxy.Director hook (already supported) to forward the auth header:
func (p *Proxy) serve(w http.ResponseWriter, r *http.Request) {
if p.isWebSocketRequest(r) {
// ...existing scheme setup...
wsProxy := NewWebSocketProxy(r.URL)
wsProxy.Director = func(incoming *http.Request, out http.Header) {
if v := incoming.Header.Get("X-EXECD-ACCESS-TOKEN"); v != "" {
out.Set("X-EXECD-ACCESS-TOKEN", v)
}
if v := incoming.Header.Get("Authorization"); v != "" {
out.Set("Authorization", v)
}
}
wsProxy.ServeHTTP(w, r)
}
// ...
}
Or alternatively (more general), forward all non-WebSocket-handshake, non-hop-by-hop headers in the WebSocketProxy.ServeHTTP method itself.
Note
The WebSocketProxy.Director field already exists (see websocket.go line ~46) and is checked at line ~138:
if w.director != nil {
w.director(r, requestHeader)
}
It is simply never set by the ingress Proxy.serve() method. Using it would be the most targeted fix with minimal risk.
Description
When using the ingress in URI mode to proxy WebSocket connections to execd's PTY endpoint (
/pty/{sessionId}/ws), the ingressWebSocketProxydrops theX-EXECD-ACCESS-TOKENheader. Execd'saccessTokenMiddlewarethen rejects the upgrade with 401, and the ingress logs"WebSocketProxy: couldn't dial to remote backend"with error"websocket: bad handshake".Environment
EXECD_ACCESS_TOKENSteps to Reproduce
EXECD_ACCESS_TOKEN=my-tokenPOST /pty(this works —httputil.ReverseProxyforwards all headers)Expected Behavior
WebSocket upgrade succeeds, bidirectional PTY relay works.
Actual Behavior
Ingress logs:
Execd rejects because
X-EXECD-ACCESS-TOKENnever reaches it.Root Cause
components/ingress/pkg/proxy/websocket.go~lines 86–112 only forwards a hardcoded whitelist of headers to the backend:Meanwhile
components/execd/pkg/web/router.go:121–135requires this header on every request:The HTTP proxy path works correctly because
httputil.ReverseProxyforwards all non-hop-by-hop headers by default. Only the WebSocket proxy path has this issue.Suggested Fix
In
components/ingress/pkg/proxy/proxy.goserve(), use theWebSocketProxy.Directorhook (already supported) to forward the auth header:Or alternatively (more general), forward all non-WebSocket-handshake, non-hop-by-hop headers in the
WebSocketProxy.ServeHTTPmethod itself.Note
The
WebSocketProxy.Directorfield already exists (seewebsocket.goline ~46) and is checked at line ~138:It is simply never set by the ingress
Proxy.serve()method. Using it would be the most targeted fix with minimal risk.