1
0
Files
2026-09-04 00:52:18 +05:30

130 lines
5.8 KiB
Markdown

# 4. STOMP: four lines of configuration, three surprising defaults
Previous: [3. The payload](03-the-payload.md) · Next: [5. The limits](05-limits.md)
---
[`WebSocketConfig`](../src/main/java/com/ankurm/ssews/WebSocketConfig.java) is short. Each line
decides more than it looks like it does.
```java
registry.enableSimpleBroker("/topic", "/queue");
registry.setApplicationDestinationPrefixes("/app");
registry.setUserDestinationPrefix("/user");
```
## Surprise 1: a client can publish straight to a topic
`setApplicationDestinationPrefixes("/app")` says which destinations reach a `@MessageMapping`.
It does **not** say which destinations a client may send to. A `SEND` to `/topic/room` goes
directly to the broker, and every subscriber receives it.
[`StompRoutingTest`](../src/test/java/com/ankurm/ssews/StompRoutingTest.java) sends a message with
a timestamp the controller would have overwritten, and it arrives untouched:
```
=== SEND straight to /topic/room ===
received: ChatMessage[from=not-checked-by-anyone, text=unvalidated, at=2000-01-01T00:00:00Z]
=== the controller's Instant.now() rewrite did NOT happen ===
```
No handler ran. No validation ran. Nothing logged that anything was skipped. If your controller is
where authorisation, sanitisation or rate limiting happens, a client that knows the destination
name walks past all of it.
The fix is not a routing setting — it is Spring Security's message-level authorisation
(`simpDestMatchers("/topic/**").denyAll()` for client `SEND` frames), or a broker relay whose
own ACLs forbid publishing. Treat a bare simple broker as an open pipe.
## Surprise 2: a handler with no `@SendTo` is not private
The default destination for a `@MessageMapping` return value is the broker prefix plus the
mapping. `@MessageMapping("/chat.echo")` with no annotation publishes to **`/topic/chat.echo`**:
```
=== @MessageMapping("/chat.echo") with NO @SendTo ===
subscribed to /topic/chat.echo, received: ChatMessage[from=echo, text=who can see this?, ...]
```
Anyone who can guess the mapping can subscribe to the replies. If a handler's answer is for the
caller only, it needs `@SendToUser`, not silence.
## Surprise 3: `convertAndSendToUser` needs more than a name
`/user/**` is not a real destination. On `SUBSCRIBE`, `DefaultUserDestinationResolver` rewrites
`/user/queue/whisper` into a session-scoped destination, which is why two clients subscribed to
the same string get different messages:
```
Translated /user/queue/whisper -> [/queue/whisper-userea271ead-...]
Translated /user/queue/whisper -> [/queue/whisper-user84865e50-...]
```
With no authenticated `Principal`, the "user" is the STOMP session id — and the three-argument
`convertAndSendToUser(sessionId, dest, payload)` **silently delivers nothing**, because the
resolver looks that name up in a user registry that has never heard of it. No exception, no log
line. The message has to carry the session id too:
```java
SimpMessageHeaderAccessor out = SimpMessageHeaderAccessor.create(SimpMessageType.MESSAGE);
out.setSessionId(targetSessionId);
out.setLeaveMutable(true); // without this the accessor is frozen before the resolver looks
template.convertAndSendToUser(targetSessionId, "/queue/whisper", payload, out.getMessageHeaders());
```
And there is a second trap stacked on the first, which cost a debugging round here:
> **`StompSession.getSessionId()` on the client is not the server's session id.**
> ```
> alice.getSessionId() [client side] : 08b58570-0fcb-ffaf-d82c-119232e66561
> server-side session id : f882cf6a-f0d9-48e3-baf3-c573045a47d1
> ```
> Whispering to the client-side value delivers nothing, silently, exactly like the missing header
> does. The server-side id is what appears in `SimpMessageHeaderAccessor.getSessionId()`, and it
> has to come from the server.
With Spring Security in the picture the `Principal` is a real user name and none of this applies
— which is a good argument for putting security in front of a STOMP endpoint even when you
do not think you need authentication yet.
## What STOMP gives you that SSE cannot
Presence. [`PresenceListener`](../src/main/java/com/ankurm/ssews/PresenceListener.java) is fifteen
lines and produces:
```
| CONNECTED d775c936-8492-4b32-871e-c659346a2fb9
| SUBSCRIBE d775c936-8492-4b32-871e-c659346a2fb9 -> /topic/room
| DISCONNECT def64a1f-dc10-45bc-92d3-aa2e842ae02f status=CloseStatus[code=1000, reason=null]
```
The server knows who is connected, what they subscribed to, and when they left, with a close
status attached. SSE has no equivalent of any of those three.
## The simple broker is not a broker
`enableSimpleBroker` is in-memory, single JVM, no persistence, no acknowledgement, and it drops
everything on restart. It is also fine for a great many applications. When it is not,
`enableStompBrokerRelay("/topic", "/queue")` swaps in RabbitMQ or ActiveMQ **without touching a
single controller** — the annotations and destinations stay exactly as they are. The broker
side of that is [`../rabbitmq`](../rabbitmq/README.md).
Note the asymmetry once you relay: `/topic` and `/queue` become real broker destinations with
real semantics, and the "client can publish straight to a topic" problem becomes the broker's ACL
problem rather than yours.
## SockJS is a second endpoint, not an option on the first
```java
registry.addEndpoint("/ws");
registry.addEndpoint("/ws-sockjs").withSockJS();
```
`.withSockJS()` on the same path does not give you both. SockJS rewrites the URL into
`/{server}/{session}/{transport}`, and a plain WebSocket client pointed at that path fails the
handshake. In 2026, SockJS is mostly legacy — WebSocket support is universal — but it
is still the answer for a corporate proxy that strips `Upgrade`.
---
Previous: [3. The payload](03-the-payload.md) · Next: [5. The limits](05-limits.md)