Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
43 changes: 43 additions & 0 deletions internal/pkg/service/appsproxy/proxy/proxy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down