WebSocket 安全

本站(springdoc.cn)中的内容来源于 spring.io ,原始版权归属于 spring.io。由 springdoc.cn 进行翻译,整理。可供个人学习、研究,未经许可,不得进行任何转载、商用或与之相关的行为。 商标声明:Spring 是 Pivotal Software, Inc. 在美国以及其他国家的商标。

Spring Security 4增加了对 Spring 的 WebSocket 支持 的安全支持。本节介绍了如何使用Spring Security的WebSocket支持。

JSR-356 的直接支持

Spring Security不提供直接的JSR-356支持,因为这样做没有什么价值。这是因为该格式是未知的,而 Spring在保护未知格式方面几乎无能为力。此外,JSR-356没有提供拦截消息的方法,所以安全性会受到影响。

WebSocket 认证

WebSockets重用了WebSocket连接时在HTTP请求中发现的相同认证信息。这意味着 HttpServletRequest 上的 Principal 将被移交给 WebSocket。如果你使用的是Spring Security,HttpServletRequest 上的 Principal 会被自动重写。

更具体地说,为了确保用户已经验证了你的 WebSocket 应用程序,所需要的就是确保你设置Spring Security来验证你基于HTTP的Web应用程序。

WebSocket 授权

Spring Security 4.0 通过 Spring Messaging 抽象引入了对 WebSockets 的授权支持。

在 Spring Security 5.8 中,该支持已更新为使用 AuthorizationManager API。

要使用 Java 配置配置授权,只需包含 @EnableWebSocketSecurity 注解并发布一个 AuthorizationManager<Message<?>> Bean 或在 XML 中使用 use-authorization-manager 属性。一种方法是使用 AuthorizationManagerMessageMatcherRegistry 来指定端点模式,如下所示:

  • Java

  • Kotlin

  • Xml

@Configuration
@EnableWebSocketSecurity (1) (2)
public class WebSocketSecurityConfig {

    @Bean
    AuthorizationManager<Message<?>> messageAuthorizationManager(MessageMatcherDelegatingAuthorizationManager.Builder messages) {
        messages
                .simpDestMatchers("/user/**").hasRole("USER") (3)

        return messages.build();
    }
}
@Configuration
@EnableWebSocketSecurity (1) (2)
open class WebSocketSecurityConfig { (1) (2)
    @Bean
    fun messageAuthorizationManager(messages: MessageMatcherDelegatingAuthorizationManager.Builder): AuthorizationManager<Message<?>> {
        messages.simpDestMatchers("/user/**").hasRole("USER") (3)
        return messages.build()
    }
}
<websocket-message-broker use-authorization-manager="true"> (1) (2)
    <intercept-message pattern="/user/**" access="hasRole('USER')"/> (3)
</websocket-message-broker>
1 任何入站 CONNECT 报文都需要有效的 CSRF token,以执行 同源策略
2 对于任何入站请求,SecurityContextHolder 将在 simpUser header 属性中填充 user。
3 我们的信息需要适当的授权。具体来说,任何以 /user/ 开头的入站消息都需要 ROLE_USER。你可以在 WebSocket 授权 中找到有关授权的更多详细信息。

自定义授权

当使用 AuthorizationManager 时,定制是非常简单的。例如,你可以使用 AuthorityAuthorizationManager 发布一个 AuthorizationManager,要求所有消息的角色都是 "USER",如下所示。

  • Java

  • Kotlin

  • Xml

@Configuration
@EnableWebSocketSecurity (1) (2)
public class WebSocketSecurityConfig {

    @Bean
    AuthorizationManager<Message<?>> messageAuthorizationManager(MessageMatcherDelegatingAuthorizationManager.Builder messages) {
        return AuthorityAuthorizationManager.hasRole("USER");
    }
}
@Configuration
@EnableWebSocketSecurity (1) (2)
open class WebSocketSecurityConfig {
    @Bean
    fun messageAuthorizationManager(messages: MessageMatcherDelegatingAuthorizationManager.Builder): AuthorizationManager<Message<?>> {
        return AuthorityAuthorizationManager.hasRole("USER") (3)
    }
}
<bean id="authorizationManager" class="org.example.MyAuthorizationManager"/>

<websocket-message-broker authorization-manager-ref="myAuthorizationManager"/>

有几种方法可以进一步匹配信息,从下面一个更高级的例子中可以看出。

  • Java

  • Kotlin

  • Xml

@Configuration
public class WebSocketSecurityConfig {

    @Bean
    public AuthorizationManager<Message<?>> messageAuthorizationManager(MessageMatcherDelegatingAuthorizationManager.Builder 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)

        return messages.build();
    }
}
@Configuration
open class WebSocketSecurityConfig {
    fun messageAuthorizationManager(messages: MessageMatcherDelegatingAuthorizationManager.Builder): AuthorizationManager<Message<?> {
        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)

        return messages.build();
    }
}
<websocket-message-broker use-authorization-manager="true">
    (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/**" type="SUBSCRIBE" access="hasRole('USER')" />
    <intercept-message pattern="/topic/friends/*" type="SUBSCRIBE" 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>

这将确保:

1 任何没有目的地的信息(即除MESSAGE或SUBSCRIBE信息类型以外的任何信息)都需要用户进行认证。
2 任何人都可以订阅/user/queue/errors。
3 任何目的地以 "/app/" 开头的信息都将要求用户拥有ROLE_USER角色。
4 任何以 "/user/" 或 "/topic/friends/" 开头的信息,如果是SUBSCRIBE类型的,都需要ROLE_USER。
5 任何其他的MESSAGE或SUBSCRIBE类型的消息都被拒绝。由于6的原因,我们不需要这个步骤,但它说明了如何对特定的消息类型进行匹配。
6 任何其他信息都会被拒绝。这是一个好主意,以确保你不会错过任何信息。

WebSocket 授权说明

为了正确保护你的应用程序,你需要了解Spring的WebSocket支持。

关于消息类型的WebSocket授权

你需要了解 SUBSCRIBEMESSAGE 两种类型的消息的区别,以及它们在Spring中的工作方式。

考虑一个聊天应用程序。

  • 系统可以通过 /topic/system/notifications 的目的地向所有用户发送一个通知 MESSAGE

  • 客户端可以通过 SUBSCRIBE/topic/system/notifications 接收通知。

虽然我们希望客户能够 SUBSCRIBE /topic/system/notifications,但我们不希望他们能够向该目的地发送 MESSAGE 。如果我们允许向 /topic/system/notifications 发送 MESSAGE,客户就可以直接向该端点发送消息并冒充系统。

一般来说,应用程序通常会拒绝任何发送到以 broker 前缀/topic//queue/)开头的目的地的 MESSAGE

目的地的WebSocket授权

你还应该了解目的地是如何转化的。

考虑一个聊天应用程序:

  • 用户可以通过向 /app/chat 目的地发送消息来向特定的用户发送消息。

  • 应用程序看到该消息,确保从属性被指定为当前用户(我们不能相信客户端)。

  • 然后应用程序通过使用 SimpMessageSendingOperations.convertAndSendToUser("toUser", "/queue/messages", message) 将信息发送给收件人。

  • 消息被转到 /queue/user/messages-<sessionid> 的目的地。

在这个聊天应用程序中,我们想让我们的客户收听 /user/queue,它被转化为 /queue/user/messages-<sessionid>。然而,我们不希望客户端能够监听 /queue/*,因为那会让客户端看到每个用户的消息。

一般来说,应用程序通常会拒绝向以 broker 前缀/topic//queue/)开头的消息发送任何 SUBSCRIBE

出站信息

Spring框架参考文档中包含一个题为 “Flow of Messages” 的章节,描述了消息如何在系统中流动。请注意,Spring Security只保护 clientInboundChannel 的安全。Spring Security并不试图保护 clientOutboundChannel

这方面最重要的原因是性能。每一条进入的消息,通常都会有更多的消息被送出。我们鼓励确保订阅端点的安全,而不是确保出站信息的安全。

强制执行同源策略

请注意,浏览器不对WebSocket连接执行 同源策略。这是一个极其重要的考虑因素。

为什么是同源?

考虑以下情况。一个用户访问 bank.com 并认证了他们的账户。同一个用户在他们的浏览器中打开另一个标签并访问了 evil.com。同源策略确保 evil.com 不能从 bank.com 读取数据或向 bank.com 写入数据。

对于WebSockets,同源政策并不适用。事实上,除非 bank.com 明确禁止,否则 evil.com 可以代表用户读取和写入数据。这意味着,用户通过 WebSocket 可以做的任何事情(如转账),evil.com 都可以代表该用户做。

由于SockJS试图模拟WebSockets,它也绕过了同源政策。这意味着开发人员在使用SockJS时需要明确地保护他们的应用程序不受外部域的影响。

Spring WebSocket 允许的源

幸运的是,自Spring 4.1.5以来,Spring 的 WebSocket 和 SockJS 支持限制了对 当前域 的访问。Spring Security增加了一个额外的保护层,以提供 深度防御

在Stomp Header中添加CSRF

默认情况下,Spring Security在任何 CONNECT 消息类型中都要求有 CSRF token。这确保只有能够访问CSRF令牌的网站才能连接。由于只有“同源”才能访问CSRF令牌,所以不允许外部域进行连接。

通常情况下,我们需要在HTTP头或HTTP参数中包含CSRF令牌。然而,SockJS不允许有这些选项。相反,我们必须将令牌包含在Stomp header中。

应用程序可以通过访问名为 _csrf 的 request attribute 来 获得CSRF令牌。例如,下面允许在JSP中访问 CsrfToken

var headerName = "${_csrf.headerName}";
var token = "${_csrf.token}";

如果你使用静态HTML,你可以将 CsrfToken 暴露在REST端点上。例如,下面将在 /csrf URL上公开 CsrfToken

  • Java

  • Kotlin

@RestController
public class CsrfController {

    @RequestMapping("/csrf")
    public CsrfToken csrf(CsrfToken token) {
        return token;
    }
}
@RestController
class CsrfController {
    @RequestMapping("/csrf")
    fun csrf(token: CsrfToken): CsrfToken {
        return token
    }
}

JavaScript可以对端点进行REST调用,并使用响应来填充 headerName 和token。

现在我们可以在我们的Stomp客户端中包含该Token。

...
var headers = {};
headers[headerName] = token;
stompClient.connect(headers, function(frame) {
  ...

})

在WebSockets中禁用CSRF

此时,当使用 @EnableWebSocketSecurity 时,CSRF是不可配置的,尽管这可能会在未来的版本中被添加。

要禁用CSRF,不要使用 @EnableWebSocketSecurity,你可以使用XML支持或自己添加Spring Security组件,像这样。

  • Java

  • Kotlin

  • Xml

@Configuration
public class WebSocketSecurityConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
        argumentResolvers.add(new AuthenticationPrincipalArgumentResolver());
    }

    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        AuthorizationManager<Message<?>> myAuthorizationRules = AuthenticatedAuthorizationManager.authenticated();
        AuthorizationChannelInterceptor authz = new AuthorizationChannelInterceptor(myAuthorizationRules);
        AuthorizationEventPublisher publisher = new SpringAuthorizationEventPublisher(this.context);
        authz.setAuthorizationEventPublisher(publisher);
        registration.interceptors(new SecurityContextChannelInterceptor(), authz);
    }
}
@Configuration
open class WebSocketSecurityConfig : WebSocketMessageBrokerConfigurer {
    @Override
    override fun addArgumentResolvers(argumentResolvers: List<HandlerMethodArgumentResolver>) {
        argumentResolvers.add(AuthenticationPrincipalArgumentResolver())
    }

    @Override
    override fun configureClientInboundChannel(registration: ChannelRegistration) {
        var myAuthorizationRules: AuthorizationManager<Message<?>> = AuthenticatedAuthorizationManager.authenticated()
        var authz: AuthorizationChannelInterceptor = AuthorizationChannelInterceptor(myAuthorizationRules)
        var publisher: AuthorizationEventPublisher = SpringAuthorizationEventPublisher(this.context)
        authz.setAuthorizationEventPublisher(publisher)
        registration.interceptors(SecurityContextChannelInterceptor(), authz)
    }
}
<websocket-message-broker use-authorization-manager="true" same-origin-disabled="true">
    <intercept-message pattern="/**" access="authenticated"/>
</websocket-message-broker>

另一方面,如果你使用 传统的 AbstractSecurityWebSocketMessageBrokerConfigurer,并且你想允许其他域访问你的网站,你可以禁用Spring Security的保护。例如,在Java配置中,你可以使用以下方法。

  • Java

  • Kotlin

@Configuration
public class WebSocketSecurityConfig extends AbstractSecurityWebSocketMessageBrokerConfigurer {

    ...

    @Override
    protected boolean sameOriginDisabled() {
        return true;
    }
}
@Configuration
open class WebSocketSecurityConfig : AbstractSecurityWebSocketMessageBrokerConfigurer() {

    // ...

    override fun sameOriginDisabled(): Boolean {
        return true
    }
}

自定义表达式处理器

有时,定制如何处理你的 intercept-message XML元素中定义的 access 表达式可能有价值。要做到这一点,你可以创建一个 SecurityExpressionHandler<MessageAuthorizationContext<?>> 类型的类,并在你的XML定义中像这样引用它。

<websocket-message-broker use-authorization-manager="true">
    <expression-handler ref="myRef"/>
    ...
</websocket-message-broker>

<b:bean ref="myRef" class="org.springframework.security.messaging.access.expression.MessageAuthorizationContextSecurityExpressionHandler"/>

如果你是从实现 SecurityExpressionHandler<Message<?>>websocket-message-broker 的传统用法迁移过来的,你可以。

  1. 另外实现 createEvaluationContext(Supplier, Message) 方法

  2. 然后在 MessageAuthorizationContextSecurityExpressionHandler 中包裹这个值,就像这样。

<websocket-message-broker use-authorization-manager="true">
    <expression-handler ref="myRef"/>
    ...
</websocket-message-broker>

<b:bean ref="myRef" class="org.springframework.security.messaging.access.expression.MessageAuthorizationContextSecurityExpressionHandler">
    <b:constructor-arg>
        <b:bean class="org.example.MyLegacyExpressionHandler"/>
    </b:constructor-arg>
</b:bean>

使用SockJS

SockJS 提供了 fallback 的传输方式来支持旧的浏览器。当使用回退选项时,我们需要放松一些安全约束,以使SockJS能够与Spring Security一起工作。

SockJS 和 frame-options

SockJS可能会使用一个 利用iframe的传输。默认情况下,Spring Security会 拒绝 网站被frame加载,以防止点击劫持攻击。为了让SockJS基于框架的传输发挥作用,我们需要配置Spring Security,让同一来源的内容被frame加载。

你可以用 frame-options 元素自定义 X-Frame-Options。例如,下面指示Spring Security使用 X-Frame-Options。SAMEORIGIN,它允许同一域内的iframe。

<http>
    <!-- ... -->

    <headers>
        <frame-options
          policy="SAMEORIGIN" />
    </headers>
</http>

同样,你可以通过以下方式在Java配置中自定义 frame 选项以使用相同的源。

  • Java

  • Kotlin

@Configuration
@EnableWebSecurity
public class WebSecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            // ...
            .headers(headers -> headers
                .frameOptions(frameOptions -> frameOptions
                     .sameOrigin()
                )
        );
        return http.build();
    }
}
@Configuration
@EnableWebSecurity
open class WebSecurityConfig {
    @Bean
    open fun filterChain(http: HttpSecurity): SecurityFilterChain {
        http {
            // ...
            headers {
                frameOptions {
                    sameOrigin = true
                }
            }
        }
        return http.build()
    }
}

SockJS与放宽CSRF

SockJS在任何基于HTTP的传输的CONNECT消息上使用POST。通常情况下,我们需要在HTTP头或HTTP参数中包含CSRF令牌。然而,SockJS不允许有这些选项。相反,我们必须在 在Stomp Header中添加CSRF 中描述的Stomp Header 中包含令牌。

这也意味着我们需要放松对web层的CSRF保护。具体来说,我们希望对我们的连接URL禁用CSRF保护。我们不希望对每个URL都禁用CSRF保护。否则,我们的网站就容易受到CSRF攻击。

我们可以通过提供一个CSRF RequestMatcher 来轻松实现这一目标。我们的Java配置使之变得简单。例如,如果我们的stomp端点是 /chat,我们可以通过使用以下配置,只对以 /chat/ 开头的URL禁用CSRF保护。

  • Java

  • Kotlin

@Configuration
@EnableWebSecurity
public class WebSecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf
                // ignore our stomp endpoints since they are protected using Stomp headers
                .ignoringRequestMatchers("/chat/**")
            )
            .headers(headers -> headers
                // allow same origin to frame our site to support iframe SockJS
                .frameOptions(frameOptions -> frameOptions
                    .sameOrigin()
                )
            )
            .authorizeHttpRequests(authorize -> authorize
                ...
            )
            ...
    }
}
@Configuration
@EnableWebSecurity
open class WebSecurityConfig {
    @Bean
    open fun filterChain(http: HttpSecurity): SecurityFilterChain {
        http {
            csrf {
                ignoringRequestMatchers("/chat/**")
            }
            headers {
                frameOptions {
                    sameOrigin = true
                }
            }
            authorizeRequests {
                // ...
            }
            // ...
        }
    }
}

如果我们使用基于XML的配置,我们可以使用 csrf@request-matcher-ref

<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>

传统的WebSocket配置

在Spring Security 5.8之前,使用Java配置消息授权的方法是扩展 AbstractSecurityWebSocketMessageBrokerConfigurer 并配置 MessageSecurityMetadataSourceRegistry。比如说。

  • Java

  • Kotlin

@Configuration
public class WebSocketSecurityConfig
      extends AbstractSecurityWebSocketMessageBrokerConfigurer { (1) (2)

    protected void configureInbound(MessageSecurityMetadataSourceRegistry messages) {
        messages
                .simpDestMatchers("/user/**").authenticated() (3)
    }
}
@Configuration
open class WebSocketSecurityConfig : AbstractSecurityWebSocketMessageBrokerConfigurer() { (1) (2)
    override fun configureInbound(messages: MessageSecurityMetadataSourceRegistry) {
        messages.simpDestMatchers("/user/**").authenticated() (3)
    }
}

这将确保:

1 任何入站的CONNECT消息都需要一个有效的CSRF令牌以执行同源策略
2 SecurityContextHolder 是用任何入站请求的 simpUser Header 属性中的用户来填充的。
3 我们的消息需要适当的授权。具体而言,任何以 "/user/" 开头的入站消息都需要ROLE_USER。关于授权的其他细节可在 WebSocket 授权 中找到。

如果你有一个自定义的 SecurityExpressionHandler,它扩展了 AbstractSecurityExpressionHandler 并重写了 createEvaluationContextInternalcreateSecurityExpressionRoot,那么使用传统的配置是有帮助的。为了推迟授权查询,新的 AuthorizationManager API在评估表达式时不调用这些。

如果你使用的是XML,你只需不使用 use-authorization-manager 元素或将其设置为 false,就可以使用传统的API。