spring中el表达式安全和扩展

spring中el表达式安全和扩展

0. 背景

Spring的核心技术SpEL底层采用反射的方式获取对象属性、调用方法、创建对象等。如果不加以限制有非常大的安全漏洞。
如果访问权限过大,系统接收的字符串,很容易就执行恶意程序.比如在上一章 Spring使用el表达式 第一小节中执行的表达式T(Runtime).getRuntime().exec('calc')就轻松运行了windows的计算器。

Spring 默认提供了两个context

  1. StandardEvaluationContext: 默认的context,可以访问任意对象属性、调用任意对象方法、创建任意对象
  2. SimpleEvaluationContext: 功能首先的上下文,可以快速限制部分能力,要想安全控制并且想省事,可以直接使用这个.

以下章节依次说明可以限制的地方以及实现方法.从开发角度来说可以是限制,也可以说是扩展

1. 限制类

在引擎加载具体字节码时,可以通过自定义 TypeLocator 限制某些类的加载.比如以下代码,实现一个白名单功能,为了针对性的处理,这个类直接从StandardTypeLocator扩展.然后重写 findType 方法

public class LimtClassTypeLocator extends StandardTypeLocator {
   Set<String> whiteClassSet = new HashSet<>();
   public LimtClassTypeLocator(String... className) {
   whiteClassSet = new HashSet<>(Arrays.asList(className));
   }
   @Override
   public Class<?> findType(String typeName) throws EvaluationException {
   if (this.whiteClassSet.contains(typeName)) {
   	return super.findType(typeName);
   }
   throw new EvaluationException("类名: " + typeName + "不允许调用");
   }
}

然后我们需依次测试一下几种情况

  • 使用T(ClassName)语法测试白名单之内和之外的class
  • 使用new ClassName()语法测试白名单之内和之外的class
  • 测试 inline list 语法 '{1,2,3}'
  • 测试 inline map 语法 '{1:'a',2:'b'}'

测试代码中可以访问的class设置为org.apache.commons.lang.StringUtils,测试代码如下

public void testLimtClass() {
  ExpressionParser elParser = new SpelExpressionParser();
  StandardEvaluationContext ctx = new StandardEvaluationContext();
  ctx.setTypeLocator(new LimtClassTypeLocator("org.apache.commons.lang.StringUtils"));
  System.out.println("===TypeLocator: 测试白名单的class===");
  String whiteExpr = "T(org.apache.commons.lang.StringUtils).substring('小游戏 地心侠士',4)";
  Object whiteValue = elParser.parseExpression(whiteExpr).getValue(ctx);
  System.out.println(whiteValue);
  System.out.println("===TypeLocator: 测试非法的class===");
  try {
  	String illegalClass = "T(org.apache.commons.lang.StringEscapeUtils).escapeHtml('小游戏 地心侠士')";
  	Object illegalValue = elParser.parseExpression(illegalClass).getValue(ctx);
  } catch (EvaluationException e) {
  	System.out.println(e.getMessage());
  }
  System.out.println("===TypeLocator: 测试非法构造函数==");
  String cotrExpr = "new java.text.SimpleDateFormat('yyyy-MM-dd HH:mm:ss')";
  try {
  	SimpleDateFormat df = (SimpleDateFormat) elParser.parseExpression(cotrExpr).getValue(ctx);
  	System.out.println("当前时间: " + df.format(new Date()));
  } catch (EvaluationException e) {
  	System.out.println(e.getMessage());
  }
  System.out.println("===TypeLocator: 测试拦截内联list===");
  String initList = "{'小游戏','地心侠士'}";
  // java.util.Collections$UnmodifiableRandomAccessList<?>
  List inlineLst = elParser.parseExpression(initList).getValue(ctx, List.class);
  inlineLst.forEach(System.out::println);
  System.out.println("===TypeLocator: 测试拦截内联Map===");
  //java.util.Collections$UnmodifiableMap<?, ?>
  String initMap = "{'gameType':'小游戏','gameName':'地心侠士'}";
  Map inlineMap = elParser.parseExpression(initMap).getValue(ctx, Map.class);
  inlineMap.forEach((k, v) -> System.out.println(k + " : " + v));
  }

测试允许结果如下

===TypeLocator: 测试白名单的class===
地心侠士
===TypeLocator: 测试非法的class===
类名: org.apache.commons.lang.StringEscapeUtils不允许调用
===TypeLocator: 测试非法构造函数==
EL1003E: A problem occurred whilst attempting to construct an object of type 'java.text.SimpleDateFormat' using arguments '(java.lang.String)'
===TypeLocator: 测试拦截内联list===
小游戏
地心侠士
===TypeLocator: 测试拦截内联Map===
gameType : 小游戏
gameName : 地心侠士

从测试结果可以看 LimtClassTypeLocator 有效拦截了非法的静态方法调用以及非法的构造函数调用. 但其中有一点,内联的list和map 拦截失败了

关键代码:ctx.setTypeLocator(new LimtClassTypeLocator("org.apache.commons.lang.StringUtils"))

2. 限制属性

spel引擎中,可以直接对对象属性进行读写操作.要限制属性读和写,需要通过实现PropertyAccessor接口.该接口提供4个方法,依次是两个读写判断,以及读写操作.
如果需要对属性值特殊处理,也可以通过此能力实现.比如把电话号中间几位改成星号.LimitPropertyAccessors这个类,不允许读的属性为gameType,
不允许写的属性为gameName.具体代码如下

public class LimitPropertyAccessors extends ReflectivePropertyAccessor {
  @Override
  public boolean canRead(EvaluationContext context, @Nullable Object target, String name) throws AccessException {
  	if ("gameType".equals(name)) {
  		throw new AccessException("gameType属性不允许访问");
  	}
  	return super.canRead(context, target, name);
  }
  @Override
  public boolean canWrite(EvaluationContext context, @Nullable Object target, String name) throws AccessException {
  	if ("gameName".equals(name)) {
  		throw new AccessException("gameName 属性不允许赋值");
  	}
  	return super.canWrite(context, target, name);
}
}

属性读测试代码如下:

public void testLimitReadProperty() {
  ElTestObject testObject = new ElTestObject("小游戏", "地心侠士");
  StandardEvaluationContext ctx = new StandardEvaluationContext();
  ctx.setPropertyAccessors(Arrays.asList(new LimitPropertyAccessors()));
  ExpressionParser elParser = new SpelExpressionParser();
  ctx.setVariable("var", testObject);
  String allowProp = "#var.gameName";
  System.out.println("===测试允许访问的属性 gameName===");
  Object value = elParser.parseExpression(allowProp).getValue(ctx);
  System.out.println("获取成功: " + value);
  String disallowProp = "#var.gameType";
  System.out.println("===测试禁止访问的属性 gameType ===");
  try {
  	value = elParser.parseExpression(disallowProp).getValue(ctx);
  	System.out.println(value);
  } catch (Exception e) {
  	System.out.println("属性访问失败: " + e.getMessage());
  }
}

运行结果如下:

===测试允许访问的属性 gameName===
获取成功: 地心侠士
===测试禁止访问的属性 gameType ===
属性访问失败: EL1021E: A problem occurred whilst attempting to access the property 'gameType': 'gameType属性不允许访问'

属性写测试代码如下:

public void testLimitWriteProperty() {
  ElTestObject testObject = new ElTestObject("小游戏", "地心侠士");
  System.out.println("===对象原始值===");
  System.out.println(testObject.toString());
  StandardEvaluationContext ctx = new StandardEvaluationContext();
  ctx.setPropertyAccessors(Arrays.asList(new LimitPropertyAccessors()));
  ExpressionParser elParser = new SpelExpressionParser();
  ctx.setVariable("var", testObject);
  String allowWriteProp = "#var.gameType='小游戏 666'";
  elParser.parseExpression(allowWriteProp).getValue(ctx);
  System.out.println("===测试可以赋值属性 gameType ===");
  System.out.println(testObject.toString());
  System.out.println("===测试不可以访问属性 gameName ===");
  String disAllowWriteProp = "#var.gameName='地心侠士 666'";
  try {
  	elParser.parseExpression(disAllowWriteProp).getValue(ctx);
  } catch (Exception e) {
  	System.out.println("属性赋值失败: " + e.getMessage());
  }
}

运行结果如下

===对象原始值===
ElTestObject [gameType=小游戏, gameName=地心侠士]
===测试可以赋值属性 gameType ===
ElTestObject [gameType=小游戏 666, gameName=地心侠士]
===测试不可以访问属性 gameName ===
属性赋值失败: EL1034E: A problem occurred whilst attempting to set the property 'gameName': gameName 属性不允许赋值

关键代码: ctx.setPropertyAccessors(Arrays.asList(new LimitPropertyAccessors()));

3. 限制方法

限制某些方法不能调用,需要实现MethodFilter接口.通过filter方法返回可以调用的方法.该接口定义为@FunctionalInterface可以直接使用lambda表达式实现.

测试代码如下:

public void testLimitMethod() {
  ElTestObject testObject = new ElTestObject("小游戏", "地心侠士");
  StandardEvaluationContext ctx = new StandardEvaluationContext();
  // 设置成只能调用 setGameName 方法 
  ctx.registerMethodFilter(ElTestObject.class, method -> {
  	return method.stream().filter(m -> m.getName().equals("getGameName")).toList();
  });
  ctx.setVariable("var", testObject);
  String limitMethod = "#var.getGameType()";
  System.out.println("===调用getGameType===");
  ExpressionParser elParser = new SpelExpressionParser();
  try {
  	Object limitValue = elParser.parseExpression(limitMethod).getValue(ctx);
  	System.out.println(limitValue);
  } catch (Exception e) {
  	System.out.println("调用getGameType失败:" + e.getMessage());
  }
  System.out.println("===调用getGameName===");
  String allowMethod = "#var.getGameName()";
  Object allowValue = elParser.parseExpression(allowMethod).getValue(ctx);
  System.out.println("获取成功: " + allowValue);
}

测试结果如下

===调用getGameType===
调用getGameType失败:EL1004E: Method call: Method getGameType() cannot be found on type com.herbert.script.SpringElScriptSafeAndExtend$ElTestObject
===调用getGameName===
获取成功: 地心侠士

关键代码: ctx.registerMethodFilter(ElTestObject.class, method->method)

4. 限制Bean

引擎使用@beanName语法,可以访问对应bean,针对一些特殊存在的bean,可以限制使用,这里需要实现接口BeanResolver
自定义一个BeanResolver.
代码如下:

public class LimtBeanResolver implements BeanResolver {
  ElTestObject testObject = new ElTestObject("小游戏", "地心侠士");
  @Override
  public Object resolve(EvaluationContext context, String beanName) throws AccessException {
  	if ("game".equals(beanName)) {
  		return testObject;
  	}
  	return new String("不允许访问的bean:[" + beanName + "]");
  }
}

从代码可知,如果传递的@game会返回实例testObject,其他则返回"不允许访问的bean:[" + beanName + "]"
测试代码如下

public void limitBean() {
  ExpressionParser elParser = new SpelExpressionParser();
  StandardEvaluationContext ctx = new StandardEvaluationContext();
  // ctx.setBeanResolver(new BeanFactoryResolver((BeanFactory) applicationContext));
  ctx.setBeanResolver(new LimtBeanResolver());
  Object value = elParser.parseExpression("@game").getValue(ctx);
  System.out.println("===测试允许访问的bean===");
  System.out.println(value);
  value = elParser.parseExpression("@other").getValue(ctx);
  System.out.println("===测试不允许访问的bean===");
  System.out.println(value);
}

运行结果如下

===测试允许访问的bean===
ElTestObject [gameType=小游戏, gameName=地心侠士]
===测试不允许访问的bean===
不允许访问的bean:[other]

关键代码: ctx.setBeanResolver(new LimtBeanResolver());

5. 限制内容

有时需要对应脚本中的参数内容做一些特殊处理,这时就需要通过TypeConverter对一些值做一些特殊处理.除此之外还可以通过PropertyAccessors实现.接下来,我们实现一个TypeConverter,主要功能是把参数中的666替换成999,代码如下

public class LimtTypeConvert extends StandardTypeConverter {
  @Override
  public @Nullable Object convertValue(@Nullable Object value, @Nullable TypeDescriptor sourceType, TypeDescriptor targetType) {
  	if (value.equals(666)) {
  		value = 999;
  	}
  	return super.convertValue(value, sourceType, targetType);
  }
}

测试代码如下

public void testLimtValue() {
  String initList = "'小游戏 地心侠士' + 666 ";
  ExpressionParser elParser = new SpelExpressionParser();
  StandardEvaluationContext ctx = new StandardEvaluationContext();
  ctx.setTypeConverter(new LimtTypeConvert());
  Object expValue = elParser.parseExpression(initList).getValue(ctx);
  System.out.println("===使用typeconvert,将666变成999===");
  System.out.println(expValue);
}

运行结果如下:

===使用typeconvert,将666变成999===
小游戏 地心侠士999

关键代码: ctx.setTypeConverter(new LimtTypeConvert());

6. 扩展函数

引擎中的函数扩展,实际就是把函数作为一个变量放到ctx中,然后通过访问对象的方式调用该函数.测试代码如下

public void testExtendFunction() throws NoSuchMethodException, SecurityException {
  ElTestObject testObject = new ElTestObject("小游戏", "地心侠士");
  ExpressionParser elParser = new SpelExpressionParser();
  StandardEvaluationContext ctx = new StandardEvaluationContext();
  Method method = ElTestObject.class.getMethod("joinString", String.class, String.class);
  // 内部调用 setVariable
  ctx.registerFunction("joinString", method);
  ctx.setVariable("var", testObject);
  ctx.setVariable("method", method);
  String extendMethod = "#joinString(#var.gameType,#var.gameName)";
  System.out.println("===测试扩展方法(registerFunction)===");
  Object value = elParser.parseExpression(extendMethod).getValue(ctx);
  System.out.println(value);

  extendMethod = "#method(#var.gameType,#var.gameName)";
  System.out.println("===测试扩展方法(setVariable)");
  value = elParser.parseExpression(extendMethod).getValue(ctx);
  System.out.println(value);
}

运行结果如下:

===测试扩展方法(registerFunction)===
注册方法调用成功: 小游戏 : 地心侠士
===测试扩展方法(setVariable)
注册方法调用成功: 小游戏 : 地心侠士

关键代码: ctx.registerFunction("joinString", method);

7. 操作符重写

操作符重写,只能重写部分数字相关的操作.并且操作符两边,至少有一边是数字才行.需要是想接口OperatorOverloader,我们这实现一个对象+数字的功能

public class AddExtendStringToObj implements OperatorOverloader {
  @Override
  public boolean overridesOperation(Operation operation, @Nullable Object leftOperand, @Nullable Object rightOperand) throws EvaluationException {
  	if (operation == Operation.ADD && leftOperand instanceof ElTestObject && NumberUtils.isNumber(rightOperand.toString())) {
  		return true;
  	}
  	return false;
  }
  @Override
  public Object operate(Operation operation, @Nullable Object leftOperand, @Nullable Object rightOperand) throws EvaluationException {
  	ElTestObject left = (ElTestObject) leftOperand;
  	left.setGameName(left.getGameName() + rightOperand.toString());
  	return leftOperand;
  }
}

测试代码如下:

public void testExtendOperator() {
  ElTestObject testObject = new ElTestObject("小游戏", "地心侠士");
  ExpressionParser elParser = new SpelExpressionParser();
  StandardEvaluationContext ctx = new StandardEvaluationContext();
  ctx.setOperatorOverloader(new AddExtendStringToObj());
  ctx.setVariable("var", testObject);
  String el = "#var + 666";
  Object value = elParser.parseExpression(el).getValue(ctx);
  System.out.println("===测试操作符重写===");
  System.out.println(value);
}

运行结果如下:

===测试操作符重写===
ElTestObject [gameType=小游戏, gameName=地心侠士666]

从测试结果可以看出 666 的数字被添加到对象gameName中.
关键代码: ctx.setOperatorOverloader(new AddExtendStringToObj());

8. SimpleEvaluationContext 使用

SimpleEvaluationContext是一个构造模式的上下文,需要使用build构造具体功能的上下文.
快速实现一个只读的上下文测试代码如下:

public void testOnlyRead() {
  ElTestObject testObject = new ElTestObject("小游戏", "地心侠士");
  // 不会主动注册 MethodResolver 不能访问方法
  SimpleEvaluationContext safeContxt = SimpleEvaluationContext.forReadOnlyDataBinding().build();
  safeContxt = SimpleEvaluationContext.forReadOnlyDataBinding(). build();
  safeContxt.setVariable("var", testObject);
  System.out.println("===测试只读模式 读取值===");
  String readExpr = "#var.gameName";
  ExpressionParser elParser = new SpelExpressionParser();
  Object readValue = elParser.parseExpression(readExpr).getValue(safeContxt);
  System.out.println(readValue);
  System.out.println("===测试只读模式 修改值===");
  String wirteExpr = "#var.gameName='地心侠士 666'";
  try {
  	elParser.parseExpression(wirteExpr).getValue(safeContxt);
  	System.out.println("属性修改成功");
  } catch (Exception e) {
  	System.out.println("属性内容修改失败:" + e.getMessage());
  }
  System.out.println("===测试安全模式 调用方法===");
  String elMethod = "#var.getGameName()";
  try {
  	elParser.parseExpression(elMethod).getValue(safeContxt);
  } catch (Exception e) {
  	System.out.println("方法调用失败:" + e.getMessage());
  }
  System.out.println(testObject.toString());
}

运行结果如下:

===测试只读模式 读取值===
地心侠士
===测试只读模式 修改值===
属性内容修改失败:EL1068E: The expression component '#var.gameName='地心侠士 666'' is not assignable
===测试安全模式 调用方法===
方法调用失败:EL1004E: Method call: Method getGameName() cannot be found on type com.herbert.script.SpringElScriptSafeAndExtend$ElTestObject
ElTestObject [gameType=小游戏, gameName=地心侠士]

从测试结果可以知道 SimpleEvaluationContext 需要在代码明确指定可访问的属性,比如上边的测试代码没有调用withMethodResolvers就不能调用对象方法.

关键代码: SimpleEvaluationContext.forReadOnlyDataBinding().build()

9. 总结

需要限制引擎能力,主要需要主要从类,属性,方法,内容层面限制.

  • 限制类 : org.springframework.expression.TypeLocator
  • 限制属性: org.springframework.expression.PropertyAccessor
  • 限制方法: org.springframework.expression.MethodResolver
  • 限制内容:org.springframework.expression.TypeConverter
  • 限制bean: org.springframework.expression.BeanResolver

扩展主要体现在 扩展方法 操作符重写 扩展索引访问

  • 扩展方法: ctx.registerFunction(String, Method)
  • 操作符重写: org.springframework.expression.OperatorOverloader
  • 扩展索引访问: org.springframework.expression.IndexAccessor

以上有完整测试代码,如有需要,请在微信公众号:小满小慢 回复spelsafe获取完整测试代码.

原文地址:https://mp.weixin.qq.com/s/jzkiCvMLVJVBjCCLHIn4Ww

posted @ 2025-12-12 12:49  _herbert  阅读(2)  评论(0)    收藏  举报