diff --git a/internal/pkg/service/appsproxy/proxy/apphandler/upstream/upstream.go b/internal/pkg/service/appsproxy/proxy/apphandler/upstream/upstream.go index d36ed4b4e8..d01b360a4b 100644 --- a/internal/pkg/service/appsproxy/proxy/apphandler/upstream/upstream.go +++ b/internal/pkg/service/appsproxy/proxy/apphandler/upstream/upstream.go @@ -234,6 +234,27 @@ func (u *AppUpstream) newProxy(timeout time.Duration) *chain.Chain { func (u *AppUpstream) newWebsocketProxy(timeout time.Duration) *chain.Chain { proxy := u.newReverseProxy() + // ****************************************************************************** + // TEMPORARY WORKAROUND — remove once Streamlit apps are configured with + // STREAMLIT_BROWSER_SERVER_ADDRESS / STREAMLIT_BROWSER_SERVER_PORT env vars. + // + // Streamlit (Tornado) checks WebSocket origin by comparing the Origin header + // against the Host header. Since apps-proxy rewrites Host to the upstream + // hostname (required for LB routing), Origin (the public domain set by the + // browser) no longer matches and Tornado rejects the connection with 403. + // + // Rewriting Origin to the upstream hostname makes the request look like a + // direct browser connection, which is what every framework expects. + // ****************************************************************************** + if u.target != nil { + upstreamOrigin := u.target.Scheme + "://" + u.target.Host + origRewrite := proxy.Rewrite + proxy.Rewrite = func(r *httputil.ProxyRequest) { + origRewrite(r) + r.Out.Header.Set("Origin", upstreamOrigin) + } + } + return chain. New(chain.HandlerFunc(func(w http.ResponseWriter, req *http.Request) error { ctx := ctxattr.ContextWith(req.Context(), attribute.Bool(attrWebsocket, true)) diff --git a/internal/pkg/service/appsproxy/proxy/proxy_test.go b/internal/pkg/service/appsproxy/proxy/proxy_test.go index 5eb6a21cae..8a09b2cf1d 100644 --- a/internal/pkg/service/appsproxy/proxy/proxy_test.go +++ b/internal/pkg/service/appsproxy/proxy/proxy_test.go @@ -473,6 +473,49 @@ func TestAppProxyRouter(t *testing.T) { // X-Forwarded-For contains the client IP. assert.NotEmpty(t, appRequest.Header.Get("X-Forwarded-For")) + + // Origin is rewritten to the upstream hostname (Streamlit/Tornado workaround). + assert.Equal(t, "http://"+appServer.Listener.Addr().String(), appRequest.Header.Get("Origin")) + }, + expectedNotifications: map[string]int{ + "123": 1, + }, + }, + { + // Streamlit (Tornado) rejects WebSocket connections when Origin does not match Host. + // apps-proxy rewrites Host to the upstream hostname for LB routing, so Origin + // (set by the browser to the public domain) would mismatch. + // Verify the temporary workaround: Origin is rewritten to the upstream hostname. + name: "websocket-origin-rewrite-streamlit-workaround", + run: func(t *testing.T, client *http.Client, m []*mockoidc.MockOIDC, appServer *testutil.AppServer, service *testutil.DataAppsAPI, fakeClient *k8sfake.FakeDynamicClient, watcher *k8sapp.StateWatcher) { + ctx, cancel := context.WithTimeout(t.Context(), time.Minute) + defer cancel() + + c, _, err := websocket.Dial( + ctx, + "wss://public-123.hub.keboola.local/ws", + &websocket.DialOptions{ + HTTPClient: client, + // Simulate browser setting Origin to the public domain. + HTTPHeader: http.Header{ + "Origin": []string{"https://public-123.hub.keboola.local"}, + }, + }, + ) + require.NoError(t, err) + + var v any + err = wsjson.Read(ctx, c, &v) + require.NoError(t, err) + assert.Equal(t, "Hello websocket", v) + require.NoError(t, c.Close(websocket.StatusNormalClosure, "")) + + require.Len(t, *appServer.Requests, 1) + appRequest := (*appServer.Requests)[0] + + // Origin must be rewritten to the upstream hostname so Tornado's + // check_origin() sees Origin == Host and accepts the connection. + assert.Equal(t, "http://"+appServer.Listener.Addr().String(), appRequest.Header.Get("Origin")) }, expectedNotifications: map[string]int{ "123": 1,