Spring SecurityでCSP(Content-Security-Policy)対策してみた話

こんにちは、猫好きリーマンのほげPGです。

ある日、いつものようにOWASP ZAPでセキュリティチェックをしていたら、こんな警告が出ました。

「Content-Security-Policyヘッダーが設定されていません!」

……というわけで、今回は CSP(Content-Security-Policy)対策 をSpring Securityでどう実装するか、実際にやってみた内容をまとめてみます。

そもそもCSPって?

CSPは、ブラウザが読み込むリソースの種類や出所を制限することで、XSS(クロスサイトスクリプティング)などの攻撃を防ぐための仕組みです。

中でも特に厳しいのが script-src と style-src の制限。これらを適切に設定しないと、ZAP先生に怒られます。

怒られたコードたち

以下のようなコードがCSP的にはNGとされます。

怒られるコード①:onclick に直接JavaScriptを書く

<form name="logoutForm" action="/logout" method="POST">
    <a onclick="logoutForm.submit();">Logout</a>
</form> 

怒られるコード②:<script> タグ内に直接JavaScriptを書く

<script>
$(window).on("load", function() {
      …
});
</script>  

怒られるコード③:style 属性に直接CSSを書く

<div style="background: green;">
<h3>トップ</h3>
</div> 

怒られるコード④:<style> タグ内に直接CSSを書く

<style>
    .cusor-pointer {
        cursor : pointer;
    }
</style>  

つまり、インラインのJavaScriptやCSSは全部アウトということですね。

対策方法:インラインコードを外に出す

基本的な対策はシンプルで、

  • JavaScript → .js ファイルに分離
  • CSS → .css ファイルに分離

という形にします。

ただし、既存プロジェクトでこれをやるのは正直しんどい……。ファイル分け、IDの整理、共通化など、考えることが山積みです。

現実的な対応:nonce を使う

そこで登場するのが nonce(ナンス)という仕組み。

CSPヘッダーに以下のように設定します:

Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-xxxx'; style-src 'self' 'nonce-xxxx';

※ xxxx はランダムな文字列(毎回変わる)

HTML側では、<script> や <style> タグに nonce 属性を付けることで、インラインコードでも許可されるようになります。

<script nonce="xxxx"
>…
</script>

Spring Bootでの実装例

Spring SecurityでCSPヘッダーを設定するには、以下のようにします。

WebSecurityConfig.java

@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
	
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                …
                .headers(headers -> headers
                        .contentSecurityPolicy(csp -> csp
                                .policyDirectives("default-src 'self' 'nonce-{nonce}';"
                                        + " frame-ancestors 'none'; form-action 'self';")
                                ))
                .addFilterBefore(new CspNonceFilter(), HeaderWriterFilter.class)
                .addFilterBefore(new LoginUserFilter(), UsernamePasswordAuthenticationFilter.class)
                …
        return http.build();
    }

    public static class CspNonceFilter implements Filter {
        private static final SecureRandom secureRandom = new SecureRandom();
        @Override
        public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
                throws IOException, ServletException {
            HttpServletRequest httpRequest = (HttpServletRequest) request;
            String uri = httpRequest.getServletPath();
            if (!checkStaticPath(uri)) {
                byte[] nonceBytes = new byte[16];
                secureRandom.nextBytes(nonceBytes);
                String nonce = Base64Utils.encodeToString(nonceBytes);
                request.setAttribute("cspNonce", nonce);
                chain.doFilter(request, new CspNonceResponseWrapper((HttpServletResponse) response, nonce));
            } else {
                chain.doFilter(request, response);
            }
        }
        private boolean checkStaticPath(String path) {
            return path.startsWith("/static/");
        }
    }
    
    public static class CspNonceResponseWrapper extends HttpServletResponseWrapper {
        private final String nonce;
        public CspNonceResponseWrapper(HttpServletResponse response, String nonce) {
            super(response);
            this.nonce = nonce;
        }
        private String getHeaderValue(String name, String value) {
            if (name.equals("Content-Security-Policy") && StringUtils.hasText(value)) {
                return value.replace("{nonce}", nonce);
            } else {
                return value;
            }
        }
        @Override
        public void setHeader(String name, String value) {
            super.setHeader(name, getHeaderValue(name, value));
        }
        @Override
        public void addHeader(String name, String value) {
            super.addHeader(name, getHeaderValue(name, value));
        }
    }
    
    @ControllerAdvice
    public static class CspNonceControllerAdvice {
        @ModelAttribute
        public void addAttributes(Model model, HttpServletRequest request) {
            model.addAttribute("nonce", request.getAttribute("cspNonce"));
        }
    }
}

テンプレート側の対応(Thymeleaf

Thymeleafを使っている場合は、テンプレートに th:nonce=”${nonce}” を書くだけでOK。

…
        <form name="logoutForm" th:action="@{/logout}" method="post" class="ms-auto link-light">
          <a id="logout" th:text="#{auth.logout}">Logout</a>
        </form>
…
      <script th:nonce="${nonce}">
      $(window).on("load", function() {
        $("#logout").on('click', function(e) {
          document.logoutForm.submit();
        });
      });
      </script>
…

イベントハンドラやクラスの切り替えなど、多少の工夫は必要ですが、構成を大きく変えずに済むのが嬉しいポイントです。

まとめ

  • CSPはセキュリティ対策として重要
  • インラインコードは基本NG
  • nonce を使えば、既存構成を大きく変えずに対応可能
  • Spring Security + Thymeleafなら比較的スムーズに導入できる

プロジェクト一式も用意してあるので、興味ある方はぜひ参考にしてみてください!

※元文書を適当に作成しCopilotで生成