/**
* BSD-style license; for more info see http://pmd.sourceforge.net/license.html
*/
package net.sourceforge.pmd.lang.vf.rule.security;
import java.util.EnumSet;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Pattern;
import net.sourceforge.pmd.lang.ast.Node;
import net.sourceforge.pmd.lang.vf.ast.ASTArguments;
import net.sourceforge.pmd.lang.vf.ast.ASTAttribute;
import net.sourceforge.pmd.lang.vf.ast.ASTContent;
import net.sourceforge.pmd.lang.vf.ast.ASTDotExpression;
import net.sourceforge.pmd.lang.vf.ast.ASTElExpression;
import net.sourceforge.pmd.lang.vf.ast.ASTElement;
import net.sourceforge.pmd.lang.vf.ast.ASTExpression;
import net.sourceforge.pmd.lang.vf.ast.ASTHtmlScript;
import net.sourceforge.pmd.lang.vf.ast.ASTIdentifier;
import net.sourceforge.pmd.lang.vf.ast.ASTLiteral;
import net.sourceforge.pmd.lang.vf.ast.ASTNegationExpression;
import net.sourceforge.pmd.lang.vf.ast.ASTText;
import net.sourceforge.pmd.lang.vf.ast.AbstractVFNode;
import net.sourceforge.pmd.lang.vf.rule.AbstractVfRule;
/**
* @author sergey.gorbaty February 2017
*
*/
public class VfUnescapeElRule extends AbstractVfRule {
private static final String A_CONST = "a";
private static final String APEXIFRAME_CONST = "apex:iframe";
private static final String IFRAME_CONST = "iframe";
private static final String HREF = "href";
private static final String SRC = "src";
private static final String APEX_PARAM = "apex:param";
private static final String VALUE = "value";
private static final String ITEM_VALUE = "itemvalue";
private static final String ESCAPE = "escape";
private static final String ITEM_ESCAPED = "itemescaped";
private static final String APEX_OUTPUT_TEXT = "apex:outputtext";
private static final String APEX_PAGE_MESSAGE = "apex:pagemessage";
private static final String APEX_PAGE_MESSAGES = "apex:pagemessages";
private static final String APEX_SELECT_OPTION = "apex:selectoption";
private static final String FALSE = "false";
private static final Pattern ON_EVENT = Pattern.compile("^on(\\w)+$");
private static final Pattern PLACEHOLDERS = Pattern.compile("\\{(\\w|,|\\.|'|:|\\s)*\\}");
@Override
public Object visit(ASTHtmlScript node, Object data) {
checkIfCorrectlyEscaped(node, data);
return super.visit(node, data);
}
private void checkIfCorrectlyEscaped(ASTHtmlScript node, Object data) {
ASTText prevText = null;
// churn thru every child just once instead of twice
for (int i = 0; i < node.jjtGetNumChildren(); i++) {
Node n = node.jjtGetChild(i);
if (n instanceof ASTText) {
prevText = (ASTText) n;
continue;
}
if (n instanceof ASTElExpression) {
processElInScriptContext((ASTElExpression) n, prevText, data);
}
}
}
private void processElInScriptContext(ASTElExpression elExpression, ASTText prevText, Object data) {
boolean quoted = false;
boolean jsonParse = false;
if (prevText != null) {
jsonParse = isJsonParse(prevText);
if (isUnbalanced(prevText.getImage(), '\'') || isUnbalanced(prevText.getImage(), '\"')) {
quoted = true;
}
}
if (quoted) {
// check escaping too
if (!(jsonParse || startsWithSafeResource(elExpression) || containsSafeFields(elExpression))) {
if (doesElContainAnyUnescapedIdentifiers(elExpression,
EnumSet.of(Escaping.JSENCODE, Escaping.JSINHTMLENCODE))) {
addViolation(data, elExpression);
}
}
} else {
if (!(startsWithSafeResource(elExpression) || containsSafeFields(elExpression))) {
final boolean hasUnscaped = doesElContainAnyUnescapedIdentifiers(elExpression,
EnumSet.of(Escaping.JSENCODE, Escaping.JSINHTMLENCODE));
if (!(jsonParse && !hasUnscaped)) {
addViolation(data, elExpression);
}
}
}
}
private boolean isJsonParse(ASTText prevText) {
final String text = (prevText.getImage().endsWith("'") || prevText.getImage().endsWith("'"))
? prevText.getImage().substring(0, prevText.getImage().length() - 1) : prevText.getImage();
if (text.endsWith("JSON.parse(") || text.endsWith("jQuery.parseJSON(") || text.endsWith("$.parseJSON(")) {
return true;
}
return false;
}
private boolean isUnbalanced(String image, char pattern) {
char[] array = image.toCharArray();
boolean foundPattern = false;
for (int i = array.length - 1; i > 0; i--) {
if (array[i] == pattern) {
foundPattern = true;
}
if (array[i] == ';') {
if (foundPattern) {
return true;
} else {
return false;
}
}
}
return foundPattern;
}
@Override
public Object visit(ASTElement node, Object data) {
if (doesTagSupportEscaping(node)) {
checkApexTagsThatSupportEscaping(node, data);
} else {
checkLimitedFlags(node, data);
checkAllOnEventTags(node, data);
}
return super.visit(node, data);
}
private void checkLimitedFlags(ASTElement node, Object data) {
switch (node.getName().toLowerCase()) {
case IFRAME_CONST:
case APEXIFRAME_CONST:
case A_CONST:
break;
default:
return;
}
final List<ASTAttribute> attributes = node.findChildrenOfType(ASTAttribute.class);
boolean isEL = false;
final Set<ASTElExpression> toReport = new HashSet<>();
for (ASTAttribute attr : attributes) {
String name = attr.getName().toLowerCase();
// look for onevents
if (HREF.equalsIgnoreCase(name) || SRC.equalsIgnoreCase(name)) {
boolean startingWithSlashText = false;
final ASTText attrText = attr.getFirstDescendantOfType(ASTText.class);
if (attrText != null) {
if (0 == attrText.jjtGetChildIndex()) {
if (attrText.getImage().startsWith("/") || attrText.getImage().toLowerCase().startsWith("http")
|| attrText.getImage().toLowerCase().startsWith("mailto")) {
startingWithSlashText = true;
}
}
}
if (!startingWithSlashText) {
final List<ASTElExpression> elsInVal = attr.findDescendantsOfType(ASTElExpression.class);
for (ASTElExpression el : elsInVal) {
if (startsWithSlashLiteral(el)) {
break;
}
if (startsWithSafeResource(el)) {
break;
}
if (doesElContainAnyUnescapedIdentifiers(el, Escaping.URLENCODE)) {
isEL = true;
toReport.add(el);
}
}
}
}
}
if (isEL) {
for (ASTElExpression expr : toReport) {
addViolation(data, expr);
}
}
}
private void checkAllOnEventTags(ASTElement node, Object data) {
final List<ASTAttribute> attributes = node.findChildrenOfType(ASTAttribute.class);
boolean isEL = false;
final Set<ASTElExpression> toReport = new HashSet<>();
for (ASTAttribute attr : attributes) {
String name = attr.getName().toLowerCase();
// look for onevents
if (ON_EVENT.matcher(name).matches()) {
final List<ASTElExpression> elsInVal = attr.findDescendantsOfType(ASTElExpression.class);
for (ASTElExpression el : elsInVal) {
if (startsWithSafeResource(el)) {
continue;
}
if (doesElContainAnyUnescapedIdentifiers(el,
EnumSet.of(Escaping.JSINHTMLENCODE, Escaping.JSENCODE))) {
isEL = true;
toReport.add(el);
}
}
}
}
if (isEL) {
for (ASTElExpression expr : toReport) {
addViolation(data, expr);
}
}
}
private boolean startsWithSafeResource(final ASTElExpression el) {
final ASTExpression expression = el.getFirstChildOfType(ASTExpression.class);
if (expression != null) {
final ASTNegationExpression negation = expression.getFirstChildOfType(ASTNegationExpression.class);
if (negation != null) {
return true;
}
final ASTIdentifier id = expression.getFirstChildOfType(ASTIdentifier.class);
if (id != null) {
List<ASTArguments> args = expression.findChildrenOfType(ASTArguments.class);
if (!args.isEmpty()) {
switch (id.getImage().toLowerCase()) {
case "urlfor":
case "casesafeid":
case "begins":
case "contains":
case "len":
case "getrecordids":
case "linkto":
case "sqrt":
case "round":
case "mod":
case "log":
case "ln":
case "exp":
case "abs":
case "floor":
case "ceiling":
case "nullvalue":
case "isnumber":
case "isnull":
case "isnew":
case "isblank":
case "isclone":
case "year":
case "month":
case "day":
case "datetimevalue":
case "datevalue":
case "date":
case "now":
case "today":
return true;
default:
}
} else {
// has no arguments
switch (id.getImage().toLowerCase()) {
case "$action":
case "$page":
case "$site":
case "$resource":
case "$label":
case "$objecttype":
case "$component":
case "$remoteaction":
return true;
default:
}
}
}
}
return false;
}
private boolean startsWithSlashLiteral(final ASTElExpression elExpression) {
final ASTExpression expression = elExpression.getFirstChildOfType(ASTExpression.class);
if (expression != null) {
final ASTLiteral literal = expression.getFirstChildOfType(ASTLiteral.class);
if (literal != null && literal.jjtGetChildIndex() == 0) {
if (literal.getImage().startsWith("'/") || literal.getImage().startsWith("\"/")
|| literal.getImage().toLowerCase().startsWith("'http")
|| literal.getImage().toLowerCase().startsWith("\"http")) {
return true;
}
}
}
return false;
}
private void checkApexTagsThatSupportEscaping(ASTElement node, Object data) {
final List<ASTAttribute> attributes = node.findChildrenOfType(ASTAttribute.class);
final Set<ASTElExpression> toReport = new HashSet<>();
boolean isUnescaped = false;
boolean isEL = false;
boolean hasPlaceholders = false;
for (ASTAttribute attr : attributes) {
String name = attr.getName().toLowerCase();
switch (name) {
case ESCAPE:
case ITEM_ESCAPED:
final ASTText text = attr.getFirstDescendantOfType(ASTText.class);
if (text != null) {
if (text.getImage().equalsIgnoreCase(FALSE)) {
isUnescaped = true;
}
}
break;
case VALUE:
case ITEM_VALUE:
final List<ASTElExpression> elsInVal = attr.findDescendantsOfType(ASTElExpression.class);
for (ASTElExpression el : elsInVal) {
if (startsWithSafeResource(el)) {
continue;
}
if (doesElContainAnyUnescapedIdentifiers(el, Escaping.HTMLENCODE)) {
isEL = true;
toReport.add(el);
}
}
final ASTText textValue = attr.getFirstDescendantOfType(ASTText.class);
if (textValue != null) {
if (PLACEHOLDERS.matcher(textValue.getImage()).matches()) {
hasPlaceholders = true;
}
}
break;
default:
break;
}
}
if (hasPlaceholders && isUnescaped) {
for (ASTElExpression expr : hasELInInnerElements(node)) {
addViolation(data, expr);
}
}
if (isEL && isUnescaped) {
for (ASTElExpression expr : toReport) {
addViolation(data, expr);
}
}
}
private boolean doesElContainAnyUnescapedIdentifiers(final ASTElExpression elExpression, Escaping escape) {
return doesElContainAnyUnescapedIdentifiers(elExpression, EnumSet.of(escape));
}
private boolean doesElContainAnyUnescapedIdentifiers(final ASTElExpression elExpression,
EnumSet<Escaping> escapes) {
if (elExpression == null) {
return false;
}
final Set<ASTIdentifier> nonEscapedIds = new HashSet<>();
final List<ASTExpression> exprs = elExpression.findChildrenOfType(ASTExpression.class);
for (final ASTExpression expr : exprs) {
if (innerContainsSafeFields(expr)) {
continue;
}
final List<ASTIdentifier> ids = expr.findChildrenOfType(ASTIdentifier.class);
for (final ASTIdentifier id : ids) {
boolean isEscaped = false;
for (Escaping e : escapes) {
if (id.getImage().equalsIgnoreCase(e.toString())) {
isEscaped = true;
break;
}
if (e.equals(Escaping.ANY)) {
for (Escaping esc : Escaping.values()) {
if (id.getImage().equalsIgnoreCase(esc.toString())) {
isEscaped = true;
break;
}
}
}
}
if (!isEscaped) {
nonEscapedIds.add(id);
}
}
}
return !nonEscapedIds.isEmpty();
}
private boolean containsSafeFields(final AbstractVFNode expression) {
final ASTExpression ex = expression.getFirstChildOfType(ASTExpression.class);
return ex == null ? false : innerContainsSafeFields(ex);
}
private boolean innerContainsSafeFields(final AbstractVFNode expression) {
for (int i = 0; i < expression.jjtGetNumChildren(); i++) {
Node child = expression.jjtGetChild(i);
if (child instanceof ASTIdentifier) {
switch (child.getImage().toLowerCase()) {
case "id":
case "size":
case "caseNumber":
return true;
default:
}
}
if (child instanceof ASTArguments) {
if (containsSafeFields((ASTArguments) child)) {
return true;
}
}
if (child instanceof ASTDotExpression) {
if (innerContainsSafeFields((ASTDotExpression) child)) {
return true;
}
}
}
return false;
}
private boolean doesTagSupportEscaping(final ASTElement node) {
if (node.getName() == null) {
return false;
}
switch (node.getName().toLowerCase()) { // vf is case insensitive
case APEX_OUTPUT_TEXT:
case APEX_PAGE_MESSAGE:
case APEX_PAGE_MESSAGES:
case APEX_SELECT_OPTION:
return true;
default:
return false;
}
}
private Set<ASTElExpression> hasELInInnerElements(final ASTElement node) {
final Set<ASTElExpression> toReturn = new HashSet<>();
final ASTContent content = node.getFirstChildOfType(ASTContent.class);
if (content != null) {
final List<ASTElement> innerElements = content.findChildrenOfType(ASTElement.class);
for (final ASTElement element : innerElements) {
if (element.getName().equalsIgnoreCase(APEX_PARAM)) {
final List<ASTAttribute> innerAttributes = element.findChildrenOfType(ASTAttribute.class);
for (ASTAttribute attrib : innerAttributes) {
final List<ASTElExpression> elsInVal = attrib.findDescendantsOfType(ASTElExpression.class);
for (final ASTElExpression el : elsInVal) {
if (startsWithSafeResource(el)) {
continue;
}
if (doesElContainAnyUnescapedIdentifiers(el, Escaping.HTMLENCODE)) {
toReturn.add(el);
}
}
}
}
}
}
return toReturn;
}
enum Escaping {
HTMLENCODE("HTMLENCODE"),
URLENCODE("URLENCODE"),
JSINHTMLENCODE("JSINHTMLENCODE"),
JSENCODE("JSENCODE"),
ANY("ANY");
private final String text;
Escaping(final String text) {
this.text = text;
}
@Override
public String toString() {
return text;
}
}
}