|
0
|
1 |
package hirondelle.web4j.security;
|
|
|
2 |
|
|
|
3 |
import hirondelle.web4j.model.Id;
|
|
|
4 |
import hirondelle.web4j.util.EscapeChars;
|
|
|
5 |
import hirondelle.web4j.util.Regex;
|
|
|
6 |
import hirondelle.web4j.util.Util;
|
|
|
7 |
|
|
|
8 |
import java.util.logging.Logger;
|
|
|
9 |
import java.util.regex.Matcher;
|
|
|
10 |
import java.util.regex.Pattern;
|
|
|
11 |
|
|
|
12 |
import javax.servlet.http.HttpServletRequest;
|
|
|
13 |
import javax.servlet.http.HttpServletResponse;
|
|
|
14 |
import javax.servlet.http.HttpSession;
|
|
|
15 |
|
|
|
16 |
/** Add a nonce to POSTed forms in any 'text/html' response. */
|
|
|
17 |
final class CsrfModifiedResponse {
|
|
|
18 |
|
|
|
19 |
CsrfModifiedResponse(HttpServletRequest aRequest, HttpServletResponse aResponse){
|
|
|
20 |
fResponse = aResponse;
|
|
|
21 |
fRequest = aRequest;
|
|
|
22 |
}
|
|
|
23 |
|
|
|
24 |
String addNonceTo(String aUnmodifiedResponse){
|
|
|
25 |
String result = aUnmodifiedResponse;
|
|
|
26 |
if(isServingHtml() && Util.textHasContent(aUnmodifiedResponse)) {
|
|
|
27 |
fLogger.fine("Adding nonce to forms having method=POST, if any.");
|
|
|
28 |
result = addHiddenParamToPostedForms(aUnmodifiedResponse);
|
|
|
29 |
}
|
|
|
30 |
return result;
|
|
|
31 |
}
|
|
|
32 |
|
|
|
33 |
// PRIVATE
|
|
|
34 |
|
|
|
35 |
private HttpServletRequest fRequest;
|
|
|
36 |
private HttpServletResponse fResponse;
|
|
|
37 |
|
|
|
38 |
/**
|
|
|
39 |
Group 1 is the FORM start tag *plus the body*, and group 2 is the FORM end tag.
|
|
|
40 |
Note the reluctant qualifier for group 2, to ensure multiple forms are not glommed together.
|
|
|
41 |
*/
|
|
|
42 |
private static final String REGEX =
|
|
|
43 |
"(<form" + Regex.ALL_BUT_END_OF_TAG +"method=" + Regex.QUOTE + "POST" + Regex.QUOTE + Regex.ALL_BUT_END_OF_TAG + ">"
|
|
|
44 |
+ Regex.ANY_CHARS + "?)" +
|
|
|
45 |
"(</form>)"
|
|
|
46 |
;
|
|
|
47 |
private static final Pattern FORM_PATTERN = Pattern.compile(REGEX, Pattern.DOTALL | Pattern.CASE_INSENSITIVE);
|
|
|
48 |
|
|
|
49 |
private static final String TEXT_HTML = "text/html";
|
|
|
50 |
|
|
|
51 |
/**
|
|
|
52 |
Problem: this class is package private. If we use the 'regular' logger, named for this class, then the output will not
|
|
|
53 |
show up. So, we use a logger attached to a closely related, public class. (Neat!)
|
|
|
54 |
*/
|
|
|
55 |
private static final Logger fLogger = Util.getLogger(CsrfFilter.class);
|
|
|
56 |
|
|
|
57 |
/** Return true if content-type of reponse is null, or starts with 'text/html' (case-sensitive). */
|
|
|
58 |
private boolean isServingHtml(){
|
|
|
59 |
String contentType = fResponse.getContentType();
|
|
|
60 |
boolean missingContentType = ! Util.textHasContent(contentType);
|
|
|
61 |
boolean startsWithHTML = Util.textHasContent(contentType) && contentType.startsWith(TEXT_HTML);
|
|
|
62 |
return missingContentType || startsWithHTML;
|
|
|
63 |
}
|
|
|
64 |
|
|
|
65 |
private String addHiddenParamToPostedForms(String aOriginalInput) {
|
|
|
66 |
StringBuffer result = new StringBuffer();
|
|
|
67 |
Matcher formMatcher = FORM_PATTERN.matcher(aOriginalInput);
|
|
|
68 |
while ( formMatcher.find() ){
|
|
|
69 |
fLogger.fine("Found a POSTed form. Adding nonce.");
|
|
|
70 |
formMatcher.appendReplacement(result, getReplacement(formMatcher));
|
|
|
71 |
}
|
|
|
72 |
formMatcher.appendTail(result);
|
|
|
73 |
return result.toString();
|
|
|
74 |
}
|
|
|
75 |
|
|
|
76 |
private String getReplacement(Matcher aMatcher){
|
|
|
77 |
//escape, since '$' char may appear in input
|
|
|
78 |
return EscapeChars.forReplacementString(aMatcher.group(1) + getHiddenInputTag() + aMatcher.group(2));
|
|
|
79 |
}
|
|
|
80 |
|
|
|
81 |
private String getHiddenInputTag(){
|
|
|
82 |
return "<input type='hidden' name='" + getHiddenParamName() + "' value='" + getHiddenParamValue().toString() + "'>";
|
|
|
83 |
}
|
|
|
84 |
|
|
|
85 |
/**
|
|
|
86 |
Return the form-source id value, stored in the user's session.
|
|
|
87 |
If there is no session, or if there is no form-source id in the session, throw a RuntimeException.
|
|
|
88 |
*/
|
|
|
89 |
private Id getHiddenParamValue(){
|
|
|
90 |
Id result = null;
|
|
|
91 |
boolean DO_NOT_CREATE = false;
|
|
|
92 |
HttpSession session = fRequest.getSession(DO_NOT_CREATE);
|
|
|
93 |
if ( session != null ) {
|
|
|
94 |
result = (Id)session.getAttribute(CsrfFilter.FORM_SOURCE_ID_KEY);
|
|
|
95 |
if( result == null ){
|
|
|
96 |
String message = "Session exists, but no CSRF token value is stored in the session";
|
|
|
97 |
fLogger.severe(message);
|
|
|
98 |
throw new RuntimeException(message);
|
|
|
99 |
}
|
|
|
100 |
}
|
|
|
101 |
else {
|
|
|
102 |
String message =
|
|
|
103 |
"No session exists! CsrfFilter can only work when a session is present, and the user has logged in. " +
|
|
|
104 |
"Ensure CsrfFilter is mapped (using url-pattern) only to URLs having mandatory login and/or a valid session."
|
|
|
105 |
;
|
|
|
106 |
fLogger.severe(message);
|
|
|
107 |
throw new RuntimeException(message);
|
|
|
108 |
}
|
|
|
109 |
return result;
|
|
|
110 |
}
|
|
|
111 |
|
|
|
112 |
/** Return the name of the hidden form parameter. */
|
|
|
113 |
private String getHiddenParamName(){
|
|
|
114 |
return CsrfFilter.FORM_SOURCE_ID_KEY;
|
|
|
115 |
}
|
|
|
116 |
} |