文章目录

最开始学习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.

文章目录