Bug Fix: iDempiere 13 WebSocket ServerPush Fails Behind Reverse Proxy

The Problem

If you deploy iDempiere 13 behind any reverse proxy (Nginx, Traefik, Apache, HAProxy, Kubernetes Ingress, cloud load balancers, etc.), the login page breaks silently: after entering username and password, the Role, Client, Organization, and Warehouse fields remain permanently disabled. The spinner never resolves.

This affects any production deployment with a reverse proxy — which is essentially every production environment.

Background: What Changed in Release 13

In iDempiere 12, the WebSocket endpoint (ServerPushEndPoint) was simple: it only handled ping/pong keepalive and echo notifications. All ZK AU (Asynchronous Update) requests went through normal HTTP.

IDEMPIERE-3840 in release 13 introduced a major architectural change: the WebSocket now proxies ZK AU requests. When a message starts with zkau;, the endpoint parses the JSON payload and makes an internal HTTP POST to localhost targeting /zkau, then returns the servlet response over the WebSocket.

This internal loopback mechanism was designed to reduce latency and improve real-time responsiveness. However, it introduced three bugs when running behind a reverse proxy.

Root Cause: Three Bugs

Bug 1: Wrong Session Cookie Name

The internal HTTP POST used JSESSIONID as the cookie name:

String jsessionidCookie = "JSESSIONID=" + sessionId;

But iDempiere configures its session cookie as WEBUI_SESSIONID in web.xml. The servlet container couldn't match the session, so the AU request was processed without a valid session context.

Bug 2: Wrong Port for Internal Loopback POST

The code used requestUri.getPort() from the WebSocket handshake to build the loopback URL:

int port = requestUri.getPort(); // Returns 443 (proxy), not 8080 (Jetty)

Behind a reverse proxy, this returns the external port (e.g., 443) instead of the internal Jetty port (e.g., 8080). The POST to localhost:443 fails silently because nothing is listening on that port internally.

Bug 3: Null httpSession Behind Reverse Proxy

When TLS is terminated at the reverse proxy and ZK uses URL-based session tracking (;jsessionid=... in URLs instead of cookies), the WebSocket upgrade request doesn't include the session ID. HandshakeRequest.getHttpSession() returns null.

The original code in EndpointConfigurator only stored the HandshakeRequest when httpSession != null:

if (httpSession != null) {
    // ... store everything, including HandshakeRequest
    sec.getUserProperties().put(HandshakeRequest.class.getName(), request);
}

So in ServerPushEndPoint.onOpen(), both baseUrl and httpSession were null. In onMessage(), new StringBuilder(this.baseUrl) threw a NullPointerException, and Jetty closed the WebSocket with code 1003 ("Endpoint notification error").

The Fix

Three changes across three files. All backward-compatible — no behavior change for direct deployments without a reverse proxy.

1. EndpointConfigurator.java

Store HandshakeRequest unconditionally, before the httpSession null check:

@Override
public void modifyHandshake(ServerEndpointConfig sec, 
        HandshakeRequest request, HandshakeResponse response) {
    // Always store HandshakeRequest so ServerPushEndPoint can build baseUrl
    // even when httpSession is null (e.g., behind a reverse proxy)
    sec.getUserProperties().put(HandshakeRequest.class.getName(), request);

    HttpSession httpSession = (HttpSession) request.getHttpSession();
    if (httpSession != null) {
        // ... rest unchanged
    }
}

2. ServerPushEndPoint.java

Cookie name fix:

// Before:
String jsessionidCookie = "JSESSIONID=" + sessionId;
// After:
String jsessionidCookie = "WEBUI_SESSIONID=" + sessionId;

Port override:

String overridePort = System.getProperty("org.adempiere.server.port");
int port;
if (overridePort != null && !overridePort.isEmpty()) {
    port = Integer.parseInt(overridePort);
} else {
    port = requestUri.getPort(); // fallback for direct deployments
}

Null httpSession fallback:

if (httpSession != null) {
    sessionId = httpSession.getId();
} else {
    // Fallback: get session ID from WebSocketServerPush registry
    sessionId = WebSocketServerPush.getHttpSessionId(this.dtid);
    if (sessionId == null) {
        // log warning and return error to client
        return;
    }
}

3. WebSocketServerPush.java

New static sessionIdMap that stores the HTTP session ID in start() when the ZK context is available (the ZK desktop always has access to the HTTP session). Provides a getHttpSessionId(dtid) method as fallback, and cleans up in unregisterEndPoint():

private final static Map<String, String> sessionIdMap = new ConcurrentHashMap<>();

@Override
public void start(Desktop desktop) {
    // ... existing code ...
    var zkSession = desktop.getSession();
    if (zkSession != null) {
        Object nativeSession = zkSession.getNativeSession();
        if (nativeSession instanceof HttpSession httpSess) {
            sessionIdMap.put(desktop.getId(), httpSess.getId());
        }
    }
}

public static String getHttpSessionId(String dtid) {
    return sessionIdMap.get(dtid);
}

public static boolean unregisterEndPoint(String dtid) {
    ServerPushEndPoint endpoint = endPointMap.remove(dtid);
    sessionIdMap.remove(dtid); // cleanup
    // ...
}

Why Not Fix It at the Infrastructure Level?

You might wonder: can't we just configure the reverse proxy to fix this? Short answer: no.

  • X-Forwarded-Port headers: The code doesn't read them — it uses requestUri.getPort() directly. And even if it did, the internal POST needs the Jetty port, not the proxy port. No standard header provides the internal servlet container port.
  • Cookie forwarding: Even with perfect proxy config, the cookie name mismatch (JSESSIONID vs WEBUI_SESSIONID) is hardcoded in Java.
  • Session tracking: Depends on ZK's session tracking mode, not just proxy configuration.

Performance Impact

Zero measurable impact. All changes are O(1) operations on ConcurrentHashMap that execute once per WebSocket connection (not per request). The fallback path is only reached when httpSession is null — the normal path is unchanged.

Affected Versions

  • iDempiere 13 (release-13 and master) — affected
  • iDempiere 12 and earlier — not affected (the WebSocket AU proxy code doesn't exist)

How to Apply

The fix modifies three files in org.adempiere.ui.zk/WEB-INF/src/org/idempiere/ui/zk/websocket/:

  1. EndpointConfigurator.java
  2. ServerPushEndPoint.java
  3. WebSocketServerPush.java

Additionally, set the system property -Dorg.adempiere.server.port=8080 (or your Jetty port) in the JVM startup options when running behind a reverse proxy.

I plan to submit this as a pull request to the official iDempiere repository. If you're experiencing this issue, the fix is available in our fork at release-13-arm64.

Comentarios

Entradas más populares de este blog

Configurar Ollama con GPU AMD Radeon RX 6600 en openSUSE Leap 15.6

SuSE 10.1 Remasterizado.