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@EnableWebSecuritypublic 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で生成