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