001 package hirondelle.web4j.security; 002 003 import hirondelle.web4j.Controller; 004 import hirondelle.web4j.database.DAOException; 005 import hirondelle.web4j.database.SqlId; 006 import hirondelle.web4j.model.Id; 007 import hirondelle.web4j.util.Util; 008 import hirondelle.web4j.util.WebUtil; 009 010 import java.io.CharArrayWriter; 011 import java.io.IOException; 012 import java.io.PrintWriter; 013 import java.security.MessageDigest; 014 import java.security.NoSuchAlgorithmException; 015 import java.util.Random; 016 import java.util.logging.Logger; 017 018 import javax.servlet.Filter; 019 import javax.servlet.FilterChain; 020 import javax.servlet.FilterConfig; 021 import javax.servlet.ServletException; 022 import javax.servlet.ServletRequest; 023 import javax.servlet.ServletResponse; 024 import javax.servlet.http.HttpServletRequest; 025 import javax.servlet.http.HttpServletResponse; 026 import javax.servlet.http.HttpServletResponseWrapper; 027 import javax.servlet.http.HttpSession; 028 029 /** 030 Protect your application from a 031 <a href='http://en.wikipedia.org/wiki/Cross-site_request_forgery'>Cross Site Request Forgery</a> (CSRF). 032 033 <P>Please see the package overview for important information regarding CSRF attacks, and security in general. 034 035 <P>This filter maintains various items needed to protect against CSRF attacks. It acts both as a 036 pre-processor and as a post-processor. The behavior of this class is controlled by detecting two important events: 037 <ul> 038 <li>the creation of new sessions (which does <i>not</i> necessarily imply a successful user login has also occured) 039 <li>a successful user login (which <i>does</i> imply a session has also been created) 040 </ul> 041 042 <h4>Pre-processing</h4> 043 When <i>a new session</i> is detected (but not necessarily a user login), then this class will do the following : 044 <ul> 045 <li>calculate a random form-source id, and place it in session scope, under the key {@link #FORM_SOURCE_ID_KEY}. 046 This value is difficult to guess. 047 <li>wrap the response in a custom wrapper, to implement the post-processing performed by this filter (see below) 048 </ul> 049 050 In addition, if <i>a new user login</i> is detected, then this class will do the following : 051 <ul> 052 <li>if there is any 'old' form-source id, place it in session scope as well, under the 053 key {@link #PREVIOUS_FORM_SOURCE_ID_KEY}. The 'old' form-source id is simply the form-source id 054 used in the <em>immediately preceding session for the same user</em>. 055 <li>place in session scope an object which will store the form-source id when the session expires or is invalidated, under 056 the key {@link #FORM_SOURCE_DAO_KEY}. 057 </ul> 058 059 <P>The above behavior of this class upon user login requires interaction with your database. 060 It's configured in <tt>web.xml</tt> using two items : 061 <tt>FormSourceIdRead</tt> and <tt>FormSourceIdWrite</tt>. These two items are 062 {@link hirondelle.web4j.database.SqlId} references. 063 They tell this class which SQL statements to use when reading and writing form-source ids 064 to the database. As usual, these {@link SqlId} items must be declared somewhere in your 065 application as <tt>public static final</tt> fields, and the corresponding SQL statements 066 must appear somewhere in an <tt>.sql</tt> file. 067 068 <P>(Please see these items in the example application for an illustration : <tt>web.xml</tt>, 069 <tt>UserDAO</tt>, and <tt>csrf.sql</tt>.) 070 071 <h4>Post-processing</h4> 072 If a session is present, then this class will use a custom response wrapper to alter the response: 073 <ul> 074 <li>if the response has <tt>content-type</tt> of <tt>text/html</tt> (or <tt>null</tt>), then scan 075 the response for all {@code <FORM>} tags with <tt>method='POST'</tt>. 076 <li>for each such {@code <FORM>} tag, add a hidden parameter in the following style : 077 <PRE><input type='hidden' name='web4j_key_for_form_source_id' value='151jdk65654dasdf545sadf6a5s4f'></PRE> 078 </ul> 079 080 The name of the hidden parameter is taken from {@link #FORM_SOURCE_ID_KEY}, 081 and the <tt>value</tt> of that hidden parameter is the random token created during the pre-processing stage. 082 083 <h4>ApplicationFirewall</h4> 084 This class cooperates closely with {@link hirondelle.web4j.security.ApplicationFirewallImpl}. It is the 085 firewall which performs the actual test to make sure the POSTed form came from your web app. 086 087 <h4>Warning Regarding Error Pages</h4> 088 This Filter uses a wrapper for the response. When a Filter wraps the response, the error page 089 customization defined by <tt>web.xml</tt> will likely not function. 090 (This may be a defect of the Servlet API itself - see section 9.9.3.) That is, when an error occurs when using this 091 Filter, the generic error pages defined by the container may be served, instead of the custom 092 error pages you have configured in <tt>web.xml</tt>. 093 094 <P>This filter will only affect the response if its content-type is <tt>text/html</tt> or <tt>null</tt>. 095 It will not affect any other type of response. 096 */ 097 public class CsrfFilter implements Filter { 098 099 /** 100 <em>Key</em> for item stored in session scope, and also <em>name</em> of hidden 101 request parameter added to POSTed forms. 102 103 <P>Value - {@value}. 104 <P>The <em>value</em> of this item is generated randomly for each new user login, and contains a 105 simple token that is hard to guess. Each POSTed form will be required by {@link ApplicationFirewallImpl} 106 to include a hidden parameter of this <em>name</em>, and the <em>value</em> of such hidden parameters 107 are matched to the corresponding item stored in session scope under the same key. These checks verify that 108 POSTed forms have come from a trusted source. 109 */ 110 public static final String FORM_SOURCE_ID_KEY = "web4j_key_for_form_source_id"; 111 112 /** 113 Key for item stored in session scope. 114 115 <P>Value - {@value}. 116 <P>The value of this item is retrieved from the database for each new user login, and 117 represents the form-source id for the user's <em>immediately preceding</em> session. 118 When a match of form-source id against {@link #FORM_SOURCE_ID_KEY} fails, then a second 119 match is attempted against this item. 120 121 <P>Please see the package description for an explanation of why this is necessary. 122 */ 123 public static final String PREVIOUS_FORM_SOURCE_ID_KEY = "web4j_key_for_previous_form_source_id"; 124 125 /** 126 Key for item stored in session scope. 127 128 <P>Value - {@value}. 129 <P>This item points to an {@link javax.servlet.http.HttpSessionBindingListener} object placed in each new session. 130 When the session ends, that object will be unbound from the session, and will save the user's current form-source id 131 to the database, for future use. 132 */ 133 public static final String FORM_SOURCE_DAO_KEY = "web4j_key_for_form_source_dao"; 134 135 /** 136 Read in filter configuration. 137 138 <P>Reads in {@link hirondelle.web4j.database.SqlId} references used to read and write the user's form-source id. 139 <P>See class comment and package-level description for further information. 140 */ 141 public void init(FilterConfig aFilterConfig) { 142 fLogger.config("INIT : " + this.getClass().getName() + ". Reading in SqlIds for reading and writing form-source ids."); 143 String read_sql = aFilterConfig.getInitParameter("FormSourceIdRead"); 144 String write_sql = aFilterConfig.getInitParameter("FormSourceIdWrite"); 145 checkValidSqlId(read_sql); 146 checkValidSqlId(write_sql); 147 CsrfDAO.init(read_sql, write_sql); 148 } 149 150 /** This implementation does nothing. */ 151 public void destroy() { 152 fLogger.config("DESTROY : " + this.getClass().getName()); 153 } 154 155 /** 156 Protect against CSRF attacks. 157 158 <P>See class comment and package-level description for further information. 159 */ 160 public void doFilter(ServletRequest aRequest, ServletResponse aResponse, FilterChain aChain) throws IOException, ServletException { 161 fLogger.fine("START CSRF Filter."); 162 HttpServletRequest request = (HttpServletRequest)aRequest; 163 HttpServletResponse response = (HttpServletResponse)aResponse; 164 165 addItemsForNewSessions(request); 166 167 if(isServingHtml(response)){ 168 fLogger.fine("Serving html. Wrapping response."); 169 CharResponseWrapper wrapper = new CharResponseWrapper(response); 170 aChain.doFilter(aRequest, wrapper); //AppFirewall and BadRequest 171 172 CharArrayWriter buffer = new CharArrayWriter(); 173 CsrfModifiedResponse modifiedResponse = new CsrfModifiedResponse(request, response); 174 String originalOutput = wrapper.toString(); 175 buffer.write(modifiedResponse.addNonceTo(originalOutput)); 176 String encoding = (String)WebUtil.findAttribute(Controller.CHARACTER_ENCODING, request); 177 aResponse.setContentLength(buffer.toString().getBytes(encoding).length); 178 179 aResponse.getWriter().write(buffer.toString()); //this will use the response's encoding 180 aResponse.getWriter().close(); 181 } 182 else { 183 fLogger.fine("Not serving html. Not modifiying response."); 184 aChain.doFilter(aRequest, aResponse); //do nothing special 185 } 186 fLogger.fine("END CSRF Filter."); 187 } 188 189 /** 190 Add a CSRF token to an existing session <i>that has no user login</i>. 191 192 <P><i>This method is called only when a session created by an Action, instead of the usual login mechanism.</i> 193 See {@link hirondelle.web4j.action.ActionImpl#createSessionAndCsrfToken()} for important information. 194 */ 195 public void addCsrfToken(HttpServletRequest aRequest) throws ServletException { 196 addItemsForNewSessions(aRequest); 197 } 198 199 // PRIVATE 200 201 //WARNING : Filters always need to be thread-safe !! 202 203 private static final Logger fLogger = Util.getLogger(CsrfFilter.class); 204 private static final boolean DO_NOT_CREATE = false; 205 206 private static void checkValidSqlId(String aSqlId){ 207 if ( ! Util.textHasContent(aSqlId) ) { 208 String message = "SqlId required as Filter init-param, but has no content: " + Util.quote(aSqlId); 209 fLogger.severe(message); 210 } 211 } 212 213 private void addItemsForNewSessions(HttpServletRequest aRequest) throws ServletException { 214 HttpSession session = aRequest.getSession(DO_NOT_CREATE); 215 if ( sessionExists(session) ){ 216 if ( hasNoFormSourceIdInSession(session) ){ 217 Id currentFormSourceId = calcFormSourceId(); 218 addFormSourceIdToSession(session, currentFormSourceId); 219 if( userHasLoggedIn(aRequest) ){ 220 CsrfDAO formSourceDAO = new CsrfDAO(aRequest.getUserPrincipal().getName(), currentFormSourceId); 221 addPreviousFormSourceIdToSession(session, formSourceDAO); 222 addFormSourceDAOToSession(session, formSourceDAO); 223 } 224 } 225 } 226 } 227 228 private boolean sessionExists(HttpSession aSession){ 229 return aSession != null; 230 } 231 232 private boolean hasNoFormSourceIdInSession(HttpSession aSession){ 233 return aSession.getAttribute(FORM_SOURCE_ID_KEY) == null; 234 } 235 236 private boolean userHasLoggedIn(HttpServletRequest aRequest){ 237 return aRequest.getUserPrincipal() != null; 238 } 239 240 private void addFormSourceIdToSession(HttpSession aSession, Id aCurrentFormSourceId) { 241 fLogger.fine("Adding new form-source id to user's session."); 242 aSession.setAttribute(FORM_SOURCE_ID_KEY, aCurrentFormSourceId); 243 } 244 245 private Id calcFormSourceId(){ 246 String token = getHashFor( getRandomNumber().toString() ); 247 return new Id(token); 248 } 249 250 private void addPreviousFormSourceIdToSession(HttpSession aSession, CsrfDAO aDAO) throws ServletException { 251 fLogger.fine("Adding previous form-source id to session."); 252 try { 253 Id previousFormSourceId = aDAO.fetchPreviousFormSourceId(); 254 if( previousFormSourceId == null ) { 255 fLogger.fine("No previous form-source id found."); 256 } 257 else { 258 fLogger.fine("Adding previous form-source id to session."); 259 aSession.setAttribute(PREVIOUS_FORM_SOURCE_ID_KEY, previousFormSourceId); 260 } 261 } 262 catch (DAOException ex){ 263 throw new ServletException("Cannot fetch previous form-source id from database.", ex); 264 } 265 } 266 267 private void addFormSourceDAOToSession(HttpSession aSession, CsrfDAO aDAO) { 268 fLogger.fine("Adding CsrfDAO object to session."); 269 aSession.setAttribute(FORM_SOURCE_DAO_KEY, aDAO); 270 } 271 272 private synchronized Long getRandomNumber() { 273 Random random = new Random(); 274 return random.nextLong(); 275 } 276 277 private String getHashFor(String aText) { 278 String result = null; 279 try { 280 MessageDigest sha = MessageDigest.getInstance("SHA-1"); 281 byte[] hashOne = sha.digest(aText.getBytes()); 282 result = hexEncode(hashOne); 283 } 284 catch (NoSuchAlgorithmException ex){ 285 String message = "MessageDigest cannot find SHA-1 algorithm."; 286 fLogger.severe(message); 287 throw new RuntimeException(message); 288 } 289 return result; 290 } 291 292 /** 293 The byte[] returned by MessageDigest does not have a nice 294 textual representation, so some form of encoding is usually performed. 295 296 This implementation follows the example of David Flanagan's book 297 "Java In A Nutshell", and converts a byte array into a String 298 of hex characters. 299 */ 300 static private String hexEncode( byte[] aInput){ 301 StringBuilder result = new StringBuilder(); 302 char[] digits = {'0', '1', '2', '3', '4','5','6','7','8','9','a','b','c','d','e','f'}; 303 for (int idx = 0; idx < aInput.length; ++idx) { 304 byte b = aInput[idx]; 305 result.append( digits[ (b&0xf0) >> 4 ] ); 306 result.append( digits[ b&0x0f] ); 307 } 308 return result.toString(); 309 } 310 311 private static final String TEXT_HTML = "text/html"; 312 313 /** Return true if content-type of reponse is null, or starts with 'text/html' (case-sensitive). */ 314 private boolean isServingHtml(HttpServletResponse aResponse){ 315 String contentType = aResponse.getContentType(); 316 boolean missingContentType = ! Util.textHasContent(contentType); 317 boolean startsWithHTML = Util.textHasContent(contentType) && contentType.startsWith(TEXT_HTML); 318 return missingContentType || startsWithHTML; 319 } 320 321 private static final class CharResponseWrapper extends HttpServletResponseWrapper { 322 public String toString() { 323 return fOutput.toString(); 324 } 325 public CharResponseWrapper(HttpServletResponse response){ 326 super(response); 327 fOutput = new CharArrayWriter(); 328 } 329 public PrintWriter getWriter(){ 330 return new PrintWriter(fOutput); 331 } 332 private CharArrayWriter fOutput; 333 } 334 }