WebSocket セキュリティ
Spring Security 4 は、Spring の WebSocket サポートを保護するためのサポートを追加しました。このセクションでは、Spring Security の WebSocket サポートの使用方法について説明します。
WebSocket 認証
WebSockets は、WebSocket 接続が確立されたときに HTTP リクエストで見つかった同じ認証情報を再利用します。これは、HttpServletRequest 上の Principal が WebSockets に引き渡されることを意味します。Spring Security を使用している場合、HttpServletRequest の Principal は自動的にオーバーライドされます。
より具体的には、ユーザーが WebSocket アプリケーションに対して認証されたことを確認するには、HTTP ベースの Web アプリケーションを認証するように Spring Security をセットアップすることだけが必要です。
WebSocket 認証
Spring Security 4.0 は、Spring メッセージングの抽象化を通じて WebSockets の認可サポートを導入しました。
Spring Security 5.8 では、このサポートがリフレッシュされ、AuthorizationManager API を使用できるようになりました。
Java 構成を使用して認可を構成するには、@EnableWebSocketSecurity アノテーションを含めて AuthorizationManager<Message<?>> Bean を発行するか、XML で use-authorization-manager 属性を使用します。これを行う 1 つの方法は、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 トークンが必要です。 |
| 2 | SecurityContextHolder には、受信リクエストの simpUser ヘッダー属性内のユーザーが入力されます。 |
| 3 | メッセージには適切な認可が必要です。具体的には、/user/ で始まる受信メッセージには ROLE_USER が必要です。認可に関する追加の詳細については、WebSocket 認証を参照してください。 |
カスタム認証
AuthorizationManager を使用する場合、カスタマイズは非常に簡単です。例: 以下に示すように、AuthorityAuthorizationManager を使用して、すべてのメッセージが "USER" のロールを持つことを要求する AuthorizationManager を公開できます。
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 | 型 SUBSCRIBE の "/user/" または "/topic/friends/" で始まるメッセージには、ROLE_USER が必要です。 |
| 5 | 型 MESSAGE または SUBSCRIBE の他のメッセージは拒否されます。6 のため、この手順は必要ありませんが、特定のメッセージ型でどのように一致するかを示しています。 |
| 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 AuthorizationResult authorize(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
Kotlin
@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();
}
}
@Configuration
open class WebSocketSecurityConfig {
fun messageAuthorizationManager(messages: MessageMatcherDelegatingAuthorizationManager.Builder): AuthorizationManager<Message<?> {
messages
// ..
.simpSubscribeDestMatchers("/topic/friends/{friends}").access(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 をその宛先に送信できるようにしたくはありません。MESSAGE を /topic/system/notifications に送信することを許可した場合、クライアントはそのエンドポイントに直接メッセージを送信し、システムになりすますことができます。
一般に、アプリケーションは、ブローカープレフィックス(/topic/ または /queue/)で始まる宛先に送信された MESSAGE を拒否するのが一般的です。
宛先での WebSocket 認可
また、宛先がどのように変換されるかを理解する必要があります。
チャットアプリケーションについて考えてみましょう。
ユーザーは、
/app/chat宛先にメッセージを送信することにより、特定のユーザーにメッセージを送信できます。アプリケーションはメッセージを確認し、
from属性が現在のユーザーとして指定されていることを確認します(クライアントを信頼することはできません)。次に、アプリケーションは
SimpMessageSendingOperations.convertAndSendToUser("toUser", "/queue/messages", message)を使用してメッセージを受信者に送信します。メッセージは
/queue/user/messages-<sessionid>の宛先に変換されます。
このチャットアプリケーションでは、/queue/user/messages-<sessionid> に変換される /user/queue をクライアントにリッスンさせたいと考えています。ただし、クライアントが /queue/* をリッスンできるようにする必要はありません。これにより、クライアントはすべてのユーザーのメッセージを見ることができます。
一般に、アプリケーションは、ブローカープレフィックス(/topic/ または /queue/)で始まるメッセージに送信された SUBSCRIBE を拒否するのが一般的です。次のようなことを説明するために例外を提供する場合があります
送信メッセージ
Spring Framework リファレンスドキュメントには、メッセージがシステムをどのように流れるかを説明する “メッセージの流れ” というタイトルのセクションが含まれています。Spring Security は clientInboundChannel のみを保護することに注意してください。Spring Security は clientOutboundChannel を保護しようとしません。
これの最も重要な理由はパフォーマンスです。受信するすべてのメッセージに対して、通常はさらに多くのメッセージが発信されます。送信メッセージを保護する代わりに、エンドポイントへのサブスクリプションを保護することをお勧めします。
同一生成元ポリシーの強制
ブラウザーは WebSocket 接続に対して同一生成元ポリシー [Wikipedia] を強制しないことに注意してください。これは非常に重要な考慮事項です。
なぜ同一生成元なのでしょうか?
次のシナリオを考えてみましょう。ユーザーが 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 が提供する追加の保護レイヤー追加多層防御 [Wikipedia] (英語) を。
CSRF を Stomp ヘッダーに追加する
デフォルトでは、Spring Security は任意の CONNECT メッセージ型で CSRF トークンを必要とします。これにより、CSRF トークンにアクセスできるサイトのみが接続できるようになります。同じオリジンのみが CSRF トークンにアクセスできるため、外部ドメインは接続を許可されません。
通常、CSRF トークンを HTTP ヘッダーまたは HTTP パラメーターに含める必要があります。ただし、SockJS はこれらのオプションを許可していません。代わりに、Stomp ヘッダーにトークンを含める必要があります。
アプリケーションは、_csrf という名前のリクエスト属性にアクセスすることで CSRF トークンを取得できます。例: 以下により、JSP で CsrfToken にアクセスできます。
var headerName = "${_csrf.headerName}";
var token = "${_csrf.token}"; 静的 HTML を使用する場合は、REST エンドポイントで CsrfToken を公開できます。例: 次の場合、/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 とトークンを設定できます。
これで、Stomp クライアントにトークンを含めることができます。
...
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 {
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);
}
}
@Configuration
open class WebSocketSecurityConfig(val applicationContext: ApplicationContext, val authorizationManager: AuthorizationManager<Message<*>>) : WebSocketMessageBrokerConfigurer {
@Override
override fun addArgumentResolvers(argumentResolvers: List<HandlerMethodArgumentResolver>) {
argumentResolvers.add(AuthenticationPrincipalArgumentResolver())
}
@Override
override fun configureClientInboundChannel(registration: ChannelRegistration) {
var authz: AuthorizationChannelInterceptor = AuthorizationChannelInterceptor(authorizationManager)
var publisher: AuthorizationEventPublisher = SpringAuthorizationEventPublisher(applicationContext)
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>
カスタム式ハンドラー
場合によっては、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 は、古いブラウザーをサポートするためのフォールバックトランスポートを提供します。フォールバックオプションを使用する場合、SockJS が Spring Security で動作できるように、いくつかのセキュリティ制約を緩和する必要があります。
SockJS とフレームオプション
SockJS は、iframe [GitHub] (英語) を利用するトランスポートを使用する場合があります。デフォルトでは、Spring Security は、クリックジャッキング攻撃を防ぐためにサイトがフレーム化されることを拒否します。 SockJS フレームベースのトランスポートを機能させるには、同じオリジンがコンテンツをフレーム化するように Spring Security を構成する必要があります。
frame-options 要素を使用して X-Frame-Options をカスタマイズできます。例: 以下は、Spring Security に X-Frame-Options: SAMEORIGIN を使用するように指示します。これにより、同じドメイン内で iframe が許可されます。
<http>
<!-- ... -->
<headers>
<frame-options
policy="SAMEORIGIN" />
</headers>
</http>同様に、以下を使用して、Java 構成内で同じオリジンを使用するようにフレームオプションをカスタマイズできます。
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 を使用します。通常、CSRF トークンを HTTP ヘッダーまたは HTTP パラメーターに含める必要があります。ただし、SockJS はこれらのオプションを許可していません。代わりに、CSRF を Stomp ヘッダーに追加するに従って、Stomp ヘッダーにトークンを含める必要があります。
また、Web レイヤーを使用して CSRF 保護を緩和する必要があることも意味します。具体的には、接続 URL の CSRF 保護を無効にします。すべての URL で CSRF 保護を無効にする必要はありません。そうしないと、当サイトは CSRF 攻撃に対して脆弱になります。
CSRF RequestMatcher を提供することで、これを簡単に実現できます。Java 構成により、これが簡単になります。例: ストンプエンドポイントが /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
}
}
authorizeHttpRequests {
// ...
}
// ...
}
}
}
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.config.http.PathPatternRequestMatcherFactoryBean">
<b:constructor-arg value="/chat/**"/>
</b:bean>
</b:bean>
</b:constructor-arg>
</b:bean>レガシー WebSocket 構成
AbstractSecurityWebSocketMessageBrokerConfigurer と MessageSecurityMetadataSourceRegistry は Spring Security 7 以降削除されています。詳しくは 5.8 移行ガイドを参照してください。