Skip to content

SockJS HTTP transports broken behind a reverse proxy in 7.0.8 — session remote-address binding (#36681) compares IP AND port #36976

Description

@kborghs

We faced with an issue after updating to spring-boot 4.0.7. We have a workaround, but it might be useful to have it resolved for everyone.

Component: spring-websocket / SockJS — TransportHandlingSockJsService
Affected version: 7.0.8 (regression vs 7.0.7)
Related: #36681 ("Improve principal checks for SockJS session"), shipped in 7.0.8 alongside the predictable-session-id fix for CVE-2026-41838 (#36740)

Summary

#36681 added remote-address binding for anonymous SockJS sessions: when the session has no principal, a follow-up transport request must now come from the same remote address as the request that created the session. This is good hardening, but the comparison uses the full InetSocketAddress (host and port), which is never stable for SockJS's HTTP fallback transports — they use multiple short-lived connections — and is guaranteed to differ behind a reverse proxy. The result is that anonymous SockJS-over-HTTP stops working entirely: the xhr_streaming receive opens, but the xhr_send POST is rejected with 404 and the STOMP CONNECT is never delivered.

Server log (DEBUG):

DefaultSockJsService : Processing transport request: POST /app/ws/253/qyg40c00/xhr_send
DefaultSockJsService : The remote address for the session and the request do not match.

Root cause

The check added in 7.0.8 (TransportHandlingSockJsService#handleTransportRequest, in the else branch when session.getPrincipal() == null):

InetSocketAddress remoteAddress = session.getRemoteAddress();
if (remoteAddress != null && !remoteAddress.equals(request.getRemoteAddress())) {
    logger.debug("The remote address for the session and the request do not match.");
    response.setStatusCode(HttpStatus.NOT_FOUND);
    return;
}

InetSocketAddress.equals compares host and port. But:

  • SockJS HTTP transports (xhr, xhr_streaming, eventsource, htmlfile) deliberately span multiple TCP connections per session — a long-lived receive plus separate xhr_send POSTs — each with its own ephemeral remote port. So even with a direct connection the ports differ across a session's requests.
  • Behind a reverse proxy / load balancer it's always different: the proxy opens a fresh upstream connection per request, and X-Forwarded-For (or Forwarded) conveys only the client IP, never a stable port. Neither Tomcat's RemoteIpValve (forward-headers-strategy=native) nor ForwardedHeaderFilter (framework) normalizes the remote portForwardedHeaderUtils.parseForwardedFor falls back to the original connection's port when the header has none.

So session.getRemoteAddress() and the xhr_send request's getRemoteAddress() never match, and every send is 404'd.

This specifically affects sessions that are anonymous at the HTTP layer — e.g. apps where authorization is carried in the STOMP CONNECT frame rather than on the SockJS HTTP requests (a common Spring STOMP-over-WebSocket setup). Sessions with an HTTP Principal take the username-comparison branch and are unaffected.

Steps to reproduce

  1. Spring Boot 4.0.7 (spring-websocket 7.0.8); a @EnableWebSocketMessageBroker endpoint with .withSockJS(); the SockJS HTTP path permitted anonymously.
  2. Connect with a client that uses a SockJS HTTP transport (e.g. WebSocketStompClient + SockJsClient configured with only RestTemplateXhrTransport), ideally through a reverse proxy that sets X-Forwarded-For.
  3. Observe: the o open frame is received, then xhr_send returns 404 ("The remote address for the session and the request do not match"); the connection never completes.

Downgrading only spring-websocket to 7.0.7 restores correct behavior.

Expected

SockJS HTTP transports continue to work behind a proxy and across the multiple connections they inherently use, as in <= 7.0.7 — without weakening the session-binding intent of #36681.

Actual

xhr_send -> 404; STOMP CONNECT never delivered; the websocket appears dead.

Suggested fix

The session-binding goal is reasonable, but the remote port can't be part of it for HTTP transports. Options:

  • Compare the remote host only (drop the port) — this still binds a session to its client IP, which is what Improve principal checks for SockJS session #36681 was after.
  • And/or make the binding honor forwarded headers / be configurable (e.g. opt-out, or a "trusted proxy" mode) so deployments behind a proxy aren't broken.

Workaround (how we bypassed it)

Until this is addressed, we register a small servlet filter scoped to the SockJS endpoint that pins the request's remote port to a constant while leaving the host untouched — reducing the 7.0.8 check to a stable host-based comparison and preserving the per-client-IP binding:

class SockJsRemoteAddressFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
            throws ServletException, IOException {
        chain.doFilter(new HttpServletRequestWrapper(req) {
            @Override public int getRemotePort() { return 0; }
        }, res);
    }
}

(Registered via a FilterRegistrationBean on the SockJS endpoint path. This keeps IP-level binding while removing the port comparison that HTTP transports can't satisfy.)

Metadata

Metadata

Assignees

No one assigned

    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