跳到主要内容

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();
}
}
java
  • 任何进入的 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");
}
}
java

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

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

这将确保:

  • 任何没有目的地的消息(即,消息类型不是 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);
}

}
java

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

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

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 目标发送消息来向特定用户发送消息。

  • 应用程序看到消息,确保 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}";
javascript

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

@RestController
public class CsrfController {

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

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

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

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

})
javascript

禁用 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);
}
}
java

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

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

...

@Override
protected boolean sameOriginDisabled() {
return true;
}
}
java

自定义表达式处理器

有时,可能需要自定义在 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"/>
xml

如果你从旧版的 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>
xml

使用 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>
xml
<http>
<!-- ... -->

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

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

// 你的代码示例可以放在这里
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();
}
}
java

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

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

旧版 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
}
}
java

这将确保:

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

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

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

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

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