/** * 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; } } }