Spring Boot - Interception of HTTP request headers at request upgrade/handshake · Issue #831 · rsocket/rsocket-java · GitHub
Skip to content

Spring Boot - Interception of HTTP request headers at request upgrade/handshake #831

Closed

Description

@maxim-bandurko-lsvt

I was trying to implement the native browser Cookie/Session authentication to RSocket plugged to server at Spring Boot, and can't find easier way of how intercept the request headers passed before handshake process not using much reflection overriding and excluding of private classes. May be there is some easier way exists already that allows to configure a bean that will intercept request headers from client and can push connection to be closed with error if that is needed?

Also, may be it is even too much in this one thread, but would be good to have an option to add to response headers custom values exactly on the moment of HTTP request upgrade to WebSockets?

This will allow to implement native browser Cookie/Session authentication to RSocket and stop dealing with tokens. Like, browser makes a request to server to have it upgraded to websockets, server checks for cookie inside request headers, and if server allows, server will make a response to make request upgrade. But in situation, when cookies are missing some properties, server will pass additional cookie property with value to the upgrade response, or for example, pass property to upgrade response and not allow upgrade (so browser will got a cookie property with closed request), etc.

So, the easier way that allows just to pass a function, that will allow to check request headers, and ideally, add something more to upgrade response headers, would help a lot.

I had similar process implemented in basic Nodejs websocket server, so trying to implement same inside Java server with minimal overrides to native Java classes.

Thank you.

Activity

  1. rstoyanchev commented on Jun 4, 2020

    @rstoyanchev
    Contributor

    @maxim-bandurko-lsvt there have been a few changes for 1.0.1 that make this easier. Take a look at the current version of WebSocketHeadersSample. That should show you how to do this.

  2. maxim-bandurko-lsvt commented on Jun 4, 2020

    @maxim-bandurko-lsvt
    Author

    @rstoyanchev Got it, thanks!

  3. mmaask commented on May 28, 2021

    @mmaask

    @maxim-bandurko-lsvt did you manage to implement such interceptor for Spring Boot? Started digging through the RSocket server code and Spring Security side, but haven't found an appropriate place for extending the functionality.

  4. maxim-bandurko-lsvt commented on Jun 7, 2021

    @maxim-bandurko-lsvt
    Author

    @mmaask It depends on what exact level is needed to implement headers inside actual spring boot. I have very draft sample only for ws level that I implemented using my custom override built, because this class is still private:
    https://github.com/spring-projects/spring-boot/blob/main/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/rsocket/RSocketWebSocketNettyRouteProvider.java

    Not a big deal, as I just make my own class that works how I need and overriding beans from:
    https://github.com/spring-projects/spring-boot/blob/main/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/rsocket/RSocketServerAutoConfiguration.java

    My goal was to not only to authenticate WS connection by headers/cookies, I was planing to populate the user id or whole user session object directly to @MessageMapping by passing the id into modified ConnectionSetupPayload that stores data inside socket for example. But still haven't implemented that yet.

    Any way, at those class RSocketWebSocketNettyRouteProvider you can do all needed things for WebsocketInbound and WebsocketOutbound

  5. maxim-bandurko-lsvt commented on Sep 8, 2022

    @maxim-bandurko-lsvt
    Author

    @mmaask Well, I finally came to the moment that need to implement this Cookie/Session auth, and first way I did how I explained year ago in my comment above, but it turned to be not so nice in terms of implementation.

    Biggest "stone" was spring-projects/spring-boot#18549 with not public RSocketWebSocketNettyRouteProvider. So had to follow the same way out that @FWinkler79 originally described by placing custom bean configuration in the same package as RSocketWebSocketNettyRouteProvider. So finally had something like:

    	@Override
    	public HttpServerRoutes apply(HttpServerRoutes httpServerRoutes) {
    		RSocketServer server = RSocketServer.create(this.socketAcceptor);
    		this.customizers.forEach((customizer) -> customizer.customize(server));
    		ServerTransport.ConnectionAcceptor connectionAcceptor = server.asConnectionAcceptor();
    
    		return HttpServerRoutes.route(HttpPredicate.get(this.mappingPath), (req, resp) -> {
    				if (req.requestHeaders() .containsValue(HttpHeaderNames.CONNECTION, HttpHeaderValues.UPGRADE, true)) {
    		
    					if (req.requestHeaders().containsValue("Authorization", "test", true)){
    
    						HttpServerOperations ops = (HttpServerOperations) req;
    						return ops.withWebsocketSupport(
    								req.uri(),
    								WebsocketServerSpec.builder().build(),
    								(in, out) -> connectionAcceptor.apply(new WebsocketDuplexConnection((Connection) in)).then(out.neverComplete())
    						);
    
    					}
    
    					resp.status(HttpResponseStatus.UNAUTHORIZED);
    					return res.send();
    		
    				}
    				return resp.sendNotFound();
    		}); 
    	}
    

    But after that I had to store some auth status to be accessed by WebsocketDuplexConnection, and again one more stone was that it is final class, and can't extend it. So had to use a wrapper for it that wasn't very convenient also.

    So, decided to switch to much easier implementation for right now by still keeping auth process per stream, but getting token from Cookie inside header (using Reflection as to get the needed private fields values):

        @MessageMapping("some")
        public Flux<DataBuffer> some(DataBuffer request, RSocketRequester requester) {
    
            RSocket rsocket = requester.rsocket();
    
            Field idcField = ReflectionUtils.findField(rsocket.getClass(), "connection");
            idcField.setAccessible(true);
            DuplexConnection idc = (DuplexConnection) ReflectionUtils.getField(idcField, rsocket);
    
            Field shdcField = ReflectionUtils.findField(idc.getClass(), "source");
            shdcField.setAccessible(true);
            DuplexConnection shdc = (DuplexConnection) ReflectionUtils.getField(shdcField, idc);
    
            Field wdcField = ReflectionUtils.findField(shdc.getClass(), "source");
            wdcField.setAccessible(true);
            WebsocketDuplexConnection wdc = (WebsocketDuplexConnection) ReflectionUtils.getField(wdcField, shdc);
    
            Field wiField = ReflectionUtils.findField(wdc.getClass(), "connection");
            wiField.setAccessible(true);
            WebsocketInbound wi = (WebsocketInbound) ReflectionUtils.getField(wiField, wdc);
    
            log.info("ConnectMapping - header Cookie: " + wi.headers().get(HttpHeaders.COOKIE));
    
            return Flux.empty();
    
        }
    

    Btw, it looks like having actual auth process would be easier to implement inside SocketAcceptorInterceptor and just store the auth status in some custom connection wrapper inside requester, so this way auth will be per connection and after that status that can be any Object can be re-used inside MessageMapping requesters (a little example discussed: #970 )

  6. maxim-bandurko-lsvt commented on Sep 8, 2022

    @maxim-bandurko-lsvt
    Author

    @mmaask Actually, decided to finish with implementation and do auth per connection.

    So:

    1. RSocketServerCustomizerCustom:
    @Slf4j
    @Configuration
    public class RSocketServerCustomizerCustom implements RSocketServerCustomizer {
    
        @Override
        public void customize(RSocketServer rSocketServer) {
    
            rSocketServer.interceptors(
                (InterceptorRegistry configurer) -> {
                    log.info("Adding RSocket interceptors...");
                    configurer.forSocketAcceptor(new SocketAcceptorInterceptorCustom());
                }
            );
    
            
        }
    
    
    }
    
    1. SocketAcceptorInterceptorCustom:
    @Slf4j
    public class SocketAcceptorInterceptorCustom implements SocketAcceptorInterceptor {
        
    
        @Override
        public SocketAcceptor apply(SocketAcceptor socketAcceptor) {
    
    
            return (ConnectionSetupPayload setup, RSocket sendingSocket) -> {
                try {
                    
                    Field idcField = ReflectionUtils.findField(sendingSocket.getClass(), "connection");
                    idcField.setAccessible(true);
                    DuplexConnection idc = (DuplexConnection) ReflectionUtils.getField(idcField, sendingSocket);
    
                    Field shdcField = ReflectionUtils.findField(idc.getClass(), "source");
                    shdcField.setAccessible(true);
                    DuplexConnection shdc = (DuplexConnection) ReflectionUtils.getField(shdcField, idc);
    
                    Field wdcField = ReflectionUtils.findField(shdc.getClass(), "source");
                    wdcField.setAccessible(true);
                    WebsocketDuplexConnection wdc = (WebsocketDuplexConnection) ReflectionUtils.getField(wdcField, shdc);
    
                    WebsocketDuplexConnectionCustom wdcCustom = new WebsocketDuplexConnectionCustom(wdc);
    
                    synchronized(shdc){
                        ReflectionUtils.setField(wdcField, shdc, wdcCustom);
                    }
    
                    log.info("SocketAcceptorInterceptorCustom - header Cookie: " + wdcCustom.headers().get(HttpHeaders.COOKIE));
    
                    // Do some auth logic, can use setup payload also
                    //ConnAuthDTO authInfo = new ObjectMapper().readValue(setup.getDataUtf8(), ConnAuthDTO.class);
    
                    wdcCustom.setSession(true);
                    
    
                    return socketAcceptor.accept(setup, sendingSocket);
                } catch (Exception e) {
                    String errMsg = "try to accept a connection error.msg";
                    log.warn(errMsg, e);
                    return Mono.error(e);
                }
            };
        }
    
    }
    

    It replaces existing WebsocketDuplexConnection wrapped with WebsocketDuplexConnectionCustom. And there you can do any Auth logic using headers or setup payload, and cache the session state value (can be any object) for reuse inside MessageMappings.

    1. WebsocketDuplexConnectionCustom:
    public class WebsocketDuplexConnectionCustom implements DuplexConnection {
    
        private final WebsocketDuplexConnection websocketDuplexConnection;
    
        private final Field websocketDuplexConnectionField;
        private final Method doOnCloseMethod;
    
    
        private AtomicReference<Object> session = new AtomicReference<Object>();
    
    
        WebsocketDuplexConnectionCustom(WebsocketDuplexConnection websocketDuplexConnection){
            this.websocketDuplexConnection = websocketDuplexConnection;
    
            this.websocketDuplexConnectionField = ReflectionUtils.findField(websocketDuplexConnection.getClass(), "connection");
            websocketDuplexConnectionField.setAccessible(true);
    
            this.doOnCloseMethod = ReflectionUtils.findMethod(websocketDuplexConnection.getClass(), "doOnClose");
            doOnCloseMethod.setAccessible(true);
    
            session.set(null);
        }
    
    
    
        @Override
        public ByteBufAllocator alloc() {
            return websocketDuplexConnection.alloc();
        }
    
        @Override
        public SocketAddress remoteAddress() {
            return websocketDuplexConnection.remoteAddress();
        }
    
        //@Override
        protected void doOnClose() {
            ReflectionUtils.invokeMethod(doOnCloseMethod, websocketDuplexConnection);
        }
    
        @Override
        public Flux<ByteBuf> receive() {
            return websocketDuplexConnection.receive();
        }
    
        @Override
        public void sendErrorAndClose(RSocketErrorException e) {
            websocketDuplexConnection.sendErrorAndClose(e);
        }
    
    
        @Override
        public Mono<Void> onClose() {
            return websocketDuplexConnection.onClose();
        }
    
    
    
        @Override
        public void dispose() {
            websocketDuplexConnection.dispose();
        }
    
    
    
        @Override
        public void sendFrame(int streamId, ByteBuf frame) {
            websocketDuplexConnection.sendFrame(streamId, frame);
        }
    
    
        public Connection getConnection(){
            return (Connection) ReflectionUtils.getField(websocketDuplexConnectionField, websocketDuplexConnection);
        }
    
    
        public HttpHeaders headers(){
            WebsocketInbound wi = (WebsocketInbound) getConnection();
            return wi.headers();
        }
        
    
    
        public void setSession(Object session){
            this.session.set(session);
        }
    
        public Object getSession(){
            return session.get();
        }
    
    }
    
    1. Any message mapping:
        @MessageMapping("some")
        public Flux<DataBuffer> some(DataBuffer request, RSocketRequester requester) {
    
            RSocket rsocket = requester.rsocket();
    
            Field idcField = ReflectionUtils.findField(rsocket.getClass(), "connection");
            idcField.setAccessible(true);
            DuplexConnection idc = (DuplexConnection) ReflectionUtils.getField(idcField, rsocket);
    
            Field shdcField = ReflectionUtils.findField(idc.getClass(), "source");
            shdcField.setAccessible(true);
            DuplexConnection shdc = (DuplexConnection) ReflectionUtils.getField(shdcField, idc);
    
            Field wdcField = ReflectionUtils.findField(shdc.getClass(), "source");
            wdcField.setAccessible(true);
            WebsocketDuplexConnectionCustom wdc = (WebsocketDuplexConnectionCustom) ReflectionUtils.getField(wdcField, shdc);
    
            log.info("MessageMapping - header Cookie: " + wdc.headers().get(HttpHeaders.COOKIE));
            log.info("MessageMapping - session: " + wdc.getSession());
    
            return Flux.empty();
    
        }
    
  7. spheenik commented on Aug 7, 2026

    @spheenik

    Coming back to this because it is still the first thing you find when you want browser-cookie
    authentication on RSocket over WebSocket in Spring Boot, and the answer it leaves you with is
    comment 1241021797 —
    a walk through four private fields (connection → source → source → connection), a wrapper
    substituted for the final WebsocketDuplexConnection by writing it back through a private field,
    and a bean planted in Spring's own package to replace the package-private
    RSocketWebSocketNettyRouteProvider.

    Most of that is avoidable now. We shipped this, and measured each step against rsocket-core 1.1.5,
    rsocket-transport-netty 1.1.5 and Spring Boot 4.1.0
    .

    1. Reading the upgrade headers, and refusing the upgrade — published API, no reflection

    HttpServer.childObserve sees the upgrade request with its headers and the connection carrying
    it. Registered through Spring Boot's NettyServerCustomizer, so no package-private class is
    involved and RSocketWebSocketNettyRouteProvider never has to be replaced:

    @Component
    class RSocketUpgradeObserver implements WebServerFactoryCustomizer<NettyReactiveWebServerFactory> {
    
        @Override
        public void customize(NettyReactiveWebServerFactory factory) {
            factory.addServerCustomizers(httpServer -> httpServer.childObserve((connection, state) -> {
                if (state != ConnectionObserver.State.CONFIGURED) return;
                if (!(connection instanceof HttpServerRequest request)) return;
                if (!rsocketMappingPath.equals(request.fullPath())) return;   // exact match, see note
    
                HttpHeaders headers = request.requestHeaders();               // Cookie, Origin, X-Forwarded-For
                if (!allowed(headers.get(HttpHeaderNames.ORIGIN))) {
                    connection.dispose();                                     // refuse the upgrade
                    return;
                }
                deposit(connection.channel().remoteAddress(), contextFrom(headers));
            }));
        }
    }

    Two notes:

    • HttpServer.doOnRequest does not exist on this reactor-netty version; childObserve with the
      CONFIGURED guard and the instanceof HttpServerRequest narrowing is the hook that yields
      request headers together with the connection.
    • Match the mapping path exactly. path.contains("/rs") also matches unrelated routes — ours
      matched an HTTP endpoint whose path merely contained the string.

    That alone answers the original question: check the cookie at the handshake, close the connection
    if it fails. No reflection.

    2. Getting that context onto the connection

    The remaining problem is the handoff. childObserve knows the headers; the SocketAcceptor is
    where you need them, and it is handed (ConnectionSetupPayload setup, RSocket sendingSocket) and
    no address.

    The key that bridges them is the TCP peer. The upgrade's SocketAddress and the address of the
    accepted connection are the same value
    — a WebSocket upgrade is HTTP/1.1 and holds its connection
    exclusively for its lifetime, so within that window the address names one connection and no other.
    A deposit keyed on it, claimed once and evicted if unclaimed, is enough; no wrapper has to be
    threaded through the framework.

    We claim in a SocketAcceptorInterceptor rather than a DuplexConnectionInterceptor, because the
    acceptor is the hook that wraps everything that follows — so it can also write the result into the
    Reactor context, which is the only channel that reaches route authorization and @MessageMapping
    handlers. A DuplexConnectionInterceptor at Type.SOURCE is handed the raw
    WebsocketDuplexConnection and can name the peer via the published
    DuplexConnection.remoteAddress(), but it has nowhere to put the answer.

    3. The one hop that is still reflective, stated plainly

    To claim by peer, the acceptor needs the peer, and it has to reach for it:

    sendingSocket                                     io.rsocket.core.RSocketRequester
       └── DuplexConnection connection                declared on RequesterResponderSupport
              └── .remoteAddress()                    published method, published interface
    

    One setAccessible, one field read. That is smaller than the walk in comment 1241021797 — one hop
    instead of four, a read instead of substituting a wrapper for a final class, and nothing mutated —
    but it is not zero, and it would be dishonest to present this as a fully public-API path.

    Two things make it tolerable rather than merely smaller:

    • Locate the field by declared type, not by name. A name is an implementation detail that can
      change silently; a field whose declared type is a published interface is much narrower to depend
      on. A rename does not break it — only removing the field does, and that fails loudly.
    • Fence it with a test. Ours asserts the field resolves, so a framework upgrade that moves it
      fails in CI. Without that the failure mode is severe: no peer means no claim, and a fail-closed
      policy then refuses every connection in production.

    4. What would close the last inch

    Either would remove the reflection entirely:

    • expose the peer (or the DuplexConnection) to SocketAcceptor — it already has the RSocket
      that holds it; or
    • expose the underlying channel on DuplexConnection, so a netty AttributeKey set at the upgrade
      can be read at acceptance. That would remove the handoff registry as well, since the attribute
      rides the channel itself.

    Happy to open a separate issue for whichever of those looks more plausible.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions