目录
前言
使用#能防止sql注入,这是一个共识,但是为什么#能够做到,却很难说的清。本文主要分析#{}和${}的解析过程,从源码层次上解释#防止sql注入的原因。
一、初步解析
1.getBoundSql
在CachingExecutor的query方法中,mybatis在从缓存或者数据库中获取数据之前,需要调用getBoundSql方法,将解析后的sql作为缓存中key的一部分,之所以在查找之前解析,也是为了缓存key的生成。在这个方法中,实现了将${}中的值替换为真实参数,将#{}中的值替换为?占位符,并将参数写入参数映射中。
public BoundSql getBoundSql(Object parameterObject) {
DynamicContext context = new DynamicContext(configuration, parameterObject);
//TextSqlNode,只是对$符完成了解析
rootSqlNode.apply(context);
SqlSourceBuilder sqlSourceParser = new SqlSourceBuilder(configuration);
Class<?> parameterType = parameterObject == null ? Object.class : parameterObject.getClass();
//完成对#符的解析
SqlSource sqlSource = sqlSourceParser.parse(context.getSql(), parameterType, context.getBindings());
BoundSql boundSql = sqlSource.getBoundSql(parameterObject);
//设置additionalParameter
context.getBindings().forEach(boundSql::setAdditionalParameter);
return boundSql;
}
2.parse
实际对sql进行处理的是GenericTokenParser的parse方法,在该方法中,保留{}外面的字符不变,对于${},直接查找对应的参数值,返回string进行 拼接;对于#{},返回?占位符。
public String parse(String text) {
if (text == null || text.isEmpty()) {
return "";
}
// search open token
int start = text.indexOf(openToken);
if (start == -1) {
return text;
}
char[] src = text.toCharArray();
int offset = 0;
final StringBuilder builder = new StringBuilder();
StringBuilder expression = null;
while (start > -1) {
if (start > 0 && src[start - 1] == '\\') {
// this open token is escaped. remove the backslash and continue.
builder.append(src, offset, start - offset - 1).append(openToken);
offset = start + openToken.length();
} else {
// found open token. let's search close token.
if (expression == null) {
expression = new StringBuilder();
} else {
expression.setLength(0);
}
//builder中添加#或$之前的字段
builder.append(src, offset, start - offset);
offset = start + openToken.length();
int end = text.indexOf(closeToken, offset);
while (end > -1) {
if (end > offset && src[end - 1] == '\\') {
// this close token is escaped. remove the backslash and continue. expression中添加{}中的字段
expression.append(src, offset, end - offset - 1).append(closeToken);
offset = end + closeToken.length();
end = text.indexOf(closeToken, offset);
} else {
expression.append(src, offset, end - offset);
break;
}
}
if (end == -1) {
// close token was not found.
builder.append(src, start, src.length - start);
offset = src.length;
} else {
//sqlSourceBuild在#{}情况下生成?,将参数加入到parameterMappings。
//BindingTokenParser 在${}情况下直接返回对应值,字符串相加,varchar字段需要手动添加单引号
//builder添加转化后的值,#{}对应?占位符,S{}对应真正的值
builder.append(handler.handleToken(expression.toString()));
offset = end + closeToken.length();
}
}
start = text.indexOf(openToken, offset);
}
if (offset < src.length) {
//添加{}后面的内容
builder.append(src, offset, src.length - offset);
}
return builder.toString();
}
3.handleToken
parse方法中通过handleToken方法返回不同的值实现对$和#的不同解析。对于$,返回真实参数;对于#,生成参数映射器,返回?。
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);
}
//查找{}中的内容对应的真实参数,并变为string返回
Object value = OgnlCache.getValue(content, context.getBindings());
String srtValue = value == null ? "" : String.valueOf(value); // issue #274 return "" instead of "null"
checkInjection(srtValue);
return srtValue;
}
public String handleToken(String content) {
//将参数映射添加到parameterMappings中,返回?占位符
parameterMappings.add(buildParameterMapping(content));
return "?";
}
二、parameterMappings解析
1.setParameters
前面在对#{}进行解析的时候,会返回占位符?,并将参数添加到parameterMappings中,在SimpleExecutor的doQuery方法中,回对parameterMappings进行解析。
public <E> List<E> doQuery(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException {
Statement stmt = null;
try {
Configuration configuration = ms.getConfiguration();
// 创建语句处理器
StatementHandler handler = configuration.newStatementHandler(wrapper, ms, parameter, rowBounds, resultHandler, boundSql);
//语句预处理,
stmt = prepareStatement(handler, ms.getStatementLog());
return handler.query(stmt, resultHandler);
} finally {
closeStatement(stmt);
}
}
在DefaultParameterHandler的setParameters方法中,会遍历parameterMappings,找到参数对应值,对预编译语句进行处理。
public void setParameters(PreparedStatement ps) {
ErrorContext.instance().activity("setting parameters").object(mappedStatement.getParameterMap().getId());
List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
if (parameterMappings != null) {
//通过@param和实体object传递参数都是在此处解析的,parameterMappings对应xml中的参数
for (int i = 0; i < parameterMappings.size(); i++) {
ParameterMapping parameterMapping = parameterMappings.get(i);
if (parameterMapping.getMode() != ParameterMode.OUT) {
Object value;
String propertyName = parameterMapping.getProperty();
//当boundSql含有额外参数时,mapper参数为list时
if (boundSql.hasAdditionalParameter(propertyName)) { // issue #448 ask first for additional params
value = boundSql.getAdditionalParameter(propertyName);
} else if (parameterObject == null) {
value = null;
} else if (typeHandlerRegistry.hasTypeHandler(parameterObject.getClass())) {
value = parameterObject;
} else {
//传递参数使用@param时,parameterObject对应ParamMap(基于@param参数和默认key生成),使用map.get(prop.getName())取值,保证xml中变量名与@param中的名一致,或者使用arg0,arg1,param0
//传递参数使用实体object时,parameterObject对应的时实体object,使用BeanWrapper基于属性反射获取,保证xml变量名与属性名一致
MetaObject metaObject = configuration.newMetaObject(parameterObject);
//获取参数对应的值
value = metaObject.getValue(propertyName);
}
TypeHandler typeHandler = parameterMapping.getTypeHandler();
JdbcType jdbcType = parameterMapping.getJdbcType();
if (value == null && jdbcType == null) {
jdbcType = configuration.getJdbcTypeForNull();
}
try {
//对预编译sql中的binding赋值,对参数值进行处理
typeHandler.setParameter(ps, i + 1, value, jdbcType);
} catch (TypeException | SQLException e) {
throw new TypeException("Could not set parameters for mapping: " + parameterMapping + ". Cause: " + e, e);
}
}
}
}
}
2.防止sql注入
setParameter方法最终会调用mysql-connection中的ClientPreparedStatement类的setString方法,如果参数值中有敏感字符,如单引号,双引号,回车等,需要进行相应处理,避免sql注入。
public void setString(int parameterIndex, String x) {
if (x == null) {
setNull(parameterIndex);
} else {
int stringLength = x.length();
String parameterAsString = x;
boolean needsQuoted = true;
//当参数字符串中敏感字符时进行处理
if (this.isLoadDataQuery || isEscapeNeededForString(x, stringLength)) {
needsQuoted = false; // saves an allocation later
StringBuilder buf = new StringBuilder((int) (x.length() * 1.1));
buf.append('\'');
//
// Note: buf.append(char) is _faster_ than appending in blocks, because the block append requires a System.arraycopy().... go figure...
//
for (int i = 0; i < stringLength; ++i) {
char c = x.charAt(i);
switch (c) {
case 0: /* Must be escaped for 'mysql' */
buf.append('\\');
buf.append('0');
break;
case '\n': /* Must be escaped for logs */
buf.append('\\');
buf.append('n');
break;
case '\r':
buf.append('\\');
buf.append('r');
break;
case '\\':
buf.append('\\');
buf.append('\\');
break;
case '\'':
buf.append('\'');
buf.append('\'');
break;
case '"': /* Better safe than sorry */
if (this.session.getServerSession().useAnsiQuotedIdentifiers()) {
buf.append('\\');
}
buf.append('"');
break;
case '\032': /* This gives problems on Win32 */
buf.append('\\');
buf.append('Z');
break;
case '\u00a5':
case '\u20a9':
// escape characters interpreted as backslash by mysql
if (this.charsetEncoder != null) {
CharBuffer cbuf = CharBuffer.allocate(1);
ByteBuffer bbuf = ByteBuffer.allocate(1);
cbuf.put(c);
cbuf.position(0);
this.charsetEncoder.encode(cbuf, bbuf, true);
if (bbuf.get(0) == '\\') {
buf.append('\\');
}
}
buf.append(c);
break;
default:
buf.append(c);
}
}
buf.append('\'');
parameterAsString = buf.toString();
}
byte[] parameterAsBytes = this.isLoadDataQuery ? StringUtils.getBytes(parameterAsString)
: (needsQuoted ? StringUtils.getBytesWrapped(parameterAsString, '\'', '\'', this.charEncoding)
: StringUtils.getBytes(parameterAsString, this.charEncoding));
setValue(parameterIndex, parameterAsBytes, MysqlType.VARCHAR);
}
}
对于select * from user where user_name=#{name}和’${name}‘,当输入参数为123456' or '1=1时,会有不同的结果。
对于${}情况,mybatis直接将参数值与sql语句拼接,形成第一种情况,
对于#{},会使用预编译,经setString转化后变为'123456'' or ''1=1',能有效避免sql注入。
select * from user where user_name= '123456' or '1=1'
select * from user where user_name= '123456'' or ''1=1'
总结
本文针对${}和#{}的解析过程进行分析,重点分析了#防止sql注入的原因,在工作开发中还是尽量使用#,避免sql注入。但是在order by后面的字段加单引号会导致排序失效,此时还是应该使用$符,有必要可以手动进行格式化。