Skip to content

Ingress WebSocket proxy drops X-EXECD-ACCESS-TOKEN header causing execd 401 / bad handshake #1050

@GreenShadeZhang

Description

@GreenShadeZhang

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

  1. Create a sandbox with execd, set EXECD_ACCESS_TOKEN=my-token
  2. Create a PTY session via POST /pty (this works — httputil.ReverseProxy forwards all headers)
  3. 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.

Metadata

Metadata

Assignees

Labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions