mybatis源码分析之sql语句中#{}与${}表达式分析
最开始学习mybatis的时候对这两个表达式有疑惑,但一直没有深入去研究,直到最近的一个项目,测试安全的同学测试到有SQL入侵的问题,那时心里就下决心一定要把这个问题搞明白,虽然看似非常初级的一个小问题,但作为一个互联网项目却是致命的错误。记得以前在W公司工作的时候,有个同事因为没有使用占位符,而直接使用SQL拼接的方式去执行,虽然使用spring的jdbctemplate,但是原理一样,后面这个同事还被单独拉到办公室去痛批一顿。反正不管什么数据层的框架,只要涉及到SQL的都应该选择底层是PreparedStatement设置参数的方式去执行。其实在mybatis的wiki上面已经说得非常清楚了,使用${}表达式会有SQL入侵的风险,参考mybatis FAQ,所以有必要单独拿出来分析。
由于前面的章节已经分析了SQL语句的生成,参考动态SQL语句分析,在解析sql标签的时候就可以初步看到两者的区别了,XMLScriptBuilder执行parseDynamicTags方法的时候,具体实现参考源代码:
List<SqlNode> parseDynamicTags(XNode node) {
List<SqlNode> contents = new ArrayList<SqlNode>();
NodeList children = node.getNode().getChildNodes();
for (int i = 0; i < children.getLength(); i++) {
XNode child = node.newXNode(children.item(i));
if (child.getNode().getNodeType() == Node.CDATA_SECTION_NODE || child.getNode().getNodeType() == Node.TEXT_NODE) {
String data = child.getStringBody("");
TextSqlNode textSqlNode = new TextSqlNode(data);
//如果包含${}表达式会先检查,如果有就使用TextSqlNode,否则StaticTextSqlNode
if (textSqlNode.isDynamic()) {
contents.add(textSqlNode);
isDynamic = true;
} else {
//解析之后最终的SqlNode为StaticTextSqlNode
contents.add(new StaticTextSqlNode(data));
}
} else if (child.getNode().getNodeType() == Node.ELEMENT_NODE) { // issue #628
String nodeName = child.getNode().getNodeName();
NodeHandler handler = nodeHandlers(nodeName);
if (handler == null) {
throw new BuilderException("Unknown element <" + nodeName + "> in SQL statement.");
}
//动态sql封装
handler.handleNode(child, contents);
isDynamic = true;
}
}
return contents;
}
注意textSqlNode.isDynamic()这个判断,解析到xml的文本时,每次都要去判断是不是动态的,判断的依据就是文本SQL语句中是否包含${…}表达式,参考TextSqlNode的isDynamic方法:
public boolean isDynamic() {
DynamicCheckerTokenParser checker = new DynamicCheckerTokenParser();
GenericTokenParser parser = createParser(checker);
//有${}占位符的就是动态语句,GenericTokenParser解析的时候,就用handleToken方法处理
//DynamicCheckerTokenParser实现了TokenHandler接口,handleToken方法就把isDynamic的值设置为true
parser.parse(text);
return checker.isDynamic();
}
最终有${}表达式的文本节点会保存到TextSqlNode,而#{}表达式的文本节点保存到StaticTextSqlNode,打开源代码看StaticTextSqlNode除了拼接sql时调用了DynamicContext的appendSql方法其他什么事情也没做。上面的步骤只是在解析xml节点,还没有真正生成可执行的SQL语句,参考动态SQL语句分析的章节的uml就一目了然了。
解析后完sql语句的xml节点最终都会保存到MixedSqlNode,不管是动态SQL语句还是静态SQL语句,在执行的时候,都要执行MixedSqlNode.apply(dynamicContext)方法去生成可执行的SQL语句。mybatis也提供了LanguageDriver接口,可以实现自定义解析xml的实现,如使用Velocity、FreeMarker等。
DynamicSqlSource或者RawSqlSource拼接sql语句时,执行MixedSqlNode.apply(dynamicContext)方法,把保存的所有节点循环执行,如前面所诉,包含${…}表达式的会使用TextSqlNode.apply()执行,参考源代码:
@Override
public boolean apply(DynamicContext context) {
GenericTokenParser parser = createParser(new BindingTokenParser(context, injectionFilter));
context.appendSql(parser.parse(text));
return true;
}
在解析文本的时候,TextSqlNode中有个内部类BindingTokenParser,这个类就负责把${}表达式替换成具体的值,也就是说在拼接SQL的时候就把值传进去了,参考BindingTokenParser.handleToken方法:
@Override
public String handleToken(String content) {
Object parameter = context.getBindings().get("_parameter");
if (parameter == null) {
context.getBindings().put("value", null);
} else if (SimpleTypeRegistry.isSimpleType(parameter.getClass())) {
context.getBindings().put("value", parameter);
}
Object value = OgnlCache.getValue(content, context.getBindings());
String srtValue = (value == null ? "" : String.valueOf(value)); // issue #274 return "" instead of "null"
checkInjection(srtValue);
return srtValue;
}
底层使用的OGNL表达式获取到具体的值,SQL入侵问题就出现在这里,表达式中的值可以写入类似or 1=1这种SQL片段,而使用#{}表达式的文本节点包含到StaticTextSqlNode,查看StaticTextSqlNode的apply方法,其实什么事情都没有做,只是一个拼接SQL字符串就没做其他事情了,然后由SqlSourceBuilder的内部类ParameterMappingTokenHandler将#{}替换成?占位符,最终参数中的各种handler将参数设值到PreparedStatement。更直观一点的方式就是查看执行前的SQL是什么样子的,在PreparedStatementHandler实例化statement的时候可以把SQL打印出来,参考源代码:
@Override
protected Statement instantiateStatement(Connection connection) throws SQLException {
log.debug("statement:预处理,获取sql并返回Statement");
String sql = boundSql.getSql();
log.debug("statement:sql语句为 " + boundSql.getSql());
if (mappedStatement.getKeyGenerator() instanceof Jdbc3KeyGenerator) {
String[] keyColumnNames = mappedStatement.getKeyColumns();
if (keyColumnNames == null) {
return connection.prepareStatement(sql, PreparedStatement.RETURN_GENERATED_KEYS);
} else {
return connection.prepareStatement(sql, keyColumnNames);
}
} else if (mappedStatement.getResultSetType() != null) {
return connection.prepareStatement(sql, mappedStatement.getResultSetType().getValue(), ResultSet.CONCUR_READ_ONLY);
} else {
return connection.prepareStatement(sql);
}
}
简单的一句话总结就是,${}表达式是一个字符串截取,在sql执行前会被参数值替换,而#{}表达式只是把里面的参数替换成问号(?)。
mybatis针对${}入侵也有具体措施,参考issues 117,但是在最新版中的Configuration也没有setInjectionFilterEnabled、setInjectionFilter方法,反正不到万不得已,千万别使用${}表达式,最后引用mybatis wiki上面的的一句话:
Important: note that use of ${…} (string substitution) presents a risk for SQL injection attacks. Also, string substitution can be problematical for complex types like dates. For these reasons, we recommend using the #{…} form whenever possible.