516 lines
20 KiB
Plaintext
516 lines
20 KiB
Plaintext
[[websocket]]
|
|
= WebSocket Security
|
|
|
|
Spring Security 4 added support for securing https://docs.spring.io/spring/docs/current/spring-framework-reference/html/websocket.html[Spring's WebSocket support].
|
|
This section describes how to use Spring Security's WebSocket support.
|
|
|
|
.Direct JSR-356 Support
|
|
****
|
|
Spring Security does not provide direct JSR-356 support because doing so would provide little value.
|
|
This is because the format is unknown, so there is https://docs.spring.io/spring/docs/current/spring-framework-reference/html/websocket.html#websocket-intro-sub-protocol[little Spring can do to secure an unknown format].
|
|
Additionally, JSR-356 does not provide a way to intercept messages, so security would be rather invasive.
|
|
****
|
|
|
|
[[websocket-configuration]]
|
|
== WebSocket Configuration
|
|
|
|
Spring Security 4.0 has introduced authorization support for WebSockets through the Spring Messaging abstraction.
|
|
To configure authorization using Java Configuration, simply extend the `AbstractSecurityWebSocketMessageBrokerConfigurer` and configure the `MessageSecurityMetadataSourceRegistry`.
|
|
For example:
|
|
|
|
====
|
|
.Java
|
|
[source,java,role="primary"]
|
|
----
|
|
@Configuration
|
|
public class WebSocketSecurityConfig
|
|
extends AbstractSecurityWebSocketMessageBrokerConfigurer { // <1> <2>
|
|
|
|
protected void configureInbound(MessageSecurityMetadataSourceRegistry messages) {
|
|
messages
|
|
.simpDestMatchers("/user/**").authenticated() // <3>
|
|
}
|
|
}
|
|
----
|
|
|
|
.Kotlin
|
|
[source,kotlin,role="secondary"]
|
|
----
|
|
@Configuration
|
|
open class WebSocketSecurityConfig : AbstractSecurityWebSocketMessageBrokerConfigurer() { // <1> <2>
|
|
override fun configureInbound(messages: MessageSecurityMetadataSourceRegistry) {
|
|
messages.simpDestMatchers("/user/**").authenticated() // <3>
|
|
}
|
|
}
|
|
----
|
|
====
|
|
|
|
This will ensure that:
|
|
|
|
<1> Any inbound CONNECT message requires a valid CSRF token to enforce <<websocket-sameorigin,Same Origin Policy>>
|
|
<2> The SecurityContextHolder is populated with the user within the simpUser header attribute for any inbound request.
|
|
<3> Our messages require the proper authorization. Specifically, any inbound message that starts with "/user/" will require ROLE_USER. Additional details on authorization can be found in <<websocket-authorization>>
|
|
|
|
Spring Security also provides xref:servlet/appendix/namespace/websocket.adoc#nsa-websocket-security[XML Namespace] support for securing WebSockets.
|
|
A comparable XML based configuration looks like the following:
|
|
|
|
[source,xml]
|
|
----
|
|
<websocket-message-broker> <!--1--> <!--2-->
|
|
<!--3-->
|
|
<intercept-message pattern="/user/**" access="hasRole('USER')" />
|
|
</websocket-message-broker>
|
|
----
|
|
|
|
This will ensure that:
|
|
|
|
<1> Any inbound CONNECT message requires a valid CSRF token to enforce <<websocket-sameorigin,Same Origin Policy>>
|
|
<2> The SecurityContextHolder is populated with the user within the simpUser header attribute for any inbound request.
|
|
<3> Our messages require the proper authorization. Specifically, any inbound message that starts with "/user/" will require ROLE_USER. Additional details on authorization can be found in <<websocket-authorization>>
|
|
|
|
[[websocket-authentication]]
|
|
== WebSocket Authentication
|
|
|
|
WebSockets reuse the same authentication information that is found in the HTTP request when the WebSocket connection was made.
|
|
This means that the `Principal` on the `HttpServletRequest` will be handed off to WebSockets.
|
|
If you are using Spring Security, the `Principal` on the `HttpServletRequest` is overridden automatically.
|
|
|
|
More concretely, to ensure a user has authenticated to your WebSocket application, all that is necessary is to ensure that you setup Spring Security to authenticate your HTTP based web application.
|
|
|
|
[[websocket-authorization]]
|
|
== WebSocket Authorization
|
|
|
|
Spring Security 4.0 has introduced authorization support for WebSockets through the Spring Messaging abstraction.
|
|
To configure authorization using Java Configuration, simply extend the `AbstractSecurityWebSocketMessageBrokerConfigurer` and configure the `MessageSecurityMetadataSourceRegistry`.
|
|
For example:
|
|
|
|
====
|
|
.Java
|
|
[source,java,role="primary"]
|
|
----
|
|
@Configuration
|
|
public class WebSocketSecurityConfig extends AbstractSecurityWebSocketMessageBrokerConfigurer {
|
|
|
|
@Override
|
|
protected void configureInbound(MessageSecurityMetadataSourceRegistry messages) {
|
|
messages
|
|
.nullDestMatcher().authenticated() // <1>
|
|
.simpSubscribeDestMatchers("/user/queue/errors").permitAll() // <2>
|
|
.simpDestMatchers("/app/**").hasRole("USER") // <3>
|
|
.simpSubscribeDestMatchers("/user/**", "/topic/friends/*").hasRole("USER") // <4>
|
|
.simpTypeMatchers(MESSAGE, SUBSCRIBE).denyAll() // <5>
|
|
.anyMessage().denyAll(); // <6>
|
|
|
|
}
|
|
}
|
|
----
|
|
|
|
.Kotlin
|
|
[source,kotlin,role="secondary"]
|
|
----
|
|
@Configuration
|
|
open class WebSocketSecurityConfig : AbstractSecurityWebSocketMessageBrokerConfigurer() {
|
|
override fun configureInbound(messages: MessageSecurityMetadataSourceRegistry) {
|
|
messages
|
|
.nullDestMatcher().authenticated() // <1>
|
|
.simpSubscribeDestMatchers("/user/queue/errors").permitAll() // <2>
|
|
.simpDestMatchers("/app/**").hasRole("USER") // <3>
|
|
.simpSubscribeDestMatchers("/user/**", "/topic/friends/*").hasRole("USER") // <4>
|
|
.simpTypeMatchers(MESSAGE, SUBSCRIBE).denyAll() // <5>
|
|
.anyMessage().denyAll() // <6>
|
|
}
|
|
}
|
|
----
|
|
====
|
|
|
|
This will ensure that:
|
|
|
|
<1> Any message without a destination (i.e. anything other than Message type of MESSAGE or SUBSCRIBE) will require the user to be authenticated
|
|
<2> Anyone can subscribe to /user/queue/errors
|
|
<3> Any message that has a destination starting with "/app/" will be require the user to have the role ROLE_USER
|
|
<4> Any message that starts with "/user/" or "/topic/friends/" that is of type SUBSCRIBE will require ROLE_USER
|
|
<5> Any other message of type MESSAGE or SUBSCRIBE is rejected. Due to 6 we do not need this step, but it illustrates how one can match on specific message types.
|
|
<6> Any other Message is rejected. This is a good idea to ensure that you do not miss any messages.
|
|
|
|
Spring Security also provides xref:servlet/appendix/namespace/websocket.adoc#nsa-websocket-security[XML Namespace] support for securing WebSockets.
|
|
A comparable XML based configuration looks like the following:
|
|
|
|
[source,xml]
|
|
----
|
|
<websocket-message-broker>
|
|
<!--1-->
|
|
<intercept-message type="CONNECT" access="permitAll" />
|
|
<intercept-message type="UNSUBSCRIBE" access="permitAll" />
|
|
<intercept-message type="DISCONNECT" access="permitAll" />
|
|
|
|
<intercept-message pattern="/user/queue/errors" type="SUBSCRIBE" access="permitAll" /> <!--2-->
|
|
<intercept-message pattern="/app/**" access="hasRole('USER')" /> <!--3-->
|
|
|
|
<!--4-->
|
|
<intercept-message pattern="/user/**" access="hasRole('USER')" />
|
|
<intercept-message pattern="/topic/friends/*" access="hasRole('USER')" />
|
|
|
|
<!--5-->
|
|
<intercept-message type="MESSAGE" access="denyAll" />
|
|
<intercept-message type="SUBSCRIBE" access="denyAll" />
|
|
|
|
<intercept-message pattern="/**" access="denyAll" /> <!--6-->
|
|
</websocket-message-broker>
|
|
----
|
|
|
|
This will ensure that:
|
|
|
|
<1> Any message of type CONNECT, UNSUBSCRIBE, or DISCONNECT will require the user to be authenticated
|
|
<2> Anyone can subscribe to /user/queue/errors
|
|
<3> Any message that has a destination starting with "/app/" will be require the user to have the role ROLE_USER
|
|
<4> Any message that starts with "/user/" or "/topic/friends/" that is of type SUBSCRIBE will require ROLE_USER
|
|
<5> Any other message of type MESSAGE or SUBSCRIBE is rejected. Due to 6 we do not need this step, but it illustrates how one can match on specific message types.
|
|
<6> Any other message with a destination is rejected. This is a good idea to ensure that you do not miss any messages.
|
|
|
|
[[websocket-authorization-notes]]
|
|
=== WebSocket Authorization Notes
|
|
|
|
In order to properly secure your application it is important to understand Spring's WebSocket support.
|
|
|
|
[[websocket-authorization-notes-messagetypes]]
|
|
==== WebSocket Authorization on Message Types
|
|
|
|
It is important to understand the distinction between SUBSCRIBE and MESSAGE types of messages and how it works within Spring.
|
|
|
|
Consider a chat application.
|
|
|
|
* The system can send notifications MESSAGE to all users through a destination of "/topic/system/notifications"
|
|
* Clients can receive notifications by SUBSCRIBE to the "/topic/system/notifications".
|
|
|
|
While we want clients to be able to SUBSCRIBE to "/topic/system/notifications", we do not want to enable them to send a MESSAGE to that destination.
|
|
If we allowed sending a MESSAGE to "/topic/system/notifications", then clients could send a message directly to that endpoint and impersonate the system.
|
|
|
|
In general, it is common for applications to deny any MESSAGE sent to a destination that starts with the https://docs.spring.io/spring/docs/current/spring-framework-reference/html/websocket.html#websocket-stomp[broker prefix] (i.e. "/topic/" or "/queue/").
|
|
|
|
[[websocket-authorization-notes-destinations]]
|
|
==== WebSocket Authorization on Destinations
|
|
|
|
It is also is important to understand how destinations are transformed.
|
|
|
|
Consider a chat application.
|
|
|
|
* Users can send messages to a specific user by sending a message to the destination of "/app/chat".
|
|
* The application sees the message, ensures that the "from" attribute is specified as the current user (we cannot trust the client).
|
|
* The application then sends the message to the recipient using `SimpMessageSendingOperations.convertAndSendToUser("toUser", "/queue/messages", message)`.
|
|
* The message gets turned into the destination of "/queue/user/messages-<sessionid>"
|
|
|
|
With the application above, we want to allow our client to listen to "/user/queue" which is transformed into "/queue/user/messages-<sessionid>".
|
|
However, we do not want the client to be able to listen to "/queue/*" because that would allow the client to see messages for every user.
|
|
|
|
In general, it is common for applications to deny any SUBSCRIBE sent to a message that starts with the https://docs.spring.io/spring/docs/current/spring-framework-reference/html/websocket.html#websocket-stomp[broker prefix] (i.e. "/topic/" or "/queue/").
|
|
Of course we may provide exceptions to account for things like
|
|
|
|
[[websocket-authorization-notes-outbound]]
|
|
=== Outbound Messages
|
|
|
|
Spring contains a section titled https://docs.spring.io/spring/docs/current/spring-framework-reference/html/websocket.html#websocket-stomp-message-flow[Flow of Messages] that describes how messages flow through the system.
|
|
It is important to note that Spring Security only secures the `clientInboundChannel`.
|
|
Spring Security does not attempt to secure the `clientOutboundChannel`.
|
|
|
|
The most important reason for this is performance.
|
|
For every message that goes in, there are typically many more that go out.
|
|
Instead of securing the outbound messages, we encourage securing the subscription to the endpoints.
|
|
|
|
[[websocket-sameorigin]]
|
|
== Enforcing Same Origin Policy
|
|
|
|
It is important to emphasize that the browser does not enforce the https://en.wikipedia.org/wiki/Same-origin_policy[Same Origin Policy] for WebSocket connections.
|
|
This is an extremely important consideration.
|
|
|
|
[[websocket-sameorigin-why]]
|
|
=== Why Same Origin?
|
|
|
|
Consider the following scenario.
|
|
A user visits bank.com and authenticates to their account.
|
|
The same user opens another tab in their browser and visits evil.com.
|
|
The Same Origin Policy ensures that evil.com cannot read or write data to bank.com.
|
|
|
|
With WebSockets the Same Origin Policy does not apply.
|
|
In fact, unless bank.com explicitly forbids it, evil.com can read and write data on behalf of the user.
|
|
This means that anything the user can do over the webSocket (i.e. transfer money), evil.com can do on that users behalf.
|
|
|
|
Since SockJS tries to emulate WebSockets it also bypasses the Same Origin Policy.
|
|
This means developers need to explicitly protect their applications from external domains when using SockJS.
|
|
|
|
[[websocket-sameorigin-spring]]
|
|
=== Spring WebSocket Allowed Origin
|
|
|
|
Fortunately, since Spring 4.1.5 Spring's WebSocket and SockJS support restricts access to the https://docs.spring.io/spring/docs/current/spring-framework-reference/html/websocket.html#websocket-server-allowed-origins[current domain].
|
|
Spring Security adds an additional layer of protection to provide https://en.wikipedia.org/wiki/Defense_in_depth_(computing)[defence in depth].
|
|
|
|
[[websocket-sameorigin-csrf]]
|
|
=== Adding CSRF to Stomp Headers
|
|
|
|
By default Spring Security requires the xref:features/exploits/csrf.adoc#csrf[CSRF token] in any CONNECT message type.
|
|
This ensures that only a site that has access to the CSRF token can connect.
|
|
Since only the *Same Origin* can access the CSRF token, external domains are not allowed to make a connection.
|
|
|
|
Typically we need to include the CSRF token in an HTTP header or an HTTP parameter.
|
|
However, SockJS does not allow for these options.
|
|
Instead, we must include the token in the Stomp headers
|
|
|
|
Applications can xref:servlet/exploits/csrf.adoc#servlet-csrf-include[obtain a CSRF token] by accessing the request attribute named _csrf.
|
|
For example, the following will allow accessing the `CsrfToken` in a JSP:
|
|
|
|
[source,javascript]
|
|
----
|
|
var headerName = "${_csrf.headerName}";
|
|
var token = "${_csrf.token}";
|
|
----
|
|
|
|
If you are using static HTML, you can expose the `CsrfToken` on a REST endpoint.
|
|
For example, the following would expose the `CsrfToken` on the URL /csrf
|
|
|
|
====
|
|
.Java
|
|
[source,java,role="primary"]
|
|
----
|
|
@RestController
|
|
public class CsrfController {
|
|
|
|
@RequestMapping("/csrf")
|
|
public CsrfToken csrf(CsrfToken token) {
|
|
return token;
|
|
}
|
|
}
|
|
----
|
|
|
|
.Kotlin
|
|
[source,kotlin,role="secondary"]
|
|
----
|
|
@RestController
|
|
class CsrfController {
|
|
@RequestMapping("/csrf")
|
|
fun csrf(token: CsrfToken): CsrfToken {
|
|
return token
|
|
}
|
|
}
|
|
----
|
|
====
|
|
|
|
The JavaScript can make a REST call to the endpoint and use the response to populate the headerName and the token.
|
|
|
|
We can now include the token in our Stomp client.
|
|
For example:
|
|
|
|
[source,javascript]
|
|
----
|
|
...
|
|
var headers = {};
|
|
headers[headerName] = token;
|
|
stompClient.connect(headers, function(frame) {
|
|
...
|
|
|
|
}
|
|
----
|
|
|
|
[[websocket-sameorigin-disable]]
|
|
=== Disable CSRF within WebSockets
|
|
|
|
If you want to allow other domains to access your site, you can disable Spring Security's protection.
|
|
For example, in Java Configuration you can use the following:
|
|
|
|
====
|
|
.Java
|
|
[source,java,role="primary"]
|
|
----
|
|
@Configuration
|
|
public class WebSocketSecurityConfig extends AbstractSecurityWebSocketMessageBrokerConfigurer {
|
|
|
|
...
|
|
|
|
@Override
|
|
protected boolean sameOriginDisabled() {
|
|
return true;
|
|
}
|
|
}
|
|
----
|
|
|
|
.Kotlin
|
|
[source,kotlin,role="secondary"]
|
|
----
|
|
@Configuration
|
|
open class WebSocketSecurityConfig : AbstractSecurityWebSocketMessageBrokerConfigurer() {
|
|
|
|
// ...
|
|
|
|
override fun sameOriginDisabled(): Boolean {
|
|
return true
|
|
}
|
|
}
|
|
----
|
|
====
|
|
|
|
|
|
[[websocket-sockjs]]
|
|
== Working with SockJS
|
|
|
|
https://docs.spring.io/spring/docs/current/spring-framework-reference/html/websocket.html#websocket-fallback[SockJS] provides fallback transports to support older browsers.
|
|
When using the fallback options we need to relax a few security constraints to allow SockJS to work with Spring Security.
|
|
|
|
[[websocket-sockjs-sameorigin]]
|
|
=== SockJS & frame-options
|
|
|
|
SockJS may use an https://github.com/sockjs/sockjs-client/tree/v0.3.4[transport that leverages an iframe].
|
|
By default Spring Security will xref:features/exploits/headers.adoc#headers-frame-options[deny] the site from being framed to prevent Clickjacking attacks.
|
|
To allow SockJS frame based transports to work, we need to configure Spring Security to allow the same origin to frame the content.
|
|
|
|
You can customize X-Frame-Options with the xref:servlet/appendix/namespace/http.adoc#nsa-frame-options[frame-options] element.
|
|
For example, the following will instruct Spring Security to use "X-Frame-Options: SAMEORIGIN" which allows iframes within the same domain:
|
|
|
|
[source,xml]
|
|
----
|
|
<http>
|
|
<!-- ... -->
|
|
|
|
<headers>
|
|
<frame-options
|
|
policy="SAMEORIGIN" />
|
|
</headers>
|
|
</http>
|
|
----
|
|
|
|
Similarly, you can customize frame options to use the same origin within Java Configuration using the following:
|
|
|
|
====
|
|
.Java
|
|
[source,java,role="primary"]
|
|
----
|
|
@EnableWebSecurity
|
|
public class WebSecurityConfig extends
|
|
WebSecurityConfigurerAdapter {
|
|
|
|
@Override
|
|
protected void configure(HttpSecurity http) throws Exception {
|
|
http
|
|
// ...
|
|
.headers(headers -> headers
|
|
.frameOptions(frameOptions -> frameOptions
|
|
.sameOrigin()
|
|
)
|
|
);
|
|
}
|
|
}
|
|
----
|
|
|
|
.Kotlin
|
|
[source,kotlin,role="secondary"]
|
|
----
|
|
@EnableWebSecurity
|
|
open class WebSecurityConfig : WebSecurityConfigurerAdapter() {
|
|
override fun configure(http: HttpSecurity) {
|
|
http {
|
|
// ...
|
|
headers {
|
|
frameOptions {
|
|
sameOrigin = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
----
|
|
====
|
|
|
|
[[websocket-sockjs-csrf]]
|
|
=== SockJS & Relaxing CSRF
|
|
|
|
SockJS uses a POST on the CONNECT messages for any HTTP based transport.
|
|
Typically we need to include the CSRF token in an HTTP header or an HTTP parameter.
|
|
However, SockJS does not allow for these options.
|
|
Instead, we must include the token in the Stomp headers as described in <<websocket-sameorigin-csrf>>.
|
|
|
|
It also means we need to relax our CSRF protection with the web layer.
|
|
Specifically, we want to disable CSRF protection for our connect URLs.
|
|
We do NOT want to disable CSRF protection for every URL.
|
|
Otherwise our site will be vulnerable to CSRF attacks.
|
|
|
|
We can easily achieve this by providing a CSRF RequestMatcher.
|
|
Our Java Configuration makes this extremely easy.
|
|
For example, if our stomp endpoint is "/chat" we can disable CSRF protection for only URLs that start with "/chat/" using the following configuration:
|
|
|
|
====
|
|
.Java
|
|
[source,java,role="primary"]
|
|
----
|
|
@Configuration
|
|
@EnableWebSecurity
|
|
public class WebSecurityConfig
|
|
extends WebSecurityConfigurerAdapter {
|
|
|
|
@Override
|
|
protected void configure(HttpSecurity http) throws Exception {
|
|
http
|
|
.csrf(csrf -> csrf
|
|
// ignore our stomp endpoints since they are protected using Stomp headers
|
|
.ignoringAntMatchers("/chat/**")
|
|
)
|
|
.headers(headers -> headers
|
|
// allow same origin to frame our site to support iframe SockJS
|
|
.frameOptions(frameOptions -> frameOptions
|
|
.sameOrigin()
|
|
)
|
|
)
|
|
.authorizeHttpRequests(authorize -> authorize
|
|
...
|
|
)
|
|
...
|
|
----
|
|
|
|
.Kotlin
|
|
[source,kotlin,role="secondary"]
|
|
----
|
|
@Configuration
|
|
@EnableWebSecurity
|
|
open class WebSecurityConfig : WebSecurityConfigurerAdapter() {
|
|
override fun configure(http: HttpSecurity) {
|
|
http {
|
|
csrf {
|
|
ignoringAntMatchers("/chat/**")
|
|
}
|
|
headers {
|
|
frameOptions {
|
|
sameOrigin = true
|
|
}
|
|
}
|
|
authorizeRequests {
|
|
// ...
|
|
}
|
|
// ...
|
|
|
|
----
|
|
====
|
|
|
|
If we are using XML based configuration, we can use the xref:servlet/appendix/namespace/http.adoc#nsa-csrf-request-matcher-ref[csrf@request-matcher-ref].
|
|
For example:
|
|
|
|
[source,xml]
|
|
----
|
|
<http ...>
|
|
<csrf request-matcher-ref="csrfMatcher"/>
|
|
|
|
<headers>
|
|
<frame-options policy="SAMEORIGIN"/>
|
|
</headers>
|
|
|
|
...
|
|
</http>
|
|
|
|
<b:bean id="csrfMatcher"
|
|
class="AndRequestMatcher">
|
|
<b:constructor-arg value="#{T(org.springframework.security.web.csrf.CsrfFilter).DEFAULT_CSRF_MATCHER}"/>
|
|
<b:constructor-arg>
|
|
<b:bean class="org.springframework.security.web.util.matcher.NegatedRequestMatcher">
|
|
<b:bean class="org.springframework.security.web.util.matcher.AntPathRequestMatcher">
|
|
<b:constructor-arg value="/chat/**"/>
|
|
</b:bean>
|
|
</b:bean>
|
|
</b:constructor-arg>
|
|
</b:bean>
|
|
----
|