入力→確認→登録→結果 という画面遷移を行う Webアプリケーションを作成し、Java におけるCSRF 対策の具体例を紹介します。画面遷移は下図のようになります。
トークンを設定しているのが登録確認画面だけですが、これは CSRF 対策では重要な処理を行う際にトークンの検証を行うためです。なお、入力画面でトークンを使用しても間違いではありません。
以前はトークンにセッション ID を使用することが多かったですが、最近は暗号論的擬似乱数生成器を使用して作成した乱数をトークンを使用します。トークンについては以下の記事を参照してください。
なお、環境は、Java 11 + Tomcat 9 + MySQL 8 になります。
・input.jsp
入力画面です。
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%> <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> </head> <body> <h1>名前を入力してください</h1> <form action="/test/receive" method="post"> 名前:<input type="text" name="name"><br> <input type="submit" value="送信"> <input type="reset" value="リセット"> </form> </body> </html>
・receive
CSRFトークンをセッションに格納します。
package test.csrf; import java.io.IOException; import javax.servlet.RequestDispatcher; import javax.servlet.ServletContext; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; @WebServlet("/Receive") public class Receive extends HttpServlet { private static final long serialVersionUID = 1L; public Receive() { super(); } /** * CSRFトークンをセットする */ protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // セッションを取得 HttpSession session = request.getSession(true); //トークンをセッションに保存 session.setAttribute("token", Csrf.getCsrfToken()); // CSRFのトークンをセット // forward する ServletContext sc = getServletContext(); RequestDispatcher rd = sc.getRequestDispatcher("/confirm.jsp"); rd.forward(request, response); } }
・Csrf
CSRFトークンを作成するクラスです。
package test.csrf; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; public class Csrf { private static int TOKEN_LENGTH = 16;//16*2=32バイト /** * 32バイトのCSRFトークンを作成 */ public static String getCsrfToken() { byte token[] = new byte[TOKEN_LENGTH]; StringBuffer buf = new StringBuffer(); SecureRandom random = null; try { random = SecureRandom.getInstance("SHA1PRNG"); random.nextBytes(token); for (int i = 0; i < token.length; i++) { buf.append(String.format("%02x", token[i])); } } catch (NoSuchAlgorithmException e) { e.printStackTrace(); } return buf.toString(); } }
・confirm.jsp
登録確認画面です。
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%> <%@ page import="org.apache.commons.text.StringEscapeUtils" %> <% request.setCharacterEncoding("UTF-8"); %> <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> </head> <body> <h1>登録しますか?</h1> <form action="/test/register" method="post"> 名前:<%=StringEscapeUtils.escapeHtml4(request.getParameter("name")) %><br> <input type="submit" value="登録"> <input type="button" value="戻る" onclick="history.back();"> <input type="hidden" name="name" value="<%=StringEscapeUtils.escapeHtml4(request.getParameter("name")) %>"> <input type="hidden" name="token" value="<%=StringEscapeUtils.escapeHtml4((String)session.getAttribute("token")) %>"> </form> </body> </html>
・register
CSRFトークンのチェックと登録処理を行います。
package test.csrf; import java.io.IOException; import java.sql.Connection; import java.sql.DriverManager; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; @WebServlet("/Register") public class Register extends HttpServlet { private static final long serialVersionUID = 1L; public Register() { super(); } /** * CSRFチェックと登録処理を行う */ protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding("UTF-8"); // パラメータを取得 String name = request.getParameter("name"); String csrf_token = request.getParameter("token"); // セッションのトークンを取得 HttpSession session = request.getSession(true); String token = (String)session.getAttribute("token"); //トークンチェック if (token == null || !(token.equals(csrf_token))) { //CSRF攻撃・エラー画面へ response.sendRedirect("/test/error.jsp"); return; } Connection con = null; PreparedStatement stmt = null; ResultSet rs = null; String url = "jdbc:mysql://192.168.33.10:3306/registersample?useServerPrepStmts=true&useUnicode=true&characterEncoding=utf8"; String user = "mysql"; String pass = "Mysql@1234"; try { //DB に接続 Class.forName("com.mysql.cj.jdbc.Driver"); con = DriverManager.getConnection(url, user, pass); // 自動コミットOFF/トランザクションの開始 con.setAutoCommit(false); //プレースホルダで SQL 作成 String sql = "INSERT INTO users (name) values (?);"; //SQL をプリコンパイル stmt = con.prepareStatement(sql); //パラメーターセット stmt.setString(1, name); //SQL 実行 stmt.execute(); // コミット con.commit(); } catch (SQLException | ClassNotFoundException e) { if (con != null) { // ロールバック try { con.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } } e.printStackTrace(); } finally { if (rs != null) { try { rs.close(); } catch (SQLException e) { e.printStackTrace(); } } if (stmt != null) { try { stmt.close(); } catch (SQLException e) { e.printStackTrace(); } } if (con != null) { try { con.close(); } catch (SQLException e) { e.printStackTrace(); } } } //登録結果画面へ response.sendRedirect("/test/success.jsp"); } }
・success.jsp
登録結果画面です。
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%> <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> </head> <body> <h1>登録しました</h1> </body> </html>
・error.jsp
エラー画面です。
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%> <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> </head> <body> <h1>処理が失敗しました</h1> </body> </html>
参考
Webアプリケーションセキュリティに関する記事は、以下のページにまとまっています。ぜひご確認ください。
更新履歴
・2020/08/18
CSRFのトークンをセッションIDから乱数に変更。
DBをSQL ServerからMySQLに変更。
XSSの脆弱性を解消。
その他改善。
・2013/03/06
初版公開
コメント