package hirondelle.web4j.security;
import hirondelle.web4j.model.Id;
import hirondelle.web4j.util.EscapeChars;
import hirondelle.web4j.util.Regex;
import hirondelle.web4j.util.Util;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
/** Add a nonce to POSTed forms in any 'text/html' response. */
final class CsrfModifiedResponse {
CsrfModifiedResponse(HttpServletRequest aRequest, HttpServletResponse aResponse){
fResponse = aResponse;
fRequest = aRequest;
}
String addNonceTo(String aUnmodifiedResponse){
String result = aUnmodifiedResponse;
if(isServingHtml() && Util.textHasContent(aUnmodifiedResponse)) {
fLogger.fine("Adding nonce to forms having method=POST, if any.");
result = addHiddenParamToPostedForms(aUnmodifiedResponse);
}
return result;
}
// PRIVATE
private HttpServletRequest fRequest;
private HttpServletResponse fResponse;
/**
Group 1 is the FORM start tag *plus the body*, and group 2 is the FORM end tag.
Note the non-greedy qualifier for group 2, to ensure multiple forms are not glommed together.
*/
private static final String REGEX =
"(<form" + Regex.ALL_BUT_END_OF_TAG +"method=" + Regex.QUOTE + "POST" + Regex.QUOTE + Regex.ALL_BUT_END_OF_TAG + ">"
+ Regex.ANY_CHARS + "?)" +
"(</form>)"
;
private static final Pattern FORM_PATTERN = Pattern.compile(REGEX, Pattern.DOTALL | Pattern.CASE_INSENSITIVE);
private static final String TEXT_HTML = "text/html";
/**
Problem: this class is package private. If we use the 'regular' logger, named for this class, then the output will not
show up. So, we use a logger attached to a closely related, public class. (Neat!)
*/
private static final Logger fLogger = Util.getLogger(CsrfFilter.class);
/** Return true if content-type of reponse is null, or starts with 'text/html' (case-sensitive). */
private boolean isServingHtml(){
String contentType = fResponse.getContentType();
boolean missingContentType = ! Util.textHasContent(contentType);
boolean startsWithHTML = Util.textHasContent(contentType) && contentType.startsWith(TEXT_HTML);
return missingContentType || startsWithHTML;
}
private String addHiddenParamToPostedForms(String aOriginalInput) {
StringBuffer result = new StringBuffer();
Matcher formMatcher = FORM_PATTERN.matcher(aOriginalInput);
while ( formMatcher.find() ){
fLogger.fine("Found a POSTed form. Adding nonce.");
formMatcher.appendReplacement(result, getReplacement(formMatcher));
}
formMatcher.appendTail(result);
return result.toString();
}
private String getReplacement(Matcher aMatcher){
//escape, since '$' char may appear in input
return EscapeChars.forReplacementString(aMatcher.group(1) + getHiddenInputTag() + aMatcher.group(2));
}
private String getHiddenInputTag(){
return "<input type='hidden' name='" + getHiddenParamName() + "' value='" + getHiddenParamValue().toString() + "'>";
}
/**
Return the form-source id value, stored in the user's session.
If there is no session, or if there is no form-source id in the session, throw a RuntimeException.
*/
private Id getHiddenParamValue(){
Id result = null;
boolean DO_NOT_CREATE = false;
HttpSession session = fRequest.getSession(DO_NOT_CREATE);
if ( session != null ) {
result = (Id)session.getAttribute(CsrfFilter.FORM_SOURCE_ID_KEY);
if( result == null ){
String message = "Session exists, but no CSRF token value is stored in the session";
fLogger.severe(message);
throw new RuntimeException(message);
}
}
else {
String message =
"No session exists! CsrfFilter can only work when a session is present, and the user has logged in. " +
"Ensure CsrfFilter is mapped (using url-pattern) only to URLs having mandatory login and/or a valid session."
;
fLogger.severe(message);
throw new RuntimeException(message);
}
return result;
}
/** Return the name of the hidden form parameter. */
private String getHiddenParamName(){
return CsrfFilter.FORM_SOURCE_ID_KEY;
}
}