跳到主要内容
版本:6.4.3

WebSocket 安全

QWen Max 中英对照 WebSocket WebSocket Security

Spring Security 4 添加了对保护 Spring 的 WebSocket 支持 的功能。本节描述如何使用 Spring Security 的 WebSocket 支持。

直接 JSR-356 支持

Spring Security 不提供直接的 JSR-356 支持,因为这样做提供的价值很小。这是因为格式是未知的,并且 Spring 对于未知格式的安全性所能做的事情很少。此外,JSR-356 没有提供拦截消息的方法,因此安全性会变得侵入性很强。

WebSocket 身份验证​

WebSockets 重用在建立 WebSocket 连接时 HTTP 请求中找到的相同的认证信息。这意味着 HttpServletRequest 上的 Principal 将被传递给 WebSockets。如果你使用的是 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 来指定端点模式,如下所示:

@Configuration
@EnableWebSocketSecurity // <1> // <2>
public class WebSocketSecurityConfig {

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

return messages.build();
}
}
  • 任何进入的 CONNECT 消息都需要一个有效的 CSRF 令牌来强制执行 同源策略。

  • 对于任何进入的请求,SecurityContextHolder 都会用 simpUser 头属性中的用户填充。

  • 我们的消息需要适当的授权。具体来说,任何以 /user/ 开头的进入消息都将需要 ROLE_USER。你可以在 WebSocket 授权 中找到有关授权的更多详细信息。

自定义授权​

当使用 AuthorizationManager 时,自定义非常简单。例如,你可以发布一个 AuthorizationManager,要求所有消息具有 "USER" 角色,如下所示:

@Configuration
@EnableWebSocketSecurity // <1> // <2>
public class WebSocketSecurityConfig {

@Bean
AuthorizationManager<Message<?>> messageAuthorizationManager(MessageMatcherDelegatingAuthorizationManager.Builder messages) {
return AuthorityAuthorizationManager.hasRole("USER");
}
}

有几种方法可以进一步匹配消息,如下所示的一个更高级的示例中可以看到:

@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();
}
}

这将确保:

  • 任何没有目的地的消息(即,消息类型不是 MESSAGE 或 SUBSCRIBE 的任何内容)将要求用户进行身份验证

  • 任何人都可以订阅 /user/queue/errors

  • 任何以 "/app/" 开头的目的地的消息将要求用户具有 ROLE_USER 角色

  • 任何以 "/user/" 或 "/topic/friends/" 开头且类型为 SUBSCRIBE 的消息将要求具有 ROLE_USER 角色

  • 任何其他类型为 MESSAGE 或 SUBSCRIBE 的消息将被拒绝。由于第 6 条的存在,我们不需要这一步,但它说明了如何匹配特定的消息类型。

  • 任何其他消息都将被拒绝。这是一个好主意,以确保你不会遗漏任何消息。

迁移 SpEL 表达式​

如果你是从旧版本的 Spring Security 迁移过来的,你的目标匹配器可能包含 SpEL 表达式。建议将这些表达式更改为使用 AuthorizationManager 的具体实现,因为这样可以独立进行测试。

然而,为了简化迁移,您也可以使用如下类:

public final class MessageExpressionAuthorizationManager implements AuthorizationManager<MessageAuthorizationContext<?>> {

private SecurityExpressionHandler<Message<?>> expressionHandler = new DefaultMessageSecurityExpressionHandler();

private Expression expression;

public MessageExpressionAuthorizationManager(String expressionString) {
Assert.hasText(expressionString, "expressionString cannot be empty");
this.expression = this.expressionHandler.getExpressionParser().parseExpression(expressionString);
}

@Override
public AuthorizationDecision check(Supplier<Authentication> authentication, MessageAuthorizationContext<?> context) {
EvaluationContext ctx = this.expressionHandler.createEvaluationContext(authentication, context.getMessage());
boolean granted = ExpressionUtils.evaluateAsBoolean(this.expression, ctx);
return new ExpressionAuthorizationDecision(granted, this.expression);
}

}

并为每个无法迁移的匹配器指定一个实例:

@Configuration
public class WebSocketSecurityConfig {

@Bean
public AuthorizationManager<Message<?>> messageAuthorizationManager(MessageMatcherDelegatingAuthorizationManager.Builder messages) {
messages
// ...
.simpSubscribeDestMatchers("/topic/friends/{friend}").access(new MessageExpressionAuthorizationManager("#friends == 'john"));
// ...

return messages.build();
}
}

WebSocket 授权注意事项​

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

WebSocket 授权在消息类型上的应用​

你需要理解 SUBSCRIBE 和 MESSAGE 类型的消息之间的区别,以及它们在 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 目标发送消息来向特定用户发送消息。

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

  • 然后应用程序使用 SimpMessageSendingOperations.convertAndSendToUser("toUser", "/queue/messages", message) 向接收者发送消息。

  • 该消息会被转换为目标 /queue/user/messages-<sessionid>。

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

通常,应用程序会拒绝发送到以 代理前缀(/topic/ 或 /queue/)开头的消息的任何 SUBSCRIBE。我们可能会提供例外情况来处理诸如

出站消息​

Spring Framework 参考文档中有一个名为 “消息流” 的章节,描述了消息如何在系统中流动。请注意,Spring Security 仅保护 clientInboundChannel。Spring Security 不会尝试保护 clientOutboundChannel。

最重要的原因是性能。对于每条进入的消息,通常会有更多的消息传出。与其保护传出的消息,我们建议保护对端点的订阅。

强制执行同源策略​

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

为什么是同源?​

考虑以下场景。用户访问 bank.com 并登录到他们的帐户。同一用户在浏览器中打开另一个标签页并访问 evil.com。同源策略确保 evil.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头部添加CSRF​

默认情况下,Spring Security 要求任何 CONNECT 消息类型中都包含 CSRF 令牌。这确保了只有能够访问 CSRF 令牌的站点才能连接。由于只有同源可以访问 CSRF 令牌,因此不允许外部域进行连接。

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

应用程序可以通过访问名为 _csrf 的请求属性来获取CSRF令牌。例如,以下方法允许在JSP中访问 CsrfToken:

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

如果你使用静态 HTML,你可以将 CsrfToken 暴露在一个 REST 端点上。例如,以下代码会将 CsrfToken 暴露在 /csrf URL 上:

@RestController
public class CsrfController {

@RequestMapping("/csrf")
public CsrfToken csrf(CsrfToken token) {
return token;
}
}

JavaScript 可以向端点发起 REST 调用,并使用响应来填充 headerName 和令牌。

我们现在可以在我们的 Stomp 客户端中包含该令牌:

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

})

禁用 WebSockets 中的 CSRF​

备注

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

要禁用CSRF,可以使用XML支持或自己添加Spring Security组件,而不是使用@EnableWebSocketSecurity,如下所示:

@Configuration
public class WebSocketSecurityConfig implements WebSocketMessageBrokerConfigurer {

private final ApplicationContext applicationContext;

private final AuthorizationManager<Message<?>> authorizationManager;

public WebSocketSecurityConfig(ApplicationContext applicationContext, AuthorizationManager<Message<?>> authorizationManager) {
this.applicationContext = applicationContext;
this.authorizationManager = authorizationManager;
}

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

@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
AuthorizationChannelInterceptor authz = new AuthorizationChannelInterceptor(authorizationManager);
AuthorizationEventPublisher publisher = new SpringAuthorizationEventPublisher(applicationContext);
authz.setAuthorizationEventPublisher(publisher);
registration.interceptors(new SecurityContextChannelInterceptor(), authz);
}
}

另一方面,如果你正在使用旧版 AbstractSecurityWebSocketMessageBrokerConfigurer,并且你希望允许其他域访问你的站点,你可以禁用 Spring Security 的保护。例如,在 Java 配置中,你可以使用以下代码:

// 代码示例
@Configuration
public class WebSocketSecurityConfig extends AbstractSecurityWebSocketMessageBrokerConfigurer {

...

@Override
protected boolean sameOriginDisabled() {
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"/>

如果你从旧版的 websocket-message-broker 迁移过来,并且实现了 SecurityExpressionHandler<Message<?>>,你可以:

  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 提供了回退传输以支持旧版浏览器。当使用回退选项时,我们需要放宽一些安全约束,以允许 SockJS 与 Spring Security 一起工作。

SockJS & frame-options​

SockJS 可能会使用基于 iframe 的传输方式。默认情况下,Spring Security 禁止 站点被嵌入框架以防止点击劫持攻击。为了使 SockJS 基于框架的传输能够正常工作,我们需要配置 Spring Security 以允许同源框架嵌入内容。

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

<http>
<frame-options policy="SAMEORIGIN" />
</http>
<http>
<!-- ... -->

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

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

// 你的代码示例可以放在这里
@Configuration
@EnableWebSecurity
public class WebSecurityConfig {

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// ...
.headers(headers -> headers
.frameOptions(frameOptions -> frameOptions
.sameOrigin()
)
);
return http.build();
}
}

SockJS 与放宽 CSRF​

SockJS 在基于 HTTP 的传输的 CONNECT 消息中使用 POST。通常,我们需要在 HTTP 头或 HTTP 参数中包含 CSRF 令牌。然而,SockJS 不允许这些选项。相反,我们必须按照 在 Stomp 头中添加 CSRF 中描述的那样,在 Stomp 头中包含该令牌。

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

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

@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
...
)
...
}
}

如果我们使用基于 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。例如:

@Configuration
public class WebSocketSecurityConfig
extends AbstractSecurityWebSocketMessageBrokerConfigurer { // <1> // <2>

protected void configureInbound(MessageSecurityMetadataSourceRegistry messages) {
messages
.simpDestMatchers("/user/**").authenticated() 3
}
}

这将确保:

  • 任何入站的 CONNECT 消息都需要一个有效的 CSRF 令牌来强制执行 同源策略

  • 对于任何入站请求,SecurityContextHolder 都会用 simpUser 标头属性中的用户填充。

  • 我们的消息需要适当的授权。具体来说,任何以 "/user/" 开头的入站消息都需要 ROLE_USER。有关授权的更多详细信息,请参阅 WebSocket 授权

使用旧版配置在你有一个自定义的 SecurityExpressionHandler(继承自 AbstractSecurityExpressionHandler 并重写了 createEvaluationContextInternal 或 createSecurityExpressionRoot)时会很有帮助。为了延迟 Authorization 查找,新的 AuthorizationManager API 在评估表达式时不会调用这些方法。

如果你使用的是 XML,可以通过不使用 use-authorization-manager 元素或将其设置为 false 来使用旧版 API。