WebSocket セキュリティ

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 アプリケーションに対して認証されたことを確認するには、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 トークンが必要です。
2SecurityContextHolder には、受信リクエストの 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 その他のメッセージは拒否されます。これは、メッセージを見逃さないようにするための良いアイデアです。

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 {

    @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 は、古いブラウザーをサポートするためのフォールバックトランスポートを提供します。フォールバックオプションを使用する場合、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
                }
            }
            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 トークンが必要です
2SecurityContextHolder には、受信リクエストの simpUser ヘッダー属性内のユーザーが入力されます。
3 メッセージには適切な認可が必要です。具体的には、"/user/" で始まる受信メッセージには ROLE_USER が必要です。認可の詳細については、WebSocket 認証を参照してください。

AbstractSecurityExpressionHandler を継承し、createEvaluationContextInternal または createSecurityExpressionRoot をオーバーライドするカスタム SecurityExpressionHandler がある場合は、レガシー構成を使用すると便利です。Authorization ルックアップを延期するために、新しい AuthorizationManager API は、式を評価するときにこれらを呼び出しません。

XML を使用している場合は、use-authorization-manager 要素を使用しないか false に設定するだけで、レガシー API を使用できます。